Ver Fonte

webapi: preliminary support for Android, AIM 8

Mike há 1 mês atrás
pai
commit
bd616cdf05
41 ficheiros alterados com 3498 adições e 1012 exclusões
  1. 7 6
      cmd/server/factory.go
  2. 6 1
      server/webapi/adapters.go
  3. 17 4
      server/webapi/handler.go
  4. 33 0
      server/webapi/handlers/aim_stub.go
  5. 3 0
      server/webapi/handlers/amf_encoder.go
  6. 202 71
      server/webapi/handlers/auth.go
  7. 163 90
      server/webapi/handlers/auth_test.go
  8. 210 79
      server/webapi/handlers/buddy_list_manager.go
  9. 273 0
      server/webapi/handlers/buddy_list_manager_test.go
  10. 359 142
      server/webapi/handlers/buddylist.go
  11. 683 273
      server/webapi/handlers/buddylist_test.go
  12. 47 3
      server/webapi/handlers/common.go
  13. 74 0
      server/webapi/handlers/common_test.go
  14. 95 0
      server/webapi/handlers/conversation_stub.go
  15. 2 2
      server/webapi/handlers/events.go
  16. 198 0
      server/webapi/handlers/login_psp.go
  17. 93 0
      server/webapi/handlers/login_psp_test.go
  18. 68 24
      server/webapi/handlers/messaging.go
  19. 38 0
      server/webapi/handlers/messaging_test.go
  20. 24 1
      server/webapi/handlers/presence.go
  21. 55 0
      server/webapi/handlers/presence_test.go
  22. 225 73
      server/webapi/handlers/session.go
  23. 40 0
      server/webapi/handlers/user_info_stub.go
  24. 15 4
      server/webapi/handlers/webapi_event_converter.go
  25. 86 51
      server/webapi/middleware/auth.go
  26. 18 0
      server/webapi/middleware/logging.go
  27. 105 19
      server/webapi/server.go
  28. 16 21
      server/webapi/types.go
  29. 37 0
      server/webapi/types/conversation.go
  30. 8 5
      server/webapi/types/events.go
  31. 32 11
      state/feedbag_list.go
  32. 10 0
      state/migrations/0034_drop_webapi_tokens.down.sql
  33. 3 0
      state/migrations/0034_drop_webapi_tokens.up.sql
  34. 1 0
      state/migrations/0035_seed_aim_web_api_key.down.sql
  35. 20 0
      state/migrations/0035_seed_aim_web_api_key.up.sql
  36. 0 0
      state/migrations/0036_linked_accounts.down.sql
  37. 0 0
      state/migrations/0036_linked_accounts.up.sql
  38. 0 114
      state/webapi_auth.go
  39. 119 0
      state/webapi_imlog.go
  40. 33 0
      state/webapi_imlog_test.go
  41. 80 18
      state/webapi_session.go

+ 7 - 6
cmd/server/factory.go

@@ -525,7 +525,7 @@ func WebAPI(deps Container) *webapi.Server {
 
 
 	// Create WebAPI buddy list manager (local to WebAPI)
 	// Create WebAPI buddy list manager (local to WebAPI)
 	buddyListManager := handlers.NewBuddyListManager(
 	buddyListManager := handlers.NewBuddyListManager(
-		feedbagAdapter,
+		deps.feedbagSvc,
 		deps.inMemorySessionManager,
 		deps.inMemorySessionManager,
 		logger,
 		logger,
 	)
 	)
@@ -621,9 +621,6 @@ func WebAPI(deps Container) *webapi.Server {
 		BuddyBroadcaster:      oscarBuddyBroadcaster,
 		BuddyBroadcaster:      oscarBuddyBroadcaster,
 		ProfileManager:        deps.sqLiteUserStore,
 		ProfileManager:        deps.sqLiteUserStore,
 		RelationshipFetcher:   deps.sqLiteUserStore,
 		RelationshipFetcher:   deps.sqLiteUserStore,
-		// Authentication support
-		UserManager: deps.sqLiteUserStore,
-		TokenStore:  deps.sqLiteUserStore.NewWebAPITokenStore(),
 		// Phase 3 additions
 		// Phase 3 additions
 		PreferenceManager: deps.sqLiteUserStore.NewWebPreferenceManager(),
 		PreferenceManager: deps.sqLiteUserStore.NewWebPreferenceManager(),
 		PermitDenyManager: deps.sqLiteUserStore.NewWebPermitDenyManager(),
 		PermitDenyManager: deps.sqLiteUserStore.NewWebPermitDenyManager(),
@@ -633,10 +630,14 @@ func WebAPI(deps Container) *webapi.Server {
 		// Phase 5 additions for buddy list and messaging
 		// Phase 5 additions for buddy list and messaging
 		BuddyListManager: buddyListManager,
 		BuddyListManager: buddyListManager,
 		// Phase 5 additions for chat rooms
 		// Phase 5 additions for chat rooms
-		ChatManager: deps.sqLiteUserStore.NewWebAPIChatManager(logger, deps.webAPISessionManager),
+		ChatManager:        deps.sqLiteUserStore.NewWebAPIChatManager(logger, deps.webAPISessionManager),
+		ChatSessionManager: deps.chatSessionManager,
+		RecalcWarning:      deps.icbmSvc.RestoreWarningLevel,
+		LowerWarnLevel:     deps.icbmSvc.UpdateWarnLevel,
+		FeedbagService:     deps.feedbagSvc,
 	}
 	}
 	// Pass SQLiteUserStore as the API key validator (it implements middleware.APIKeyValidator)
 	// Pass SQLiteUserStore as the API key validator (it implements middleware.APIKeyValidator)
-	return webapi.NewServer([]string{"0.0.0.0:9000"}, logger, handler, deps.sqLiteUserStore, deps.webAPISessionManager)
+	return webapi.NewServer([]string{"0.0.0.0:80"}, logger, handler, deps.sqLiteUserStore, deps.webAPISessionManager)
 }
 }
 
 
 // ICQLegacy creates a legacy ICQ server for v2-v5 protocols.
 // ICQLegacy creates a legacy ICQ server for v2-v5 protocols.

+ 6 - 1
server/webapi/adapters.go

@@ -140,7 +140,12 @@ func ICBMToWebAPIEvent(icbm wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) (types.E
 		Type:      types.EventTypeIM,
 		Type:      types.EventTypeIM,
 		Timestamp: time.Now().Unix(),
 		Timestamp: time.Now().Unix(),
 		Data: types.IMEvent{
 		Data: types.IMEvent{
-			From:      senderScreenName,
+			Source: types.UserInfo{
+				AimID:     senderScreenName,
+				DisplayID: senderScreenName,
+				UserType:  "aim",
+				State:     "online",
+			},
 			Message:   messageText,
 			Message:   messageText,
 			Timestamp: float64(time.Now().Unix()),
 			Timestamp: float64(time.Now().Unix()),
 			AutoResp:  autoResponse,
 			AutoResp:  autoResponse,

+ 17 - 4
server/webapi/handler.go

@@ -2,6 +2,7 @@ package webapi
 
 
 import (
 import (
 	"context"
 	"context"
+	"encoding/json"
 	"fmt"
 	"fmt"
 	"log/slog"
 	"log/slog"
 	"net/http"
 	"net/http"
@@ -38,9 +39,6 @@ type Handler struct {
 	RelationshipFetcher   interface {
 	RelationshipFetcher   interface {
 		Relationship(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) (state.Relationship, error)
 		Relationship(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) (state.Relationship, error)
 	}
 	}
-	// Authentication support
-	UserManager UserManager
-	TokenStore  TokenStore
 	// Phase 3 additions
 	// Phase 3 additions
 	PreferenceManager PreferenceManager
 	PreferenceManager PreferenceManager
 	PermitDenyManager PermitDenyManager
 	PermitDenyManager PermitDenyManager
@@ -50,10 +48,25 @@ type Handler struct {
 	// Phase 5 additions for buddy list and messaging
 	// Phase 5 additions for buddy list and messaging
 	BuddyListManager interface{}
 	BuddyListManager interface{}
 	// Phase 5 additions for chat rooms
 	// Phase 5 additions for chat rooms
-	ChatManager *state.WebAPIChatManager
+	ChatManager        *state.WebAPIChatManager
+	RecalcWarning      func(ctx context.Context, instance *state.SessionInstance) error
+	LowerWarnLevel     func(ctx context.Context, instance *state.SessionInstance)
+	ChatSessionManager ChatSessionManager
+	FeedbagService     FeedbagService
 }
 }
 
 
 func (h Handler) GetHelloWorldHandler(w http.ResponseWriter, r *http.Request) {
 func (h Handler) GetHelloWorldHandler(w http.ResponseWriter, r *http.Request) {
 	h.Logger.Info("got a request to the root endpoint", "method", r.Method, "path", r.URL.Path)
 	h.Logger.Info("got a request to the root endpoint", "method", r.Method, "path", r.URL.Path)
 	_, _ = fmt.Fprintf(w, "WebAPI Server Running\n")
 	_, _ = fmt.Fprintf(w, "WebAPI Server Running\n")
+	// Must return the same JSON envelope as other Web AIM APIs.
+	h.Logger.Info("webapi root GET", "remote", r.RemoteAddr, "host", r.Host, "path", r.URL.Path)
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+	resp := map[string]interface{}{
+		"response": map[string]interface{}{
+			"statusCode": 200,
+			"statusText": "OK",
+			"data":       map[string]interface{}{},
+		},
+	}
+	_ = json.NewEncoder(w).Encode(resp)
 }
 }

+ 33 - 0
server/webapi/handlers/aim_stub.go

@@ -0,0 +1,33 @@
+package handlers
+
+import (
+	"log/slog"
+	"net/http"
+)
+
+// AimStubHandler serves unimplemented Web AIM /aim/* endpoints the client
+// calls during normal startup (client-side storage, forward-domain config).
+type AimStubHandler struct {
+	Logger *slog.Logger
+}
+
+// SetForwardDomain acknowledges the client's forward-domain registration.
+// The Web AIM client fires this once when the session goes online; name may be
+// the literal string "null" for local/dev servers.
+func (h *AimStubHandler) SetForwardDomain(w http.ResponseWriter, r *http.Request) {
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	SendResponse(w, r, resp, h.Logger)
+}
+
+// GetData returns empty client-side data blobs (buddy list favorites, etc.).
+func (h *AimStubHandler) GetData(w http.ResponseWriter, r *http.Request) {
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]interface{}{
+		"items": []interface{}{},
+	}
+	SendResponse(w, r, resp, h.Logger)
+}

+ 3 - 0
server/webapi/handlers/amf_encoder.go

@@ -207,6 +207,9 @@ func (e *AMFEncoder) responseBodyToMap(body ResponseBody) map[string]interface{}
 		"statusCode": body.StatusCode,
 		"statusCode": body.StatusCode,
 		"statusText": body.StatusText,
 		"statusText": body.StatusText,
 	}
 	}
+	if body.RequestID != "" {
+		m["requestId"] = body.RequestID
+	}
 	if body.Data != nil {
 	if body.Data != nil {
 		m["data"] = e.toAMF3Compatible(body.Data)
 		m["data"] = e.toAMF3Compatible(body.Data)
 	} else {
 	} else {

+ 202 - 71
server/webapi/handlers/auth.go

@@ -1,43 +1,37 @@
 package handlers
 package handlers
 
 
 import (
 import (
+	"bytes"
 	"context"
 	"context"
 	"crypto/rand"
 	"crypto/rand"
 	"encoding/base64"
 	"encoding/base64"
 	"encoding/json"
 	"encoding/json"
+	"fmt"
 	"log/slog"
 	"log/slog"
 	"net/http"
 	"net/http"
+	"net/url"
+	"strings"
 	"time"
 	"time"
 
 
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
 )
 )
 
 
 // AuthHandler handles Web AIM API authentication endpoints.
 // AuthHandler handles Web AIM API authentication endpoints.
 type AuthHandler struct {
 type AuthHandler struct {
-	UserManager UserManager
-	TokenStore  TokenStore
+	AuthService AuthService
+	CookieBaker CookieBaker
+	UserManager UserRetriever
 	Logger      *slog.Logger
 	Logger      *slog.Logger
-	DisableAuth bool
 }
 }
 
 
-// UserManager defines methods for user authentication.
-type UserManager interface {
-	// AuthenticateUser verifies username and password
-	AuthenticateUser(ctx context.Context, username, password string) (*state.User, error)
-	// FindUserByScreenName finds a user by their screen name
-	FindUserByScreenName(ctx context.Context, screenName state.IdentScreenName) (*state.User, error)
-	// InsertUser creates a new user (for DISABLE_AUTH mode)
-	InsertUser(ctx context.Context, u state.User) error
+// UserRetriever looks up local AIM accounts.
+type UserRetriever interface {
+	User(ctx context.Context, screenName state.IdentScreenName) (*state.User, error)
 }
 }
 
 
-// TokenStore manages authentication tokens.
-type TokenStore interface {
-	// StoreToken saves an authentication token for a user
-	StoreToken(ctx context.Context, token string, screenName state.IdentScreenName, expiresAt time.Time) error
-	// ValidateToken checks if a token is valid and returns the associated screen name
-	ValidateToken(ctx context.Context, token string) (state.IdentScreenName, error)
-	// DeleteToken removes a token
-	DeleteToken(ctx context.Context, token string) error
+type OServiceService interface {
+	ClientOnline(ctx context.Context, service uint16, inBody wire.SNAC_0x01_0x02_OServiceClientOnline, instance *state.SessionInstance) error
 }
 }
 
 
 // ClientLoginRequest represents the request body for clientLogin.
 // ClientLoginRequest represents the request body for clientLogin.
@@ -47,6 +41,175 @@ type ClientLoginRequest struct {
 	DevID    string `json:"devId"`
 	DevID    string `json:"devId"`
 }
 }
 
 
+// GetToken handles GET /auth/getToken requests.
+// The Web AIM client uses this JSONP endpoint to exchange SSO session cookies for an API token.
+func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	devID := r.URL.Query().Get("devId")
+
+	loginID, tokenBytes, ok := h.resolveGetTokenSession(ctx, r)
+	if !ok || loginID == "" {
+		h.Logger.DebugContext(ctx, "getToken: no session, returning redirect",
+			"devId", devID,
+			"host", r.Host)
+		resp := BaseResponse{}
+		resp.Response.StatusCode = 401
+		resp.Response.StatusText = "Unauthorized"
+		resp.Response.Data = map[string]interface{}{
+			"redirectURL": h.loginRedirectURL(r),
+		}
+		SendResponse(w, r, resp, h.Logger)
+		return
+	}
+
+	if h.UserManager != nil {
+		user, err := h.UserManager.User(ctx, loginID.IdentScreenName())
+		if err != nil {
+			h.Logger.ErrorContext(ctx, "getToken: user lookup failed", "error", err, "loginId", loginID)
+			SendError(w, http.StatusInternalServerError, "internal server error")
+			return
+		}
+		if user == nil {
+			h.Logger.DebugContext(ctx, "getToken: user not found", "loginId", loginID)
+			resp := BaseResponse{}
+			resp.Response.StatusCode = 401
+			resp.Response.StatusText = "Unauthorized"
+			resp.Response.Data = map[string]interface{}{
+				"redirectURL": h.loginRedirectURL(r),
+			}
+			SendResponse(w, r, resp, h.Logger)
+			return
+		}
+	}
+
+	if len(tokenBytes) == 0 {
+		var err error
+		tokenBytes, err = h.issueAuthCookie(loginID, devID)
+		if err != nil {
+			h.Logger.ErrorContext(ctx, "getToken: failed to issue token", "error", err, "loginId", loginID)
+			SendError(w, http.StatusInternalServerError, "internal server error")
+			return
+		}
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]interface{}{
+		"token": map[string]interface{}{
+			"a":         base64.URLEncoding.EncodeToString(tokenBytes),
+			"expiresIn": "86400",
+		},
+		"userData": map[string]interface{}{
+			"attributes": map[string]interface{}{
+				"loginId": string(loginID),
+			},
+		},
+	}
+	SendResponse(w, r, resp, h.Logger)
+
+	h.Logger.InfoContext(ctx, "getToken succeeded", "loginId", loginID, "devId", devID)
+}
+
+func (h *AuthHandler) resolveGetTokenSession(ctx context.Context, r *http.Request) (state.DisplayScreenName, []byte, bool) {
+	if token := r.URL.Query().Get("a"); token != "" {
+		if loginID, cookie, ok := h.loginFromToken(token); ok {
+			return loginID, cookie, true
+		}
+	}
+
+	if c, err := r.Cookie("oldAimToken"); err == nil && c.Value != "" {
+		token, err := url.QueryUnescape(c.Value)
+		if err != nil {
+			token = c.Value
+		}
+		if loginID, cookie, ok := h.loginFromToken(token); ok {
+			return loginID, cookie, true
+		}
+	}
+
+	if c, err := r.Cookie("localAuthUser"); err == nil && c.Value != "" {
+		if loginID, ok := parseLocalAuthUser(c.Value); ok {
+			return loginID, nil, true
+		}
+	}
+
+	for _, name := range []string{"RSP_USER", "RSP_LOCAL"} {
+		if c, err := r.Cookie(name); err == nil {
+			if loginID, ok := parseRSPCookie(c.Value); ok {
+				return loginID, nil, true
+			}
+		}
+	}
+
+	return "", nil, false
+}
+
+func (h *AuthHandler) loginFromToken(token string) (state.DisplayScreenName, []byte, bool) {
+	rawCookie, err := base64.URLEncoding.DecodeString(strings.TrimSpace(token))
+	if err != nil {
+		return "", nil, false
+	}
+	serverCookie, err := h.AuthService.CrackCookie(rawCookie)
+	if err != nil {
+		return "", nil, false
+	}
+	return serverCookie.ScreenName, rawCookie, true
+}
+
+func parseLocalAuthUser(value string) (state.DisplayScreenName, bool) {
+	parts := strings.SplitN(value, "||", 2)
+	loginID := strings.TrimSpace(parts[0])
+	if loginID == "" {
+		return "", false
+	}
+	return state.DisplayScreenName(loginID), true
+}
+
+func parseRSPCookie(value string) (state.DisplayScreenName, bool) {
+	value = strings.TrimSpace(value)
+	if value == "" {
+		return "", false
+	}
+	if decoded, err := url.QueryUnescape(value); err == nil && decoded != "" {
+		value = decoded
+	}
+	// RSP cookies typically contain the screen name directly.
+	if strings.ContainsAny(value, " \t\r\n") {
+		return "", false
+	}
+	return state.DisplayScreenName(value), true
+}
+
+func (h *AuthHandler) issueAuthCookie(screenName state.DisplayScreenName, devID string) ([]byte, error) {
+	if h.CookieBaker == nil {
+		return nil, fmt.Errorf("cookie baker not configured")
+	}
+	clientID := devID
+	if clientID == "" {
+		clientID = "WebAIM"
+	}
+	serverCookie := state.ServerCookie{
+		Service:       wire.BOS,
+		ScreenName:    screenName,
+		ClientID:      clientID,
+		MultiConnFlag: uint8(wire.MultiConnFlagsRecentClient),
+	}
+	buf := &bytes.Buffer{}
+	if err := wire.MarshalBE(serverCookie, buf); err != nil {
+		return nil, err
+	}
+	return h.CookieBaker.Issue(buf.Bytes())
+}
+
+func (h *AuthHandler) loginRedirectURL(r *http.Request) string {
+	scheme := "http"
+	if r.TLS != nil {
+		scheme = "https"
+	}
+	return fmt.Sprintf("%s://%s/_cqr/login/login.psp", scheme, r.Host)
+}
+
 // ClientLogin handles POST /auth/clientLogin requests.
 // ClientLogin handles POST /auth/clientLogin requests.
 // This endpoint authenticates users and returns an authentication token.
 // This endpoint authenticates users and returns an authentication token.
 func (h *AuthHandler) ClientLogin(w http.ResponseWriter, r *http.Request) {
 func (h *AuthHandler) ClientLogin(w http.ResponseWriter, r *http.Request) {
@@ -97,59 +260,27 @@ func (h *AuthHandler) ClientLogin(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	// Authenticate user
-	user, err := h.UserManager.AuthenticateUser(r.Context(), username, password)
-	if err != nil {
-		// If DISABLE_AUTH is enabled and user doesn't exist, create the user
-		if h.DisableAuth && err.Error() == "user not found" {
-			h.Logger.Info("DISABLE_AUTH: Creating new user",
-				"username", username)
-
-			// Create new user with the provided username
-			newUser := state.User{
-				IdentScreenName:   state.NewIdentScreenName(username),
-				DisplayScreenName: state.DisplayScreenName(username),
-			}
-
-			// Insert the new user
-			if err := h.UserManager.InsertUser(r.Context(), newUser); err != nil {
-				h.Logger.Error("failed to create user",
-					"username", username,
-					"error", err)
-				SendError(w, http.StatusInternalServerError, "failed to create user")
-				return
-			}
-
-			// Try to authenticate again after creating the user
-			user, err = h.UserManager.AuthenticateUser(r.Context(), username, password)
-			if err != nil {
-				h.Logger.Error("failed to authenticate after creating user",
-					"username", username,
-					"error", err)
-				SendError(w, http.StatusInternalServerError, "internal server error")
-				return
-			}
-		} else {
-			h.Logger.Warn("authentication failed",
-				"username", username,
-				"error", err)
-			SendError(w, http.StatusUnauthorized, "authentication failed")
-			return
-		}
-	}
+	signonFrame := wire.FLAPSignonFrame{}
+	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsScreenName, username))
+	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsPlaintextPassword, password))
+	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsMultiConnFlags, wire.MultiConnFlagsRecentClient))
 
 
-	// Generate authentication token
-	token, err := h.generateToken()
+	block, err := h.AuthService.FLAPLogin(r.Context(), signonFrame, "")
 	if err != nil {
 	if err != nil {
-		h.Logger.Error("failed to generate token", "error", err)
+		h.Logger.DebugContext(r.Context(), err.Error())
 		SendError(w, http.StatusInternalServerError, "internal server error")
 		SendError(w, http.StatusInternalServerError, "internal server error")
 		return
 		return
 	}
 	}
 
 
-	// Store token with 24 hour expiry
-	expiresAt := time.Now().Add(24 * time.Hour)
-	if err := h.TokenStore.StoreToken(r.Context(), token, user.IdentScreenName, expiresAt); err != nil {
-		h.Logger.Error("failed to store token", "error", err)
+	if block.HasTag(wire.LoginTLVTagsErrorSubcode) {
+		h.Logger.DebugContext(r.Context(), "login failed")
+		SendError(w, http.StatusUnauthorized, "username and password required")
+		return
+	}
+
+	authCookie, ok := block.Bytes(wire.OServiceTLVTagsLoginCookie)
+	if !ok {
+		h.Logger.DebugContext(r.Context(), "login cookie not found")
 		SendError(w, http.StatusInternalServerError, "internal server error")
 		SendError(w, http.StatusInternalServerError, "internal server error")
 		return
 		return
 	}
 	}
@@ -168,11 +299,11 @@ func (h *AuthHandler) ClientLogin(w http.ResponseWriter, r *http.Request) {
 	resp.Response.StatusText = "OK"
 	resp.Response.StatusText = "OK"
 	resp.Response.Data = map[string]interface{}{
 	resp.Response.Data = map[string]interface{}{
 		"token": map[string]interface{}{
 		"token": map[string]interface{}{
-			"a":         token,
-			"expiresIn": 86400, // 24 hours in seconds
+			"a":         base64.URLEncoding.EncodeToString(authCookie),
+			"expiresIn": "86400", // 24 hours in seconds
 		},
 		},
-		"loginId":        string(user.DisplayScreenName),
-		"screenName":     string(user.DisplayScreenName),
+		"loginId":        username,
+		"screenName":     username,
 		"sessionSecret":  sessionSecret,
 		"sessionSecret":  sessionSecret,
 		"hostTime":       time.Now().Unix(),
 		"hostTime":       time.Now().Unix(),
 		"tokenExpiresIn": 86400, // 24 hours in seconds
 		"tokenExpiresIn": 86400, // 24 hours in seconds
@@ -183,7 +314,7 @@ func (h *AuthHandler) ClientLogin(w http.ResponseWriter, r *http.Request) {
 
 
 	h.Logger.Info("user authenticated successfully",
 	h.Logger.Info("user authenticated successfully",
 		"username", username,
 		"username", username,
-		"screenName", user.DisplayScreenName)
+		"screenName", username)
 }
 }
 
 
 // generateToken generates a secure random token.
 // generateToken generates a secure random token.

+ 163 - 90
server/webapi/handlers/auth_test.go

@@ -8,58 +8,157 @@ import (
 	"net/http/httptest"
 	"net/http/httptest"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
-	"time"
 
 
+	"github.com/google/uuid"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
 
 
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
 )
 )
 
 
-// MockUserManager is a mock implementation of UserManager
-type MockUserManager struct {
-	mock.Mock
+// testAuthService implements AuthService for ClientLogin tests (only FLAPLogin is exercised).
+type testAuthService struct {
+	flapLogin func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error)
 }
 }
 
 
-func (m *MockUserManager) AuthenticateUser(ctx context.Context, username, password string) (*state.User, error) {
-	args := m.Called(ctx, username, password)
-	if user := args.Get(0); user != nil {
-		return user.(*state.User), args.Error(1)
-	}
-	return nil, args.Error(1)
+func (t *testAuthService) BUCPChallenge(ctx context.Context, bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error) {
+	return wire.SNACMessage{}, nil
+}
+
+func (t *testAuthService) BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, advertisedHost string) (wire.SNACMessage, error) {
+	return wire.SNACMessage{}, nil
+}
+
+func (t *testAuthService) CrackCookie(authCookie []byte) (state.ServerCookie, error) {
+	return state.ServerCookie{}, nil
 }
 }
 
 
-func (m *MockUserManager) FindUserByScreenName(ctx context.Context, screenName state.IdentScreenName) (*state.User, error) {
-	args := m.Called(ctx, screenName)
-	if user := args.Get(0); user != nil {
-		return user.(*state.User), args.Error(1)
+func (t *testAuthService) RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, conf func(sess *state.Session)) (*state.SessionInstance, error) {
+	return nil, nil
+}
+
+func (t *testAuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+	if t.flapLogin != nil {
+		return t.flapLogin(ctx, inFrame, advertisedHost)
 	}
 	}
-	return nil, args.Error(1)
+	return wire.TLVRestBlock{}, nil
 }
 }
 
 
-func (m *MockUserManager) InsertUser(ctx context.Context, u state.User) error {
-	args := m.Called(ctx, u)
-	return args.Error(0)
+func (t *testAuthService) Signout(ctx context.Context, session *state.Session) {}
+
+func (t *testAuthService) SignoutChat(ctx context.Context, sess *state.Session) {}
+
+func successfulLoginBlock() wire.TLVRestBlock {
+	var b wire.TLVRestBlock
+	b.Append(wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("fake-auth-cookie-bytes")))
+	return b
 }
 }
 
 
-// MockTokenStore is a mock implementation of TokenStore
-type MockTokenStore struct {
-	mock.Mock
+func failedLoginBlock() wire.TLVRestBlock {
+	var b wire.TLVRestBlock
+	b.Append(wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, uint16(1)))
+	return b
 }
 }
 
 
-func (m *MockTokenStore) StoreToken(ctx context.Context, token string, screenName state.IdentScreenName, expiresAt time.Time) error {
-	args := m.Called(ctx, token, screenName, expiresAt)
-	return args.Error(0)
+type testCookieBaker struct {
+	issue func(data []byte) ([]byte, error)
 }
 }
 
 
-func (m *MockTokenStore) ValidateToken(ctx context.Context, token string) (state.IdentScreenName, error) {
-	args := m.Called(ctx, token)
-	return args.Get(0).(state.IdentScreenName), args.Error(1)
+func (t *testCookieBaker) Issue(data []byte) ([]byte, error) {
+	if t.issue != nil {
+		return t.issue(data)
+	}
+	return []byte("issued-cookie"), nil
+}
+
+func (t *testCookieBaker) Crack(data []byte) ([]byte, error) {
+	return data, nil
+}
+
+type testUserRetriever struct {
+	user *state.User
+	err  error
+}
+
+func (t *testUserRetriever) User(ctx context.Context, screenName state.IdentScreenName) (*state.User, error) {
+	if t.err != nil {
+		return nil, t.err
+	}
+	return t.user, nil
 }
 }
 
 
-func (m *MockTokenStore) DeleteToken(ctx context.Context, token string) error {
-	args := m.Called(ctx, token)
-	return args.Error(0)
+func TestAuthHandler_GetToken(t *testing.T) {
+	tests := []struct {
+		name         string
+		query        string
+		cookies      []*http.Cookie
+		user         *state.User
+		checkBody    func(*testing.T, string)
+		expectedCode int
+	}{
+		{
+			name:  "Success_LocalAuthUserCookie",
+			query: "f=json&attributes=loginId&devId=ao1yOLlHVHhsa3o6&c=_callbacks_._0mq8wqdav",
+			cookies: []*http.Cookie{
+				{Name: "localAuthUser", Value: "testuser||Test User"},
+			},
+			user: &state.User{},
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, "_callbacks_._0mq8wqdav(")
+				assert.Contains(t, body, `"statusCode":200`)
+				assert.Contains(t, body, `"loginId":"testuser"`)
+				assert.Contains(t, body, `"a":`)
+			},
+			expectedCode: http.StatusOK,
+		},
+		{
+			name:  "Unauthorized_NoSession",
+			query: "f=json&attributes=loginId&devId=ao1yOLlHVHhsa3o6&c=_callbacks_._abc",
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":401`)
+				assert.Contains(t, body, `"redirectURL"`)
+			},
+			expectedCode: http.StatusOK,
+		},
+		{
+			name:  "Unauthorized_UnknownUser",
+			query: "f=json&attributes=loginId&devId=dev123",
+			cookies: []*http.Cookie{
+				{Name: "localAuthUser", Value: "missing||Missing User"},
+			},
+			user: nil,
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":401`)
+				assert.Contains(t, body, `"redirectURL"`)
+			},
+			expectedCode: http.StatusOK,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			handler := &AuthHandler{
+				AuthService: &testAuthService{},
+				CookieBaker: &testCookieBaker{},
+				UserManager: &testUserRetriever{user: tt.user},
+				Logger:      slog.Default(),
+			}
+
+			req, err := http.NewRequest(http.MethodGet, "/auth/getToken?"+tt.query, nil)
+			assert.NoError(t, err)
+			for _, c := range tt.cookies {
+				req.AddCookie(c)
+			}
+
+			rr := httptest.NewRecorder()
+			handler.GetToken(rr, req)
+
+			assert.Equal(t, tt.expectedCode, rr.Code)
+			if tt.checkBody != nil {
+				tt.checkBody(t, rr.Body.String())
+			}
+		})
+	}
 }
 }
 
 
 func TestAuthHandler_ClientLogin(t *testing.T) {
 func TestAuthHandler_ClientLogin(t *testing.T) {
@@ -68,8 +167,7 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 		method             string
 		method             string
 		contentType        string
 		contentType        string
 		body               string
 		body               string
-		setupMocks         func(*MockUserManager, *MockTokenStore)
-		disableAuth        bool
+		auth               *testAuthService
 		expectedStatusCode int
 		expectedStatusCode int
 		checkResponse      func(*testing.T, string)
 		checkResponse      func(*testing.T, string)
 	}{
 	}{
@@ -78,13 +176,10 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 			method:      "POST",
 			method:      "POST",
 			contentType: "application/json",
 			contentType: "application/json",
 			body:        `{"username":"testuser","password":"testpass","devId":"dev123"}`,
 			body:        `{"username":"testuser","password":"testpass","devId":"dev123"}`,
-			setupMocks: func(um *MockUserManager, ts *MockTokenStore) {
-				user := &state.User{
-					IdentScreenName:   state.NewIdentScreenName("testuser"),
-					DisplayScreenName: state.DisplayScreenName("testuser"),
-				}
-				um.On("AuthenticateUser", mock.Anything, "testuser", "testpass").Return(user, nil)
-				ts.On("StoreToken", mock.Anything, mock.AnythingOfType("string"), state.NewIdentScreenName("testuser"), mock.AnythingOfType("time.Time")).Return(nil)
+			auth: &testAuthService{
+				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+					return successfulLoginBlock(), nil
+				},
 			},
 			},
 			expectedStatusCode: http.StatusOK,
 			expectedStatusCode: http.StatusOK,
 			checkResponse: func(t *testing.T, body string) {
 			checkResponse: func(t *testing.T, body string) {
@@ -100,13 +195,10 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 			method:      "POST",
 			method:      "POST",
 			contentType: "application/x-www-form-urlencoded",
 			contentType: "application/x-www-form-urlencoded",
 			body:        "s=testuser&pwd=testpass&devId=dev123",
 			body:        "s=testuser&pwd=testpass&devId=dev123",
-			setupMocks: func(um *MockUserManager, ts *MockTokenStore) {
-				user := &state.User{
-					IdentScreenName:   state.NewIdentScreenName("testuser"),
-					DisplayScreenName: state.DisplayScreenName("testuser"),
-				}
-				um.On("AuthenticateUser", mock.Anything, "testuser", "testpass").Return(user, nil)
-				ts.On("StoreToken", mock.Anything, mock.AnythingOfType("string"), state.NewIdentScreenName("testuser"), mock.AnythingOfType("time.Time")).Return(nil)
+			auth: &testAuthService{
+				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+					return successfulLoginBlock(), nil
+				},
 			},
 			},
 			expectedStatusCode: http.StatusOK,
 			expectedStatusCode: http.StatusOK,
 			checkResponse: func(t *testing.T, body string) {
 			checkResponse: func(t *testing.T, body string) {
@@ -114,39 +206,12 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 				assert.Contains(t, body, `"loginId":"testuser"`)
 				assert.Contains(t, body, `"loginId":"testuser"`)
 			},
 			},
 		},
 		},
-		{
-			name:        "Success_DisableAuth_NewUser",
-			method:      "POST",
-			contentType: "application/json",
-			body:        `{"username":"newuser","password":"pass123"}`,
-			disableAuth: true,
-			setupMocks: func(um *MockUserManager, ts *MockTokenStore) {
-				// First auth attempt fails - user not found
-				um.On("AuthenticateUser", mock.Anything, "newuser", "pass123").Return(nil, errors.New("user not found")).Once()
-				// User is created
-				um.On("InsertUser", mock.Anything, mock.MatchedBy(func(u state.User) bool {
-					return u.IdentScreenName == state.NewIdentScreenName("newuser")
-				})).Return(nil)
-				// Second auth attempt succeeds
-				user := &state.User{
-					IdentScreenName:   state.NewIdentScreenName("newuser"),
-					DisplayScreenName: state.DisplayScreenName("newuser"),
-				}
-				um.On("AuthenticateUser", mock.Anything, "newuser", "pass123").Return(user, nil)
-				ts.On("StoreToken", mock.Anything, mock.AnythingOfType("string"), state.NewIdentScreenName("newuser"), mock.AnythingOfType("time.Time")).Return(nil)
-			},
-			expectedStatusCode: http.StatusOK,
-			checkResponse: func(t *testing.T, body string) {
-				assert.Contains(t, body, `"statusCode":200`)
-				assert.Contains(t, body, `"loginId":"newuser"`)
-			},
-		},
 		{
 		{
 			name:               "Error_MissingUsername",
 			name:               "Error_MissingUsername",
 			method:             "POST",
 			method:             "POST",
 			contentType:        "application/json",
 			contentType:        "application/json",
 			body:               `{"username":"","password":"testpass"}`,
 			body:               `{"username":"","password":"testpass"}`,
-			setupMocks:         func(um *MockUserManager, ts *MockTokenStore) {},
+			auth:               &testAuthService{},
 			expectedStatusCode: http.StatusBadRequest,
 			expectedStatusCode: http.StatusBadRequest,
 			checkResponse: func(t *testing.T, body string) {
 			checkResponse: func(t *testing.T, body string) {
 				assert.Contains(t, body, "username and password required")
 				assert.Contains(t, body, "username and password required")
@@ -157,7 +222,7 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 			method:             "POST",
 			method:             "POST",
 			contentType:        "application/json",
 			contentType:        "application/json",
 			body:               `{"username":"testuser","password":""}`,
 			body:               `{"username":"testuser","password":""}`,
-			setupMocks:         func(um *MockUserManager, ts *MockTokenStore) {},
+			auth:               &testAuthService{},
 			expectedStatusCode: http.StatusBadRequest,
 			expectedStatusCode: http.StatusBadRequest,
 			checkResponse: func(t *testing.T, body string) {
 			checkResponse: func(t *testing.T, body string) {
 				assert.Contains(t, body, "username and password required")
 				assert.Contains(t, body, "username and password required")
@@ -168,12 +233,29 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 			method:      "POST",
 			method:      "POST",
 			contentType: "application/json",
 			contentType: "application/json",
 			body:        `{"username":"testuser","password":"wrongpass"}`,
 			body:        `{"username":"testuser","password":"wrongpass"}`,
-			setupMocks: func(um *MockUserManager, ts *MockTokenStore) {
-				um.On("AuthenticateUser", mock.Anything, "testuser", "wrongpass").Return(nil, errors.New("invalid credentials"))
+			auth: &testAuthService{
+				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+					return failedLoginBlock(), nil
+				},
 			},
 			},
 			expectedStatusCode: http.StatusUnauthorized,
 			expectedStatusCode: http.StatusUnauthorized,
 			checkResponse: func(t *testing.T, body string) {
 			checkResponse: func(t *testing.T, body string) {
-				assert.Contains(t, body, "authentication failed")
+				assert.Contains(t, body, "username and password required")
+			},
+		},
+		{
+			name:        "Error_FLAPLoginError",
+			method:      "POST",
+			contentType: "application/json",
+			body:        `{"username":"testuser","password":"testpass"}`,
+			auth: &testAuthService{
+				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+					return wire.TLVRestBlock{}, errors.New("boom")
+				},
+			},
+			expectedStatusCode: http.StatusInternalServerError,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, "internal server error")
 			},
 			},
 		},
 		},
 		{
 		{
@@ -181,7 +263,7 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 			method:             "POST",
 			method:             "POST",
 			contentType:        "application/json",
 			contentType:        "application/json",
 			body:               `{invalid json`,
 			body:               `{invalid json`,
-			setupMocks:         func(um *MockUserManager, ts *MockTokenStore) {},
+			auth:               &testAuthService{},
 			expectedStatusCode: http.StatusBadRequest,
 			expectedStatusCode: http.StatusBadRequest,
 			checkResponse: func(t *testing.T, body string) {
 			checkResponse: func(t *testing.T, body string) {
 				assert.Contains(t, body, "invalid JSON format")
 				assert.Contains(t, body, "invalid JSON format")
@@ -191,19 +273,13 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 
 
 	for _, tt := range tests {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
-			userManager := &MockUserManager{}
-			tokenStore := &MockTokenStore{}
 			logger := slog.Default()
 			logger := slog.Default()
 
 
 			handler := &AuthHandler{
 			handler := &AuthHandler{
-				UserManager: userManager,
-				TokenStore:  tokenStore,
+				AuthService: tt.auth,
 				Logger:      logger,
 				Logger:      logger,
-				DisableAuth: tt.disableAuth,
 			}
 			}
 
 
-			tt.setupMocks(userManager, tokenStore)
-
 			req, err := http.NewRequest(tt.method, "/auth/clientLogin", strings.NewReader(tt.body))
 			req, err := http.NewRequest(tt.method, "/auth/clientLogin", strings.NewReader(tt.body))
 			assert.NoError(t, err)
 			assert.NoError(t, err)
 			req.Header.Set("Content-Type", tt.contentType)
 			req.Header.Set("Content-Type", tt.contentType)
@@ -218,9 +294,6 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 			if tt.checkResponse != nil {
 			if tt.checkResponse != nil {
 				tt.checkResponse(t, responseBody)
 				tt.checkResponse(t, responseBody)
 			}
 			}
-
-			userManager.AssertExpectations(t)
-			tokenStore.AssertExpectations(t)
 		})
 		})
 	}
 	}
 }
 }

+ 210 - 79
server/webapi/handlers/buddy_list_manager.go

@@ -2,8 +2,12 @@ package handlers
 
 
 import (
 import (
 	"context"
 	"context"
+	"errors"
 	"fmt"
 	"fmt"
 	"log/slog"
 	"log/slog"
+	"math/rand"
+	"slices"
+	"strings"
 	"time"
 	"time"
 
 
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/state"
@@ -13,15 +17,15 @@ import (
 // BuddyListManager handles the conversion of OSCAR feedbag data
 // BuddyListManager handles the conversion of OSCAR feedbag data
 // to WebAPI buddy list format for web clients.
 // to WebAPI buddy list format for web clients.
 type BuddyListManager struct {
 type BuddyListManager struct {
-	feedbagRetriever FeedbagRetriever
+	feedbagService   FeedbagService
 	sessionRetriever SessionRetriever
 	sessionRetriever SessionRetriever
 	logger           *slog.Logger
 	logger           *slog.Logger
 }
 }
 
 
 // NewBuddyListManager creates a new instance of the buddy list manager.
 // NewBuddyListManager creates a new instance of the buddy list manager.
-func NewBuddyListManager(feedbagRetriever FeedbagRetriever, sessionRetriever SessionRetriever, logger *slog.Logger) *BuddyListManager {
+func NewBuddyListManager(feedbagService FeedbagService, sessionRetriever SessionRetriever, logger *slog.Logger) *BuddyListManager {
 	return &BuddyListManager{
 	return &BuddyListManager{
-		feedbagRetriever: feedbagRetriever,
+		feedbagService:   feedbagService,
 		sessionRetriever: sessionRetriever,
 		sessionRetriever: sessionRetriever,
 		logger:           logger,
 		logger:           logger,
 	}
 	}
@@ -46,7 +50,7 @@ type WebAPIBuddyInfo struct {
 	IdleTime     int      `json:"idleTime,omitempty"` // Minutes idle
 	IdleTime     int      `json:"idleTime,omitempty"` // Minutes idle
 	UserType     string   `json:"userType"`           // "aim", "icq", "admin"
 	UserType     string   `json:"userType"`           // "aim", "icq", "admin"
 	Bot          bool     `json:"bot"`
 	Bot          bool     `json:"bot"`
-	Service      string   `json:"service,omitempty"` // "aim", "icq"
+	Service      string   `json:"service,omitempty"` // "AIM", "ICQ" (Web AIM client compares case-sensitively)
 	PresenceIcon string   `json:"presenceIcon,omitempty"`
 	PresenceIcon string   `json:"presenceIcon,omitempty"`
 	BuddyIcon    string   `json:"buddyIcon,omitempty"`
 	BuddyIcon    string   `json:"buddyIcon,omitempty"`
 	Capabilities []string `json:"capabilities,omitempty"`
 	Capabilities []string `json:"capabilities,omitempty"`
@@ -54,99 +58,112 @@ type WebAPIBuddyInfo struct {
 }
 }
 
 
 // GetBuddyListForUser retrieves and converts the buddy list for a user.
 // GetBuddyListForUser retrieves and converts the buddy list for a user.
-func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, screenName state.IdentScreenName) ([]WebAPIBuddyGroup, error) {
-	// Retrieve feedbag items
-	items, err := m.feedbagRetriever.RetrieveFeedbag(ctx, screenName)
+func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, sess *state.WebAPISession) ([]WebAPIBuddyGroup, error) {
+	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
+	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf("failed to retrieve feedbag: %w", err)
 		return nil, fmt.Errorf("failed to retrieve feedbag: %w", err)
 	}
 	}
+	reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+	if !ok {
+		return nil, fmt.Errorf("failed to retrieve feedbag: unexpected reply type")
+	}
+	items := reply.Items
 
 
-	// Build group map
-	groupMap := make(map[uint16]string)
-	buddyGroupMap := make(map[uint16][]wire.FeedbagItem)
+	type buddy struct {
+		name  string
+		alias string
+	}
+	type group struct {
+		name    string
+		buddies map[uint16]buddy
+		order   []uint16
+	}
+	type feedbagBL struct {
+		order  []uint16
+		groups map[uint16]group
+	}
+	bl := feedbagBL{groups: make(map[uint16]group)}
 
 
 	for _, item := range items {
 	for _, item := range items {
-		switch item.ClassID {
-		case wire.FeedbagClassIdGroup:
-			// Store group name
-			groupMap[item.ItemID] = item.Name
-			buddyGroupMap[item.ItemID] = []wire.FeedbagItem{}
-		case wire.FeedbagClassIdBuddy:
-			// Add buddy to its group
-			if _, exists := buddyGroupMap[item.GroupID]; !exists {
-				// Create implicit group if it doesn't exist
-				buddyGroupMap[item.GroupID] = []wire.FeedbagItem{}
+		if item.ClassID != wire.FeedbagClassIdGroup {
+			continue
+		}
+		if item.GroupID == 0 {
+			val, hasVal := item.Uint16SliceBE(wire.FeedbagAttributesOrder)
+			if hasVal {
+				bl.order = val
 			}
 			}
-			buddyGroupMap[item.GroupID] = append(buddyGroupMap[item.GroupID], item)
+			continue
 		}
 		}
-	}
-
-	// Convert to WebAPI format
-	var groups []WebAPIBuddyGroup
-
-	// Add online group (virtual group for online buddies)
-	onlineGroup := WebAPIBuddyGroup{
-		Name:    "Online",
-		Buddies: []WebAPIBuddyInfo{},
-	}
-
-	// Process each group
-	for groupID, buddyItems := range buddyGroupMap {
-		groupName := groupMap[groupID]
-		if groupName == "" {
-			groupName = "Buddies" // Default group name
+		name := item.Name
+		if name == "" {
+			name = "Buddies"
 		}
 		}
-
-		group := WebAPIBuddyGroup{
-			Name:    groupName,
-			Buddies: []WebAPIBuddyInfo{},
+		g := group{
+			name:    name,
+			buddies: make(map[uint16]buddy),
 		}
 		}
+		val, _ := item.Uint16SliceBE(wire.FeedbagAttributesOrder)
+		g.order = val
+		bl.groups[item.GroupID] = g
+	}
 
 
-		// Process buddies in this group
-		for _, buddyItem := range buddyItems {
-			buddyInfo := m.getBuddyInfo(buddyItem.Name)
-
-			// Add to online group if buddy is online
-			if buddyInfo.State == "online" || buddyInfo.State == "away" || buddyInfo.State == "idle" {
-				onlineGroup.Buddies = append(onlineGroup.Buddies, buddyInfo)
+	for _, item := range items {
+		if item.ClassID != wire.FeedbagClassIdBuddy || item.Name == "" {
+			continue
+		}
+		if _, exists := bl.groups[item.GroupID]; !exists {
+			bl.groups[item.GroupID] = group{
+				name:    "Buddies",
+				buddies: make(map[uint16]buddy),
+				order:   nil,
 			}
 			}
-
-			group.Buddies = append(group.Buddies, buddyInfo)
 		}
 		}
-
-		// Only add group if it has buddies
-		if len(group.Buddies) > 0 {
-			groups = append(groups, group)
+		b := buddy{name: item.Name}
+		if val, hasVal := item.String(wire.FeedbagAttributesAlias); hasVal {
+			b.alias = val
 		}
 		}
+		g := bl.groups[item.GroupID]
+		g.buddies[item.ItemID] = b
+		bl.groups[item.GroupID] = g
 	}
 	}
 
 
-	// Add online group at the beginning if it has buddies
-	if len(onlineGroup.Buddies) > 0 {
-		groups = append([]WebAPIBuddyGroup{onlineGroup}, groups...)
-	}
-
-	// Always add an "Offline" group at the end for offline buddies
-	offlineGroup := WebAPIBuddyGroup{
-		Name:    "Offline",
-		Buddies: []WebAPIBuddyInfo{},
+	groupOrder := bl.order
+	if len(groupOrder) == 0 && len(bl.groups) > 0 {
+		groupOrder = make([]uint16, 0, len(bl.groups))
+		for gid := range bl.groups {
+			groupOrder = append(groupOrder, gid)
+		}
+		slices.Sort(groupOrder)
 	}
 	}
 
 
-	// Collect all offline buddies
-	for _, group := range groups {
-		if group.Name != "Online" {
-			for _, buddy := range group.Buddies {
-				if buddy.State == "offline" {
-					offlineGroup.Buddies = append(offlineGroup.Buddies, buddy)
-				}
+	var out []WebAPIBuddyGroup
+	for _, gid := range groupOrder {
+		g, ok := bl.groups[gid]
+		if !ok {
+			continue
+		}
+		groupName := g.name
+		if groupName == "" {
+			groupName = "Buddies"
+		}
+		wg := WebAPIBuddyGroup{Name: groupName, Buddies: []WebAPIBuddyInfo{}}
+		for _, bid := range g.order {
+			b, ok := g.buddies[bid]
+			if !ok {
+				continue
 			}
 			}
+			info := m.getBuddyInfo(b.name)
+			if b.alias != "" {
+				info.DisplayID = b.alias
+			}
+			wg.Buddies = append(wg.Buddies, info)
 		}
 		}
+		out = append(out, wg)
 	}
 	}
 
 
-	if len(offlineGroup.Buddies) > 0 {
-		groups = append(groups, offlineGroup)
-	}
-
-	return groups, nil
+	return out, nil
 }
 }
 
 
 // getBuddyInfo retrieves the current presence information for a buddy.
 // getBuddyInfo retrieves the current presence information for a buddy.
@@ -158,7 +175,7 @@ func (m *BuddyListManager) getBuddyInfo(buddyName string) WebAPIBuddyInfo {
 		State:     "offline",
 		State:     "offline",
 		UserType:  "aim",
 		UserType:  "aim",
 		Bot:       false,
 		Bot:       false,
-		Service:   "aim",
+		Service:   "AIM",
 	}
 	}
 
 
 	// Check if buddy is online
 	// Check if buddy is online
@@ -201,12 +218,17 @@ func (m *BuddyListManager) GetPresenceForBuddy(screenName string) WebAPIBuddyInf
 }
 }
 
 
 // GetOnlineBuddies returns a list of all online buddies for a user.
 // GetOnlineBuddies returns a list of all online buddies for a user.
-func (m *BuddyListManager) GetOnlineBuddies(ctx context.Context, userScreenName state.IdentScreenName) ([]WebAPIBuddyInfo, error) {
-	// Get user's buddy list
-	items, err := m.feedbagRetriever.RetrieveFeedbag(ctx, userScreenName)
+func (m *BuddyListManager) GetOnlineBuddies(ctx context.Context, sess *state.WebAPISession) ([]WebAPIBuddyInfo, error) {
+	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
+	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf("failed to retrieve feedbag: %w", err)
 		return nil, fmt.Errorf("failed to retrieve feedbag: %w", err)
 	}
 	}
+	reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+	if !ok {
+		return nil, fmt.Errorf("failed to retrieve feedbag: unexpected reply type")
+	}
+	items := reply.Items
 
 
 	var onlineBuddies []WebAPIBuddyInfo
 	var onlineBuddies []WebAPIBuddyInfo
 
 
@@ -223,6 +245,115 @@ func (m *BuddyListManager) GetOnlineBuddies(ctx context.Context, userScreenName
 	return onlineBuddies, nil
 	return onlineBuddies, nil
 }
 }
 
 
+// RemoveBuddyFromFeedbag removes a buddy from a group (or all groups if allGroups is true) using feedbag delete/update SNACs.
+func (m *BuddyListManager) RemoveBuddyFromFeedbag(ctx context.Context, sess *state.WebAPISession, buddyName, groupName string, allGroups bool) (resultCode string, err error) {
+	buddyName = strings.TrimSpace(buddyName)
+	if buddyName == "" {
+		return "error", fmt.Errorf("empty buddy")
+	}
+
+	if sess.OSCARSession == nil {
+		return "error", fmt.Errorf("no OSCAR session")
+	}
+
+	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
+	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
+	if err != nil {
+		m.logger.ErrorContext(ctx, "remove buddy: feedbag query failed", "err", err.Error())
+		return "error", err
+	}
+	reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+	if !ok {
+		return "error", fmt.Errorf("unexpected feedbag reply type")
+	}
+
+	fl := state.NewFeedbagList(reply.Items, rand.Intn)
+
+	target := groupName
+	if allGroups {
+		target = "*"
+	}
+	if err := fl.DeleteBuddy(target, buddyName); err != nil {
+		if errors.Is(err, state.ErrGroupNotFound) {
+			return "notFound", nil
+		}
+		m.logger.ErrorContext(ctx, "remove buddy: DeleteBuddy failed", "err", err.Error())
+		return "error", err
+	}
+
+	if pending := fl.PendingDeletes(); len(pending) > 0 {
+		delFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}
+		delBody := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: pending}
+		if _, err := m.feedbagService.DeleteItem(ctx, sess.OSCARSession, delFrame, delBody); err != nil {
+			m.logger.ErrorContext(ctx, "remove buddy: Feedbag DeleteItem failed", "err", err.Error())
+			return "error", err
+		}
+	} else {
+		return "notFound", nil
+	}
+
+	if pending := fl.PendingUpdates(); len(pending) > 0 {
+		upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
+		if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, pending); err != nil {
+			m.logger.ErrorContext(ctx, "remove buddy: Feedbag UpsertItem failed", "err", err.Error())
+			return "error", err
+		}
+	}
+
+	return "success", nil
+}
+
+// RemoveGroupFromFeedbag deletes a buddy group and updates the root order (TOC DelGroup).
+func (m *BuddyListManager) RemoveGroupFromFeedbag(ctx context.Context, sess *state.WebAPISession, requestedGroup string) (resultCode string, err error) {
+	req := strings.TrimSpace(requestedGroup)
+	if req == "" {
+		return "error", fmt.Errorf("empty group")
+	}
+	if sess.OSCARSession == nil {
+		return "error", fmt.Errorf("no OSCAR session")
+	}
+
+	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
+	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
+	if err != nil {
+		m.logger.ErrorContext(ctx, "remove group: feedbag query failed", "err", err.Error())
+		return "error", err
+	}
+	reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+	if !ok {
+		return "error", fmt.Errorf("unexpected feedbag reply type")
+	}
+
+	storedName, found := storedGroupNameForRequest(reply.Items, req)
+	if !found {
+		return "notFound", nil
+	}
+
+	fl := state.NewFeedbagList(reply.Items, rand.Intn)
+	fl.DeleteGroup(storedName)
+
+	if pending := fl.PendingDeletes(); len(pending) > 0 {
+		delFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}
+		delBody := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: pending}
+		if _, err := m.feedbagService.DeleteItem(ctx, sess.OSCARSession, delFrame, delBody); err != nil {
+			m.logger.ErrorContext(ctx, "remove group: Feedbag DeleteItem failed", "err", err.Error())
+			return "error", err
+		}
+	} else {
+		return "notFound", nil
+	}
+
+	if pending := fl.PendingUpdates(); len(pending) > 0 {
+		upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
+		if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, pending); err != nil {
+			m.logger.ErrorContext(ctx, "remove group: Feedbag UpsertItem failed", "err", err.Error())
+			return "error", err
+		}
+	}
+
+	return "success", nil
+}
+
 // FormatBuddyListEvent formats a buddy list for an event.
 // FormatBuddyListEvent formats a buddy list for an event.
 func (m *BuddyListManager) FormatBuddyListEvent(groups []WebAPIBuddyGroup) map[string]interface{} {
 func (m *BuddyListManager) FormatBuddyListEvent(groups []WebAPIBuddyGroup) map[string]interface{} {
 	// Convert groups to a format that AMF3 can properly encode
 	// Convert groups to a format that AMF3 can properly encode

+ 273 - 0
server/webapi/handlers/buddy_list_manager_test.go

@@ -0,0 +1,273 @@
+package handlers
+
+import (
+	"context"
+	"errors"
+	"log/slog"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+)
+
+func offlineWebAPIBuddy(aimID, displayID string) WebAPIBuddyInfo {
+	return WebAPIBuddyInfo{
+		AimID:     aimID,
+		DisplayID: displayID,
+		State:     "offline",
+		UserType:  "aim",
+		Bot:       false,
+		Service:   "AIM",
+	}
+}
+
+func buddyCountInGroups(groups []WebAPIBuddyGroup) int {
+	n := 0
+	for _, g := range groups {
+		n += len(g.Buddies)
+	}
+	return n
+}
+
+func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
+	ctx := context.Background()
+	owner := state.NewIdentScreenName("listowner")
+
+	tests := []struct {
+		name    string
+		fb      []wire.FeedbagItem
+		fbErr   error
+		want    []WebAPIBuddyGroup
+		wantErr string
+	}{
+		{
+			name:    "retrieve feedbag error",
+			fbErr:   errors.New("db unavailable"),
+			wantErr: "failed to retrieve feedbag",
+		},
+		{
+			name: "root group missing order attribute yields no groups",
+			fb: []wire.FeedbagItem{
+				{Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, TLVLBlock: wire.TLVLBlock{}},
+			},
+			want: nil,
+		},
+		{
+			name: "empty buddylist yields no groups",
+			fb: []wire.FeedbagItem{
+				{
+					Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{})}},
+				},
+			},
+			want: nil,
+		},
+		{
+			name: "single group with buddies",
+			fb: []wire.FeedbagItem{
+				{
+					Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
+				},
+				{Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1, 2})}}},
+				{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "user1", TLVLBlock: wire.TLVLBlock{}},
+				{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "user2", TLVLBlock: wire.TLVLBlock{}},
+			},
+			want: []WebAPIBuddyGroup{
+				{
+					Name: "Buddies",
+					Buddies: []WebAPIBuddyInfo{
+						offlineWebAPIBuddy("user1", "user1"),
+						offlineWebAPIBuddy("user2", "user2"),
+					},
+				},
+			},
+		},
+		{
+			name: "deny permit and pdinfo items do not produce groups",
+			fb: []wire.FeedbagItem{
+				{
+					Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{})}},
+				},
+				{ClassID: wire.FeedbagClassIDDeny, Name: "blockeduser"},
+				{ClassID: wire.FeedbagClassIDPermit, Name: "allowuser"},
+				{
+					ClassID:   wire.FeedbagClassIdPdinfo,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesPdMode, uint8(3))}},
+				},
+			},
+			want: nil,
+		},
+		{
+			name: "buddy with alias",
+			fb: []wire.FeedbagItem{
+				{
+					Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
+				},
+				{Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
+				{
+					ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "bob",
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesAlias, "Bob Smith")}},
+				},
+			},
+			want: []WebAPIBuddyGroup{
+				{
+					Name:    "Buddies",
+					Buddies: []WebAPIBuddyInfo{offlineWebAPIBuddy("bob", "Bob Smith")},
+				},
+			},
+		},
+		{
+			name: "buddy with note still listed note not exposed in WebAPI",
+			fb: []wire.FeedbagItem{
+				{
+					Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
+				},
+				{Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
+				{
+					ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "alice",
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesNote, "Friend from work")}},
+				},
+			},
+			want: []WebAPIBuddyGroup{
+				{
+					Name:    "Buddies",
+					Buddies: []WebAPIBuddyInfo{offlineWebAPIBuddy("alice", "alice")},
+				},
+			},
+		},
+		{
+			name: "multiple groups in root order",
+			fb: []wire.FeedbagItem{
+				{
+					Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100, 200})}},
+				},
+				{Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
+				{Name: "Family", GroupID: 200, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{2})}}},
+				{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "friend1", TLVLBlock: wire.TLVLBlock{}},
+				{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 200, Name: "mom", TLVLBlock: wire.TLVLBlock{}},
+			},
+			want: []WebAPIBuddyGroup{
+				{
+					Name:    "Buddies",
+					Buddies: []WebAPIBuddyInfo{offlineWebAPIBuddy("friend1", "friend1")},
+				},
+				{
+					Name:    "Family",
+					Buddies: []WebAPIBuddyInfo{offlineWebAPIBuddy("mom", "mom")},
+				},
+			},
+		},
+		{
+			name: "buddy order follows group order TLV not feedbag slice order",
+			fb: []wire.FeedbagItem{
+				{
+					Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
+				},
+				{Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{2, 1})}}},
+				{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "firstInSlice", TLVLBlock: wire.TLVLBlock{}},
+				{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "secondInSlice", TLVLBlock: wire.TLVLBlock{}},
+			},
+			want: []WebAPIBuddyGroup{
+				{
+					Name: "Buddies",
+					Buddies: []WebAPIBuddyInfo{
+						offlineWebAPIBuddy("secondInSlice", "secondInSlice"),
+						offlineWebAPIBuddy("firstInSlice", "firstInSlice"),
+					},
+				},
+			},
+		},
+		{
+			name: "group order follows root order TLV not feedbag slice order",
+			fb: []wire.FeedbagItem{
+				{
+					Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{200, 100})}},
+				},
+				{Name: "Family", GroupID: 200, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{2})}}},
+				{Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
+				{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "inBuddies", TLVLBlock: wire.TLVLBlock{}},
+				{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 200, Name: "inFamily", TLVLBlock: wire.TLVLBlock{}},
+			},
+			want: []WebAPIBuddyGroup{
+				{
+					Name:    "Family",
+					Buddies: []WebAPIBuddyInfo{offlineWebAPIBuddy("inFamily", "inFamily")},
+				},
+				{
+					Name:    "Buddies",
+					Buddies: []WebAPIBuddyInfo{offlineWebAPIBuddy("inBuddies", "inBuddies")},
+				},
+			},
+		},
+		{
+			name: "unnamed group becomes Buddies",
+			fb: []wire.FeedbagItem{
+				{
+					Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
+				},
+				{Name: "", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
+				{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "solo", TLVLBlock: wire.TLVLBlock{}},
+			},
+			want: []WebAPIBuddyGroup{
+				{
+					Name:    "Buddies",
+					Buddies: []WebAPIBuddyInfo{offlineWebAPIBuddy("solo", "solo")},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			fs := &MockFeedbagService{}
+			sr := &MockSessionRetriever{}
+			if tt.fbErr != nil {
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).Return(wire.SNACMessage{}, tt.fbErr).Once()
+			} else {
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).Return(
+					wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: tt.fb}}, nil,
+				).Once()
+				if bc := buddyCountInGroups(tt.want); bc > 0 {
+					sr.On("RetrieveSession", mock.Anything).Return((*state.Session)(nil)).Times(bc)
+				}
+			}
+
+			m := NewBuddyListManager(fs, sr, slog.Default())
+			sess := &state.WebAPISession{ScreenName: state.DisplayScreenName(owner.String())}
+			got, err := m.GetBuddyListForUser(ctx, sess)
+
+			if tt.wantErr != "" {
+				assert.ErrorContains(t, err, tt.wantErr)
+				assert.Nil(t, got)
+				fs.AssertExpectations(t)
+				return
+			}
+			assert.NoError(t, err)
+			assert.Equal(t, tt.want, got)
+			fs.AssertExpectations(t)
+			if tt.fbErr == nil && buddyCountInGroups(tt.want) > 0 {
+				sr.AssertExpectations(t)
+			}
+		})
+	}
+}

+ 359 - 142
server/webapi/handlers/buddylist.go

@@ -3,6 +3,7 @@ package handlers
 import (
 import (
 	"context"
 	"context"
 	"log/slog"
 	"log/slog"
+	"math/rand"
 	"net/http"
 	"net/http"
 	"strings"
 	"strings"
 
 
@@ -19,48 +20,74 @@ type WebAPISessionManager interface {
 
 
 // BuddyListHandler handles Web AIM API buddy list management endpoints.
 // BuddyListHandler handles Web AIM API buddy list management endpoints.
 type BuddyListHandler struct {
 type BuddyListHandler struct {
-	SessionManager WebAPISessionManager
-	FeedbagManager FeedbagManager
-	Logger         *slog.Logger
+	SessionManager   WebAPISessionManager
+	BuddyListManager *BuddyListManager
+	Logger           *slog.Logger
+	FeedbagService   FeedbagService
+	mux              *http.ServeMux
 }
 }
 
 
-// FeedbagManager provides methods to manage buddy lists.
-type FeedbagManager interface {
-	RetrieveFeedbag(ctx context.Context, screenName state.IdentScreenName) ([]wire.FeedbagItem, error)
-	InsertItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error
-	UpdateItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error
-	DeleteItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error
+func NewBuddyListHandler(sessionManager WebAPISessionManager, blm *BuddyListManager, logger *slog.Logger, feedbagService FeedbagService) *BuddyListHandler {
+	h := &BuddyListHandler{
+		SessionManager:   sessionManager,
+		BuddyListManager: blm,
+		Logger:           logger,
+		FeedbagService:   feedbagService,
+	}
+	m := http.NewServeMux()
+	m.Handle("GET /buddylist/addBuddy", h.SessionMiddleware(h.AddBuddy))
+	m.Handle("GET /buddylist/addGroup", h.SessionMiddleware(h.AddGroup))
+	m.Handle("GET /buddylist/removeBuddy", h.SessionMiddleware(h.RemoveBuddy))
+	m.Handle("GET /buddylist/removeGroup", h.SessionMiddleware(h.RemoveGroup))
+	h.mux = m
+	return h
 }
 }
 
 
-// AddBuddy handles GET /buddylist/addBuddy requests.
-func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-
-	// Get session ID from parameters
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
-		return
-	}
+func (h *BuddyListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	h.mux.ServeHTTP(w, r)
+}
 
 
-	// Get session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		switch err {
-		case state.ErrNoWebAPISession:
-			h.sendError(w, http.StatusNotFound, "session not found")
-		case state.ErrWebAPISessionExpired:
-			h.sendError(w, http.StatusGone, "session expired")
-		default:
-			h.sendError(w, http.StatusInternalServerError, "internal server error")
+func (h *BuddyListHandler) SessionMiddleware(next func(http.ResponseWriter, *http.Request, *state.WebAPISession)) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		aimsid := r.URL.Query().Get("aimsid")
+		if aimsid == "" {
+			h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
+			return
 		}
 		}
-		return
-	}
+		session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+		if err != nil {
+			switch err {
+			case state.ErrNoWebAPISession:
+				h.sendError(w, http.StatusNotFound, "session not found")
+			case state.ErrWebAPISessionExpired:
+				h.sendError(w, http.StatusGone, "session expired")
+			default:
+				h.sendError(w, http.StatusInternalServerError, "internal server error")
+			}
+			return
+		}
+		_ = h.SessionManager.TouchSession(r.Context(), aimsid)
+		next(w, r, session)
+	})
+}
+
+type FeedbagService interface {
+	DeleteItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (*wire.SNACMessage, error)
+	Query(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error)
+	QueryIfModified(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error)
+	RespondAuthorizeToHost(ctx context.Context, instance state.IdentScreenName, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error
+	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+	StartCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster)
+	EndCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) error
+	UpsertItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) (*wire.SNACMessage, error)
+	Use(ctx context.Context, instance *state.SessionInstance) error
+}
 
 
-	// Touch the session
-	_ = h.SessionManager.TouchSession(r.Context(), aimsid)
+// AddBuddy handles GET /buddylist/addBuddy requests.
+func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+	ctx := r.Context()
+	aimsid := r.URL.Query().Get("aimsid")
 
 
-	// Get buddy and group parameters
 	buddyName := strings.TrimSpace(r.URL.Query().Get("buddy"))
 	buddyName := strings.TrimSpace(r.URL.Query().Get("buddy"))
 	groupName := strings.TrimSpace(r.URL.Query().Get("group"))
 	groupName := strings.TrimSpace(r.URL.Query().Get("group"))
 
 
@@ -74,10 +101,10 @@ func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 
 
 	// Add buddy to feedbag
 	// Add buddy to feedbag
-	resultCode, buddyInfo := h.addBuddyToFeedbag(ctx, session.ScreenName.IdentScreenName(), buddyName, groupName)
+	resultCode, buddyInfo := h.addBuddyToFeedbag(ctx, session, buddyName, groupName)
 
 
 	// Prepare response
 	// Prepare response
-	responseData := map[string]interface{}{
+	responseData := map[string]any{
 		"resultCode": resultCode,
 		"resultCode": resultCode,
 	}
 	}
 	if resultCode == "success" {
 	if resultCode == "success" {
@@ -90,14 +117,14 @@ func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request) {
 	resp.Response.Data = responseData
 	resp.Response.Data = responseData
 	SendResponse(w, r, resp, h.Logger)
 	SendResponse(w, r, resp, h.Logger)
 
 
-	if resultCode == "success" && session.EventQueue != nil {
-		// Push buddy list update event to the session's event queue
-		event := types.BuddyListEvent{
-			Action: "add",
-			Buddy:  buddyInfo,
-			Group:  groupName,
+	if resultCode == "success" {
+		groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
+		if err != nil {
+			h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
+		} else {
+			blPayload := map[string]any{"groups": groups}
+			session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
 		}
 		}
-		session.EventQueue.Push(types.EventTypeBuddyList, event)
 	}
 	}
 
 
 	h.Logger.InfoContext(ctx, "buddy added",
 	h.Logger.InfoContext(ctx, "buddy added",
@@ -108,81 +135,273 @@ func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request) {
 	)
 	)
 }
 }
 
 
-// addBuddyToFeedbag adds a buddy to the user's feedbag.
-func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, screenName state.IdentScreenName, buddyName, groupName string) (string, *BuddyPresenceInfo) {
-	// Retrieve current feedbag
-	items, err := h.FeedbagManager.RetrieveFeedbag(ctx, screenName)
+// AddGroup handles GET /buddylist/addGroup requests.
+func (h *BuddyListHandler) AddGroup(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+	ctx := r.Context()
+	aimsid := r.URL.Query().Get("aimsid")
+
+	groupName := strings.TrimSpace(r.URL.Query().Get("group"))
+	if groupName == "" {
+		h.sendError(w, http.StatusBadRequest, "missing group parameter")
+		return
+	}
+
+	resultCode := h.addGroupToFeedbag(ctx, session, groupName)
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]any{
+		"resultCode": resultCode,
+	}
+	SendResponse(w, r, resp, h.Logger)
+
+	if resultCode == "success" {
+		groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
+		if err != nil {
+			h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
+		} else {
+			blPayload := map[string]any{"groups": groups}
+			session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
+		}
+	}
+
+	h.Logger.InfoContext(ctx, "buddy list group added",
+		"aimsid", aimsid,
+		"group", groupName,
+		"result", resultCode,
+	)
+}
+
+func (h *BuddyListHandler) addGroupToFeedbag(ctx context.Context, sess *state.WebAPISession, groupName string) string {
+	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
+	snac, err := h.FeedbagService.Query(ctx, sess.OSCARSession, frame)
 	if err != nil {
 	if err != nil {
 		h.Logger.ErrorContext(ctx, "failed to retrieve feedbag", "err", err.Error())
 		h.Logger.ErrorContext(ctx, "failed to retrieve feedbag", "err", err.Error())
-		return "error", nil
+		return "error"
 	}
 	}
 
 
-	// Check if buddy already exists
-	for _, item := range items {
-		if item.ClassID == wire.FeedbagClassIdBuddy && item.Name == buddyName {
-			// Buddy already exists
-			return "alreadyExists", nil
-		}
+	reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+	if !ok {
+		return "error"
 	}
 	}
 
 
-	// Find or create the group
-	var groupID uint16
-	groupFound := false
-	maxGroupID := uint16(0)
+	fl := state.NewFeedbagList(reply.Items, rand.Intn)
+	fl.AddGroup(groupName)
 
 
-	for _, item := range items {
-		if item.ClassID == wire.FeedbagClassIdGroup {
-			if item.ItemID > maxGroupID {
-				maxGroupID = item.ItemID
-			}
+	pending := fl.PendingUpdates()
+	if len(pending) == 0 {
+		return "alreadyExists"
+	}
 
 
-			// Check group name
-			if item.Name == groupName {
-				groupID = item.ItemID
-				groupFound = true
-			}
+	insertFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
+	if _, err := h.FeedbagService.UpsertItem(ctx, sess.OSCARSession, insertFrame, pending); err != nil {
+		h.Logger.ErrorContext(ctx, "failed to add group", "err", err.Error())
+		return "error"
+	}
+
+	return "success"
+}
+
+// feedbagGroupMatchesRequested returns true if a feedbag group row matches the
+// group the Web client asked for. OSCAR often stores the default group with an
+// empty name; GetBuddyListForUser labels that as "Buddies", so addBuddy must
+// treat "" and "Buddies" as the same bucket when the client sends group=Buddies.
+func feedbagGroupMatchesRequested(storedName, requested string) bool {
+	req := strings.TrimSpace(requested)
+	st := strings.TrimSpace(storedName)
+	if strings.EqualFold(st, req) {
+		return true
+	}
+	if strings.EqualFold(strings.TrimSpace(req), "Buddies") && st == "" {
+		return true
+	}
+	return false
+}
+
+// findFeedbagGroupID returns the ItemID of a group matching requested, or false if none.
+func findFeedbagGroupID(items []wire.FeedbagItem, requested string) (uint16, bool) {
+	for _, item := range items {
+		if item.ClassID != wire.FeedbagClassIdGroup {
+			continue
+		}
+		if feedbagGroupMatchesRequested(item.Name, requested) {
+			return item.ItemID, true
 		}
 		}
 	}
 	}
+	return 0, false
+}
 
 
-	// If group doesn't exist, create it
-	if !groupFound {
-		groupID = maxGroupID + 1
-		groupItem := wire.FeedbagItem{
-			ItemID:    groupID,
-			ClassID:   wire.FeedbagClassIdGroup,
-			Name:      groupName,
-			GroupID:   0,
-			TLVLBlock: wire.TLVLBlock{},
+// storedGroupNameForRequest returns the feedbag group row Name for a Web client group label.
+// Rows with GroupID 0 are the root order record, not a named buddy group.
+func storedGroupNameForRequest(items []wire.FeedbagItem, requested string) (string, bool) {
+	for _, item := range items {
+		if item.ClassID != wire.FeedbagClassIdGroup {
+			continue
 		}
 		}
+		if item.GroupID == 0 {
+			continue
+		}
+		if feedbagGroupMatchesRequested(item.Name, requested) {
+			return item.Name, true
+		}
+	}
+	return "", false
+}
 
 
-		if err := h.FeedbagManager.InsertItem(ctx, screenName, groupItem); err != nil {
-			h.Logger.ErrorContext(ctx, "failed to create group", "err", err.Error())
-			return "error", nil
+// RemoveBuddy handles GET /buddylist/removeBuddy requests.
+func (h *BuddyListHandler) RemoveBuddy(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+	ctx := r.Context()
+	aimsid := r.URL.Query().Get("aimsid")
+
+	buddyName := strings.TrimSpace(r.URL.Query().Get("buddy"))
+	groupName := strings.TrimSpace(r.URL.Query().Get("group"))
+	allGroupsParam := r.URL.Query().Get("allGroups")
+	allGroups := allGroupsParam == "true" || allGroupsParam == "1"
+	if buddyName == "" {
+		h.sendError(w, http.StatusBadRequest, "missing buddy parameter")
+		return
+	}
+
+	resultCode, rmErr := h.BuddyListManager.RemoveBuddyFromFeedbag(ctx, session, buddyName, groupName, allGroups)
+	if rmErr != nil {
+		h.Logger.ErrorContext(ctx, "remove buddy failed", "err", rmErr.Error())
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]any{
+		"resultCode": resultCode,
+	}
+	SendResponse(w, r, resp, h.Logger)
+
+	if resultCode == "success" {
+		groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
+		if err != nil {
+			h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
+		} else {
+			blPayload := map[string]any{"groups": groups}
+			session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
 		}
 		}
 	}
 	}
 
 
-	// Find next available item ID for buddy
-	maxBuddyID := uint16(0)
-	for _, item := range items {
-		if item.ClassID == wire.FeedbagClassIdBuddy && item.ItemID > maxBuddyID {
-			maxBuddyID = item.ItemID
+	h.Logger.InfoContext(ctx, "buddy removed",
+		"aimsid", aimsid,
+		"buddy", buddyName,
+		"group", groupName,
+		"result", resultCode,
+	)
+}
+
+// todo don't remove empty group?
+// RemoveGroup handles GET /buddylist/removeGroup requests.
+func (h *BuddyListHandler) RemoveGroup(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+	ctx := r.Context()
+	aimsid := r.URL.Query().Get("aimsid")
+
+	groupName := strings.TrimSpace(r.URL.Query().Get("group"))
+	if groupName == "" {
+		h.sendError(w, http.StatusBadRequest, "missing group parameter")
+		return
+	}
+
+	resultCode, rmErr := h.BuddyListManager.RemoveGroupFromFeedbag(ctx, session, groupName)
+	if rmErr != nil {
+		h.Logger.ErrorContext(ctx, "remove group failed", "err", rmErr.Error())
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]any{
+		"resultCode": resultCode,
+	}
+	SendResponse(w, r, resp, h.Logger)
+
+	if resultCode == "success" {
+		groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
+		if err != nil {
+			h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
+		} else {
+			blPayload := map[string]any{"groups": groups}
+			session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
 		}
 		}
 	}
 	}
 
 
-	// Create buddy item
-	buddyItem := wire.FeedbagItem{
-		ItemID:    maxBuddyID + 1,
-		ClassID:   wire.FeedbagClassIdBuddy,
-		Name:      buddyName,
-		GroupID:   groupID,
-		TLVLBlock: wire.TLVLBlock{},
+	h.Logger.InfoContext(ctx, "buddy list group removed",
+		"aimsid", aimsid,
+		"group", groupName,
+		"result", resultCode,
+	)
+}
+
+// addBuddyToFeedbag adds a buddy to the user's feedbag.
+func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, sess *state.WebAPISession, buddyName, groupName string) (string, *BuddyPresenceInfo) {
+	// Retrieve current feedbag
+	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
+	snac, err := h.FeedbagService.Query(ctx, sess.OSCARSession, frame)
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "failed to retrieve feedbag", "err", err.Error())
+		return "error", nil
+	}
+
+	reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+	if !ok {
+		// todo what
+		return "error", nil
+	}
+
+	fl := state.NewFeedbagList(reply.Items, rand.Intn)
+
+	fl.AddGroup(groupName)
+	if pending := fl.PendingUpdates(); len(pending) > 0 {
+		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
+		if _, err := h.FeedbagService.UpsertItem(ctx, sess.OSCARSession, frame, pending); err != nil {
+			h.Logger.ErrorContext(ctx, "failed to add buddy", "err", err.Error())
+			return "error", nil
+		}
 	}
 	}
 
 
-	// Insert buddy into feedbag
-	if err := h.FeedbagManager.InsertItem(ctx, screenName, buddyItem); err != nil {
-		h.Logger.ErrorContext(ctx, "failed to add buddy", "err", err.Error())
+	added, err := fl.AddBuddy(groupName, buddyName, "", "")
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "failed to add buddy to feedbag", "err", err.Error())
 		return "error", nil
 		return "error", nil
 	}
 	}
+	if !added {
+		return "alreadyExists", nil
+	}
+
+	if pending := fl.PendingUpdates(); len(pending) > 0 {
+
+		buddyItems := make(map[uint16][]wire.FeedbagItem)
+		for _, item := range pending {
+			if item.ClassID == wire.FeedbagClassIdBuddy {
+				if _, ok := buddyItems[item.GroupID]; !ok {
+					buddyItems[item.GroupID] = nil
+				}
+				buddyItems[item.GroupID] = append(buddyItems[item.GroupID], item)
+			}
+		}
+
+		for _, buddies := range buddyItems {
+			frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
+			if _, err := h.FeedbagService.UpsertItem(ctx, sess.OSCARSession, frame, buddies); err != nil {
+				h.Logger.ErrorContext(ctx, "failed to add buddy", "err", err.Error())
+				return "error", nil
+			}
+		}
+
+		for _, item := range pending { // todo why not filter buddies out of pending?
+			if item.ClassID == wire.FeedbagClassIdGroup {
+				frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
+				if _, err := h.FeedbagService.UpsertItem(ctx, sess.OSCARSession, frame, []wire.FeedbagItem{item}); err != nil {
+					h.Logger.ErrorContext(ctx, "failed to add buddy", "err", err.Error())
+					return "error", nil
+				}
+			}
+		}
+	}
 
 
 	// Get current presence for the buddy
 	// Get current presence for the buddy
 	buddyInfo := &BuddyPresenceInfo{
 	buddyInfo := &BuddyPresenceInfo{
@@ -199,35 +418,10 @@ func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, screenName sta
 // AddTempBuddy handles GET /aim/addTempBuddy requests.
 // AddTempBuddy handles GET /aim/addTempBuddy requests.
 // This adds temporary buddies to the session without persisting them to the feedbag.
 // This adds temporary buddies to the session without persisting them to the feedbag.
 // The temporary buddies are only visible for the duration of the session.
 // The temporary buddies are only visible for the duration of the session.
-func (h *BuddyListHandler) AddTempBuddy(w http.ResponseWriter, r *http.Request) {
+func (h *BuddyListHandler) AddTempBuddy(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
 	ctx := r.Context()
 	ctx := r.Context()
-
-	// Get session ID from parameters
 	aimsid := r.URL.Query().Get("aimsid")
 	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
-		return
-	}
-
-	// Get session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		switch err {
-		case state.ErrNoWebAPISession:
-			h.sendError(w, http.StatusNotFound, "session not found")
-		case state.ErrWebAPISessionExpired:
-			h.sendError(w, http.StatusGone, "session expired")
-		default:
-			h.sendError(w, http.StatusInternalServerError, "internal server error")
-		}
-		return
-	}
 
 
-	// Touch the session
-	_ = h.SessionManager.TouchSession(r.Context(), aimsid)
-
-	// Get buddy names from parameters
-	// The WebAPI accepts multiple buddy names via &t= parameters
 	buddyNames := r.URL.Query()["t"]
 	buddyNames := r.URL.Query()["t"]
 	if len(buddyNames) == 0 {
 	if len(buddyNames) == 0 {
 		h.sendError(w, http.StatusBadRequest, "missing buddy names (t parameter)")
 		h.sendError(w, http.StatusBadRequest, "missing buddy names (t parameter)")
@@ -248,7 +442,7 @@ func (h *BuddyListHandler) AddTempBuddy(w http.ResponseWriter, r *http.Request)
 	}
 	}
 
 
 	// Prepare response
 	// Prepare response
-	responseData := map[string]interface{}{
+	responseData := map[string]any{
 		"resultCode": "success",
 		"resultCode": "success",
 		"buddyNames": buddyNames,
 		"buddyNames": buddyNames,
 	}
 	}
@@ -259,26 +453,9 @@ func (h *BuddyListHandler) AddTempBuddy(w http.ResponseWriter, r *http.Request)
 	resp.Response.Data = responseData
 	resp.Response.Data = responseData
 	SendResponse(w, r, resp, h.Logger)
 	SendResponse(w, r, resp, h.Logger)
 
 
-	// Push temp buddy event to the session's event queue
-	if session.EventQueue != nil {
-		for _, buddyName := range buddyNames {
-			buddyName = strings.TrimSpace(buddyName)
-			if buddyName != "" {
-				// Create minimal buddy info for temp buddy
-				buddyInfo := &BuddyPresenceInfo{
-					AimID:    buddyName,
-					State:    "offline", // Default state
-					UserType: "aim",
-				}
-
-				event := types.BuddyListEvent{
-					Action: "addTemp",
-					Buddy:  buddyInfo,
-				}
-				session.EventQueue.Push(types.EventTypeBuddyList, event)
-			}
-		}
-	}
+	// Do not push buddylist events for temp buddies. The Web AIM client handles
+	// addTempBuddy via the API response; a buddylist event without "groups" causes
+	// the client to clear the entire contact list (zC always calls clear() first).
 
 
 	h.Logger.InfoContext(ctx, "temporary buddies added",
 	h.Logger.InfoContext(ctx, "temporary buddies added",
 		"aimsid", aimsid,
 		"aimsid", aimsid,
@@ -287,6 +464,46 @@ func (h *BuddyListHandler) AddTempBuddy(w http.ResponseWriter, r *http.Request)
 	)
 	)
 }
 }
 
 
+// RemoveTempBuddy handles GET /aim/removeTempBuddy requests.
+// This removes temporary session buddies added via addTempBuddy.
+func (h *BuddyListHandler) RemoveTempBuddy(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+	ctx := r.Context()
+	aimsid := r.URL.Query().Get("aimsid")
+
+	buddyNames := r.URL.Query()["t"]
+	if len(buddyNames) == 0 {
+		h.sendError(w, http.StatusBadRequest, "missing buddy names (t parameter)")
+		return
+	}
+
+	removed := make([]string, 0, len(buddyNames))
+	for _, buddyName := range buddyNames {
+		buddyName = strings.TrimSpace(buddyName)
+		if buddyName == "" {
+			continue
+		}
+		if session.TempBuddies != nil {
+			delete(session.TempBuddies, buddyName)
+		}
+		removed = append(removed, buddyName)
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]any{
+		"resultCode": "success",
+		"buddyNames": removed,
+	}
+	SendResponse(w, r, resp, h.Logger)
+
+	h.Logger.InfoContext(ctx, "temporary buddies removed",
+		"aimsid", aimsid,
+		"buddies", removed,
+		"count", len(removed),
+	)
+}
+
 // sendError is a convenience method that wraps the common SendError function.
 // sendError is a convenience method that wraps the common SendError function.
 func (h *BuddyListHandler) sendError(w http.ResponseWriter, statusCode int, message string) {
 func (h *BuddyListHandler) sendError(w http.ResponseWriter, statusCode int, message string) {
 	SendError(w, statusCode, message)
 	SendError(w, statusCode, message)

+ 683 - 273
server/webapi/handlers/buddylist_test.go

@@ -37,39 +37,11 @@ func (m *MockWebAPISessionManager) TouchSession(ctx context.Context, aimsid stri
 	return args.Error(0)
 	return args.Error(0)
 }
 }
 
 
-// MockFeedbagManager is a mock implementation of FeedbagManager
-type MockFeedbagManager struct {
-	mock.Mock
-}
-
-func (m *MockFeedbagManager) RetrieveFeedbag(ctx context.Context, screenName state.IdentScreenName) ([]wire.FeedbagItem, error) {
-	args := m.Called(ctx, screenName)
-	if items := args.Get(0); items != nil {
-		return items.([]wire.FeedbagItem), args.Error(1)
-	}
-	return nil, args.Error(1)
-}
-
-func (m *MockFeedbagManager) InsertItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error {
-	args := m.Called(ctx, screenName, item)
-	return args.Error(0)
-}
-
-func (m *MockFeedbagManager) UpdateItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error {
-	args := m.Called(ctx, screenName, item)
-	return args.Error(0)
-}
-
-func (m *MockFeedbagManager) DeleteItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error {
-	args := m.Called(ctx, screenName, item)
-	return args.Error(0)
-}
-
 func TestBuddyListHandler_AddTempBuddy(t *testing.T) {
 func TestBuddyListHandler_AddTempBuddy(t *testing.T) {
 	tests := []struct {
 	tests := []struct {
 		name               string
 		name               string
 		queryParams        map[string][]string
 		queryParams        map[string][]string
-		setupMocks         func(*MockWebAPISessionManager, *MockFeedbagManager, string)
+		session            *state.WebAPISession
 		expectedStatusCode int
 		expectedStatusCode int
 		expectedResponse   string
 		expectedResponse   string
 		checkSession       func(*testing.T, *state.WebAPISession)
 		checkSession       func(*testing.T, *state.WebAPISession)
@@ -80,16 +52,12 @@ func TestBuddyListHandler_AddTempBuddy(t *testing.T) {
 				"aimsid": {"test-session-id"},
 				"aimsid": {"test-session-id"},
 				"t":      {"buddy1"},
 				"t":      {"buddy1"},
 			},
 			},
-			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
-				session := &state.WebAPISession{
-					AimSID:       aimsid,
-					ScreenName:   state.DisplayScreenName("testuser"),
-					EventQueue:   types.NewEventQueue(100),
-					TempBuddies:  nil,
-					LastAccessed: time.Now(),
-				}
-				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
-				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
+			session: &state.WebAPISession{
+				AimSID:       "test-session-id",
+				ScreenName:   state.DisplayScreenName("testuser"),
+				EventQueue:   types.NewEventQueue(100),
+				TempBuddies:  nil,
+				LastAccessed: time.Now(),
 			},
 			},
 			expectedStatusCode: http.StatusOK,
 			expectedStatusCode: http.StatusOK,
 			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["buddy1"],"resultCode":"success"}}}`,
 			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["buddy1"],"resultCode":"success"}}}`,
@@ -105,16 +73,12 @@ func TestBuddyListHandler_AddTempBuddy(t *testing.T) {
 				"aimsid": {"test-session-id"},
 				"aimsid": {"test-session-id"},
 				"t":      {"buddy1", "buddy2", "buddy3"},
 				"t":      {"buddy1", "buddy2", "buddy3"},
 			},
 			},
-			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
-				session := &state.WebAPISession{
-					AimSID:       aimsid,
-					ScreenName:   state.DisplayScreenName("testuser"),
-					EventQueue:   types.NewEventQueue(100),
-					TempBuddies:  nil,
-					LastAccessed: time.Now(),
-				}
-				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
-				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
+			session: &state.WebAPISession{
+				AimSID:       "test-session-id",
+				ScreenName:   state.DisplayScreenName("testuser"),
+				EventQueue:   types.NewEventQueue(100),
+				TempBuddies:  nil,
+				LastAccessed: time.Now(),
 			},
 			},
 			expectedStatusCode: http.StatusOK,
 			expectedStatusCode: http.StatusOK,
 			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["buddy1","buddy2","buddy3"],"resultCode":"success"}}}`,
 			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["buddy1","buddy2","buddy3"],"resultCode":"success"}}}`,
@@ -132,18 +96,14 @@ func TestBuddyListHandler_AddTempBuddy(t *testing.T) {
 				"aimsid": {"test-session-id"},
 				"aimsid": {"test-session-id"},
 				"t":      {"buddy2"},
 				"t":      {"buddy2"},
 			},
 			},
-			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
-				session := &state.WebAPISession{
-					AimSID:     aimsid,
-					ScreenName: state.DisplayScreenName("testuser"),
-					EventQueue: types.NewEventQueue(100),
-					TempBuddies: map[string]bool{
-						"buddy1": true,
-					},
-					LastAccessed: time.Now(),
-				}
-				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
-				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
+			session: &state.WebAPISession{
+				AimSID:     "test-session-id",
+				ScreenName: state.DisplayScreenName("testuser"),
+				EventQueue: types.NewEventQueue(100),
+				TempBuddies: map[string]bool{
+					"buddy1": true,
+				},
+				LastAccessed: time.Now(),
 			},
 			},
 			expectedStatusCode: http.StatusOK,
 			expectedStatusCode: http.StatusOK,
 			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["buddy2"],"resultCode":"success"}}}`,
 			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["buddy2"],"resultCode":"success"}}}`,
@@ -154,85 +114,32 @@ func TestBuddyListHandler_AddTempBuddy(t *testing.T) {
 				assert.Equal(t, 2, len(session.TempBuddies))
 				assert.Equal(t, 2, len(session.TempBuddies))
 			},
 			},
 		},
 		},
-		{
-			name: "Error_MissingAimSID",
-			queryParams: map[string][]string{
-				"t": {"buddy1"},
-			},
-			setupMocks:         func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {},
-			expectedStatusCode: http.StatusBadRequest,
-			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing aimsid parameter"}}`,
-		},
 		{
 		{
 			name: "Error_MissingBuddyNames",
 			name: "Error_MissingBuddyNames",
 			queryParams: map[string][]string{
 			queryParams: map[string][]string{
 				"aimsid": {"test-session-id"},
 				"aimsid": {"test-session-id"},
 			},
 			},
-			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
-				session := &state.WebAPISession{
-					AimSID:       aimsid,
-					ScreenName:   state.DisplayScreenName("testuser"),
-					EventQueue:   types.NewEventQueue(100),
-					LastAccessed: time.Now(),
-				}
-				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
-				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
+			session: &state.WebAPISession{
+				AimSID:       "test-session-id",
+				ScreenName:   state.DisplayScreenName("testuser"),
+				EventQueue:   types.NewEventQueue(100),
+				LastAccessed: time.Now(),
 			},
 			},
 			expectedStatusCode: http.StatusBadRequest,
 			expectedStatusCode: http.StatusBadRequest,
 			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy names (t parameter)"}}`,
 			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy names (t parameter)"}}`,
 		},
 		},
-		{
-			name: "Error_SessionNotFound",
-			queryParams: map[string][]string{
-				"aimsid": {"invalid-session"},
-				"t":      {"buddy1"},
-			},
-			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
-				sm.On("GetSession", mock.Anything, aimsid).Return(nil, state.ErrNoWebAPISession)
-			},
-			expectedStatusCode: http.StatusNotFound,
-			expectedResponse:   `{"response":{"statusCode":404,"statusText":"session not found"}}`,
-		},
-		{
-			name: "Error_SessionExpired",
-			queryParams: map[string][]string{
-				"aimsid": {"expired-session"},
-				"t":      {"buddy1"},
-			},
-			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
-				sm.On("GetSession", mock.Anything, aimsid).Return(nil, state.ErrWebAPISessionExpired)
-			},
-			expectedStatusCode: http.StatusGone,
-			expectedResponse:   `{"response":{"statusCode":410,"statusText":"session expired"}}`,
-		},
-		{
-			name: "Error_InternalServerError",
-			queryParams: map[string][]string{
-				"aimsid": {"test-session-id"},
-				"t":      {"buddy1"},
-			},
-			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
-				sm.On("GetSession", mock.Anything, aimsid).Return(nil, errors.New("database error"))
-			},
-			expectedStatusCode: http.StatusInternalServerError,
-			expectedResponse:   `{"response":{"statusCode":500,"statusText":"internal server error"}}`,
-		},
 		{
 		{
 			name: "Success_WithWhitespace",
 			name: "Success_WithWhitespace",
 			queryParams: map[string][]string{
 			queryParams: map[string][]string{
 				"aimsid": {"test-session-id"},
 				"aimsid": {"test-session-id"},
 				"t":      {"  buddy1  ", "buddy2 ", " buddy3"},
 				"t":      {"  buddy1  ", "buddy2 ", " buddy3"},
 			},
 			},
-			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
-				session := &state.WebAPISession{
-					AimSID:       aimsid,
-					ScreenName:   state.DisplayScreenName("testuser"),
-					EventQueue:   types.NewEventQueue(100),
-					TempBuddies:  nil,
-					LastAccessed: time.Now(),
-				}
-				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
-				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
+			session: &state.WebAPISession{
+				AimSID:       "test-session-id",
+				ScreenName:   state.DisplayScreenName("testuser"),
+				EventQueue:   types.NewEventQueue(100),
+				TempBuddies:  nil,
+				LastAccessed: time.Now(),
 			},
 			},
 			expectedStatusCode: http.StatusOK,
 			expectedStatusCode: http.StatusOK,
 			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["  buddy1  ","buddy2 "," buddy3"],"resultCode":"success"}}}`,
 			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["  buddy1  ","buddy2 "," buddy3"],"resultCode":"success"}}}`,
@@ -248,24 +155,10 @@ func TestBuddyListHandler_AddTempBuddy(t *testing.T) {
 
 
 	for _, tt := range tests {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
-			// Setup
-			sessionManager := &MockWebAPISessionManager{}
-			feedbagManager := &MockFeedbagManager{}
-			logger := slog.Default()
-
 			handler := &BuddyListHandler{
 			handler := &BuddyListHandler{
-				SessionManager: sessionManager,
-				FeedbagManager: feedbagManager,
-				Logger:         logger,
-			}
-
-			aimsid := ""
-			if aimsids, ok := tt.queryParams["aimsid"]; ok && len(aimsids) > 0 {
-				aimsid = aimsids[0]
+				Logger: slog.Default(),
 			}
 			}
-			tt.setupMocks(sessionManager, feedbagManager, aimsid)
 
 
-			// Create request with query parameters
 			reqURL := "/aim/addTempBuddy"
 			reqURL := "/aim/addTempBuddy"
 			if len(tt.queryParams) > 0 {
 			if len(tt.queryParams) > 0 {
 				values := url.Values{}
 				values := url.Values{}
@@ -280,50 +173,21 @@ func TestBuddyListHandler_AddTempBuddy(t *testing.T) {
 			req, err := http.NewRequest("GET", reqURL, nil)
 			req, err := http.NewRequest("GET", reqURL, nil)
 			assert.NoError(t, err)
 			assert.NoError(t, err)
 
 
-			// Create response recorder
 			rr := httptest.NewRecorder()
 			rr := httptest.NewRecorder()
+			handler.AddTempBuddy(rr, req, tt.session)
 
 
-			// Execute
-			handler.AddTempBuddy(rr, req)
-
-			// Verify status code
 			assert.Equal(t, tt.expectedStatusCode, rr.Code)
 			assert.Equal(t, tt.expectedStatusCode, rr.Code)
+			assert.Equal(t, tt.expectedResponse, strings.TrimSpace(rr.Body.String()))
 
 
-			// Verify response body
-			responseBody := strings.TrimSpace(rr.Body.String())
-			assert.Equal(t, tt.expectedResponse, responseBody)
-
-			// Check session state if provided
-			if tt.checkSession != nil && aimsid != "" {
-				// Get the session from mock to verify state
-				for _, call := range sessionManager.Calls {
-					if call.Method == "GetSession" {
-						if session, ok := call.ReturnArguments[0].(*state.WebAPISession); ok {
-							tt.checkSession(t, session)
-							break
-						}
-					}
-				}
+			if tt.checkSession != nil && tt.session != nil {
+				tt.checkSession(t, tt.session)
 			}
 			}
-
-			// Verify all mock expectations were met
-			sessionManager.AssertExpectations(t)
-			feedbagManager.AssertExpectations(t)
 		})
 		})
 	}
 	}
 }
 }
 
 
-func TestBuddyListHandler_AddTempBuddy_EventQueueBehavior(t *testing.T) {
-	// Test that events are properly added to the event queue
-	sessionManager := &MockWebAPISessionManager{}
-	feedbagManager := &MockFeedbagManager{}
-	logger := slog.Default()
-
-	handler := &BuddyListHandler{
-		SessionManager: sessionManager,
-		FeedbagManager: feedbagManager,
-		Logger:         logger,
-	}
+func TestBuddyListHandler_AddTempBuddy_DoesNotPushBuddyListEvent(t *testing.T) {
+	handler := &BuddyListHandler{Logger: slog.Default()}
 
 
 	eventQueue := types.NewEventQueue(100)
 	eventQueue := types.NewEventQueue(100)
 	session := &state.WebAPISession{
 	session := &state.WebAPISession{
@@ -334,46 +198,84 @@ func TestBuddyListHandler_AddTempBuddy_EventQueueBehavior(t *testing.T) {
 		LastAccessed: time.Now(),
 		LastAccessed: time.Now(),
 	}
 	}
 
 
-	sessionManager.On("GetSession", mock.Anything, "test-session").Return(session, nil)
-	sessionManager.On("TouchSession", mock.Anything, "test-session").Return(nil)
-
 	req, err := http.NewRequest("GET", "/aim/addTempBuddy?aimsid=test-session&t=buddy1&t=buddy2", nil)
 	req, err := http.NewRequest("GET", "/aim/addTempBuddy?aimsid=test-session&t=buddy1&t=buddy2", nil)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 
 
 	rr := httptest.NewRecorder()
 	rr := httptest.NewRecorder()
-	handler.AddTempBuddy(rr, req)
+	handler.AddTempBuddy(rr, req, session)
 
 
-	// Verify that events were added to the queue
 	assert.Equal(t, http.StatusOK, rr.Code)
 	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Empty(t, eventQueue.GetAllEvents(), "addTempBuddy must not push buddylist events")
+}
 
 
-	// Check that the event queue has the correct number of events
-	events := eventQueue.GetAllEvents()
-	assert.GreaterOrEqual(t, len(events), 2, "Should have at least 2 events for 2 buddies")
-
-	// Verify event content
-	for _, event := range events {
-		assert.Equal(t, types.EventTypeBuddyList, event.Type)
-		eventData, ok := event.Data.(types.BuddyListEvent)
-		assert.True(t, ok, "Event data should be BuddyListEvent")
-		assert.Equal(t, "addTemp", eventData.Action)
-		assert.NotNil(t, eventData.Buddy)
-		buddyInfo, ok := eventData.Buddy.(*BuddyPresenceInfo)
-		assert.True(t, ok, "Buddy should be *BuddyPresenceInfo")
-		if ok {
-			assert.Contains(t, []string{"buddy1", "buddy2"}, buddyInfo.AimID)
-			assert.Equal(t, "offline", buddyInfo.State)
-			assert.Equal(t, "aim", buddyInfo.UserType)
-		}
+func TestBuddyListHandler_RemoveTempBuddy(t *testing.T) {
+	handler := &BuddyListHandler{Logger: slog.Default()}
+
+	session := &state.WebAPISession{
+		AimSID:     "test-session",
+		ScreenName: state.DisplayScreenName("testuser"),
+		TempBuddies: map[string]bool{
+			"buddy1": true,
+			"buddy2": true,
+		},
+		LastAccessed: time.Now(),
+	}
+
+	req, err := http.NewRequest("GET", "/aim/removeTempBuddy?aimsid=test-session&t=buddy1", nil)
+	assert.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	handler.RemoveTempBuddy(rr, req, session)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.False(t, session.TempBuddies["buddy1"])
+	assert.True(t, session.TempBuddies["buddy2"])
+}
+
+func TestFeedbagGroupMatchesRequested(t *testing.T) {
+	assert.True(t, feedbagGroupMatchesRequested("Buddies", "Buddies"))
+	assert.True(t, feedbagGroupMatchesRequested("", "Buddies"))
+	assert.True(t, feedbagGroupMatchesRequested("  ", "Buddies"))
+	assert.True(t, feedbagGroupMatchesRequested("Friends", "friends"))
+	assert.False(t, feedbagGroupMatchesRequested("", "Friends"))
+}
+
+func TestFindFeedbagGroupID(t *testing.T) {
+	items := []wire.FeedbagItem{
+		{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "", GroupID: 0},
+		{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "jon", GroupID: 1},
+	}
+	id, ok := findFeedbagGroupID(items, "Buddies")
+	assert.True(t, ok)
+	assert.Equal(t, uint16(1), id)
+
+	id, ok = findFeedbagGroupID(items, "Friends")
+	assert.False(t, ok)
+	assert.Equal(t, uint16(0), id)
+}
+
+func TestStoredGroupNameForRequest(t *testing.T) {
+	items := []wire.FeedbagItem{
+		{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "", GroupID: 1},
+		{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "jon", GroupID: 1},
 	}
 	}
+	st, ok := storedGroupNameForRequest(items, "Buddies")
+	assert.True(t, ok)
+	assert.Equal(t, "", st)
 
 
-	sessionManager.AssertExpectations(t)
+	items2 := []wire.FeedbagItem{
+		{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "Friends", GroupID: 2},
+	}
+	st2, ok2 := storedGroupNameForRequest(items2, "Friends")
+	assert.True(t, ok2)
+	assert.Equal(t, "Friends", st2)
 }
 }
 
 
 func TestBuddyListHandler_AddBuddy(t *testing.T) {
 func TestBuddyListHandler_AddBuddy(t *testing.T) {
 	tests := []struct {
 	tests := []struct {
 		name               string
 		name               string
 		queryParams        map[string][]string
 		queryParams        map[string][]string
-		setupMocks         func(*MockWebAPISessionManager, *MockFeedbagManager, string)
+		setupMocks         func(*MockWebAPISessionManager, *MockFeedbagService, *MockFeedbagService, string) *state.WebAPISession
 		expectedStatusCode int
 		expectedStatusCode int
 		expectedResponse   string
 		expectedResponse   string
 	}{
 	}{
@@ -384,72 +286,55 @@ func TestBuddyListHandler_AddBuddy(t *testing.T) {
 				"buddy":  {"newbuddy"},
 				"buddy":  {"newbuddy"},
 				"group":  {"Friends"},
 				"group":  {"Friends"},
 			},
 			},
-			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
+			setupMocks: func(sm *MockWebAPISessionManager, fs *MockFeedbagService, blmFs *MockFeedbagService, aimsid string) *state.WebAPISession {
 				session := &state.WebAPISession{
 				session := &state.WebAPISession{
 					AimSID:       aimsid,
 					AimSID:       aimsid,
 					ScreenName:   state.DisplayScreenName("testuser"),
 					ScreenName:   state.DisplayScreenName("testuser"),
 					EventQueue:   types.NewEventQueue(100),
 					EventQueue:   types.NewEventQueue(100),
 					LastAccessed: time.Now(),
 					LastAccessed: time.Now(),
 				}
 				}
-				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
-				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
 
 
-				// Mock feedbag retrieval with existing group
-				existingItems := []wire.FeedbagItem{
-					{
-						ItemID:  1,
-						ClassID: wire.FeedbagClassIdGroup,
-						Name:    "Friends",
-						GroupID: 0,
-					},
+				items := []wire.FeedbagItem{
+					{GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
 				}
 				}
-				fm.On("RetrieveFeedbag", mock.Anything, state.NewIdentScreenName("testuser")).
-					Return(existingItems, nil)
-
-				// Mock buddy insertion
-				fm.On("InsertItem", mock.Anything, state.NewIdentScreenName("testuser"), mock.MatchedBy(func(item wire.FeedbagItem) bool {
-					return item.ClassID == wire.FeedbagClassIdBuddy &&
-						item.Name == "newbuddy" &&
-						item.GroupID == 1
-				})).Return(nil)
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
+				// addBuddyToFeedbag calls UpsertItem twice: once for group order update, once for buddy insert
+				fs.On("UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return((*wire.SNACMessage)(nil), nil)
+				blmFs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
+				return session
 			},
 			},
 			expectedStatusCode: http.StatusOK,
 			expectedStatusCode: http.StatusOK,
 			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyInfo":{"aimId":"newbuddy","state":"offline","userType":"aim"},"resultCode":"success"}}}`,
 			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyInfo":{"aimId":"newbuddy","state":"offline","userType":"aim"},"resultCode":"success"}}}`,
 		},
 		},
 		{
 		{
-			name: "Success_AddBuddyCreateNewGroup",
+			name: "Success_EventPushSkippedOnBLMError",
 			queryParams: map[string][]string{
 			queryParams: map[string][]string{
 				"aimsid": {"test-session"},
 				"aimsid": {"test-session"},
 				"buddy":  {"newbuddy"},
 				"buddy":  {"newbuddy"},
-				"group":  {"NewGroup"},
+				"group":  {"Friends"},
 			},
 			},
-			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
+			setupMocks: func(sm *MockWebAPISessionManager, fs *MockFeedbagService, blmFs *MockFeedbagService, aimsid string) *state.WebAPISession {
 				session := &state.WebAPISession{
 				session := &state.WebAPISession{
 					AimSID:       aimsid,
 					AimSID:       aimsid,
 					ScreenName:   state.DisplayScreenName("testuser"),
 					ScreenName:   state.DisplayScreenName("testuser"),
 					EventQueue:   types.NewEventQueue(100),
 					EventQueue:   types.NewEventQueue(100),
 					LastAccessed: time.Now(),
 					LastAccessed: time.Now(),
 				}
 				}
-				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
-				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
-
-				// Mock feedbag retrieval with no existing groups
-				fm.On("RetrieveFeedbag", mock.Anything, state.NewIdentScreenName("testuser")).
-					Return([]wire.FeedbagItem{}, nil)
 
 
-				// Mock group creation
-				fm.On("InsertItem", mock.Anything, state.NewIdentScreenName("testuser"), mock.MatchedBy(func(item wire.FeedbagItem) bool {
-					return item.ClassID == wire.FeedbagClassIdGroup &&
-						item.Name == "NewGroup" &&
-						item.ItemID == 1
-				})).Return(nil)
-
-				// Mock buddy insertion
-				fm.On("InsertItem", mock.Anything, state.NewIdentScreenName("testuser"), mock.MatchedBy(func(item wire.FeedbagItem) bool {
-					return item.ClassID == wire.FeedbagClassIdBuddy &&
-						item.Name == "newbuddy" &&
-						item.GroupID == 1
-				})).Return(nil)
+				items := []wire.FeedbagItem{
+					{GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
+				}
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
+				// addBuddyToFeedbag calls UpsertItem twice: once for group order update, once for buddy insert
+				fs.On("UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return((*wire.SNACMessage)(nil), nil)
+				blmFs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{}, errors.New("feedbag unavailable")).Once()
+				return session
 			},
 			},
 			expectedStatusCode: http.StatusOK,
 			expectedStatusCode: http.StatusOK,
 			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyInfo":{"aimId":"newbuddy","state":"offline","userType":"aim"},"resultCode":"success"}}}`,
 			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyInfo":{"aimId":"newbuddy","state":"offline","userType":"aim"},"resultCode":"success"}}}`,
@@ -461,27 +346,22 @@ func TestBuddyListHandler_AddBuddy(t *testing.T) {
 				"buddy":  {"existingbuddy"},
 				"buddy":  {"existingbuddy"},
 				"group":  {"Friends"},
 				"group":  {"Friends"},
 			},
 			},
-			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
+			setupMocks: func(sm *MockWebAPISessionManager, fs *MockFeedbagService, blmFs *MockFeedbagService, aimsid string) *state.WebAPISession {
 				session := &state.WebAPISession{
 				session := &state.WebAPISession{
 					AimSID:       aimsid,
 					AimSID:       aimsid,
 					ScreenName:   state.DisplayScreenName("testuser"),
 					ScreenName:   state.DisplayScreenName("testuser"),
 					EventQueue:   types.NewEventQueue(100),
 					EventQueue:   types.NewEventQueue(100),
 					LastAccessed: time.Now(),
 					LastAccessed: time.Now(),
 				}
 				}
-				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
-				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
 
 
-				// Mock feedbag retrieval with existing buddy
-				existingItems := []wire.FeedbagItem{
-					{
-						ItemID:  1,
-						ClassID: wire.FeedbagClassIdBuddy,
-						Name:    "existingbuddy",
-						GroupID: 1,
-					},
+				// Friends group with existingbuddy already present
+				items := []wire.FeedbagItem{
+					{GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
+					{GroupID: 1, ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "existingbuddy"},
 				}
 				}
-				fm.On("RetrieveFeedbag", mock.Anything, state.NewIdentScreenName("testuser")).
-					Return(existingItems, nil)
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
+				return session
 			},
 			},
 			expectedStatusCode: http.StatusOK,
 			expectedStatusCode: http.StatusOK,
 			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"alreadyExists"}}}`,
 			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"alreadyExists"}}}`,
@@ -492,15 +372,13 @@ func TestBuddyListHandler_AddBuddy(t *testing.T) {
 				"aimsid": {"test-session"},
 				"aimsid": {"test-session"},
 				"group":  {"Friends"},
 				"group":  {"Friends"},
 			},
 			},
-			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
-				session := &state.WebAPISession{
+			setupMocks: func(sm *MockWebAPISessionManager, fs *MockFeedbagService, blmFs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				return &state.WebAPISession{
 					AimSID:       aimsid,
 					AimSID:       aimsid,
 					ScreenName:   state.DisplayScreenName("testuser"),
 					ScreenName:   state.DisplayScreenName("testuser"),
 					EventQueue:   types.NewEventQueue(100),
 					EventQueue:   types.NewEventQueue(100),
 					LastAccessed: time.Now(),
 					LastAccessed: time.Now(),
 				}
 				}
-				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
-				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
 			},
 			},
 			expectedStatusCode: http.StatusBadRequest,
 			expectedStatusCode: http.StatusBadRequest,
 			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy parameter"}}`,
 			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy parameter"}}`,
@@ -509,24 +387,25 @@ func TestBuddyListHandler_AddBuddy(t *testing.T) {
 
 
 	for _, tt := range tests {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
-			// Setup
 			sessionManager := &MockWebAPISessionManager{}
 			sessionManager := &MockWebAPISessionManager{}
-			feedbagManager := &MockFeedbagManager{}
+			feedbagService := &MockFeedbagService{}
+			blmFeedbagService := &MockFeedbagService{}
+			blm := NewBuddyListManager(blmFeedbagService, &MockSessionRetriever{}, slog.Default())
 			logger := slog.Default()
 			logger := slog.Default()
 
 
 			handler := &BuddyListHandler{
 			handler := &BuddyListHandler{
-				SessionManager: sessionManager,
-				FeedbagManager: feedbagManager,
-				Logger:         logger,
+				SessionManager:   sessionManager,
+				FeedbagService:   feedbagService,
+				BuddyListManager: blm,
+				Logger:           logger,
 			}
 			}
 
 
 			aimsid := ""
 			aimsid := ""
 			if aimsids, ok := tt.queryParams["aimsid"]; ok && len(aimsids) > 0 {
 			if aimsids, ok := tt.queryParams["aimsid"]; ok && len(aimsids) > 0 {
 				aimsid = aimsids[0]
 				aimsid = aimsids[0]
 			}
 			}
-			tt.setupMocks(sessionManager, feedbagManager, aimsid)
+			session := tt.setupMocks(sessionManager, feedbagService, blmFeedbagService, aimsid)
 
 
-			// Create request
 			reqURL := "/buddylist/addBuddy"
 			reqURL := "/buddylist/addBuddy"
 			if len(tt.queryParams) > 0 {
 			if len(tt.queryParams) > 0 {
 				values := url.Values{}
 				values := url.Values{}
@@ -541,19 +420,550 @@ func TestBuddyListHandler_AddBuddy(t *testing.T) {
 			req, err := http.NewRequest("GET", reqURL, nil)
 			req, err := http.NewRequest("GET", reqURL, nil)
 			assert.NoError(t, err)
 			assert.NoError(t, err)
 
 
-			// Create response recorder
 			rr := httptest.NewRecorder()
 			rr := httptest.NewRecorder()
+			handler.AddBuddy(rr, req, session)
 
 
-			// Execute
-			handler.AddBuddy(rr, req)
-
-			// Verify
 			assert.Equal(t, tt.expectedStatusCode, rr.Code)
 			assert.Equal(t, tt.expectedStatusCode, rr.Code)
 			responseBody := strings.TrimSpace(rr.Body.String())
 			responseBody := strings.TrimSpace(rr.Body.String())
 			assert.Equal(t, tt.expectedResponse, responseBody)
 			assert.Equal(t, tt.expectedResponse, responseBody)
 
 
-			sessionManager.AssertExpectations(t)
-			feedbagManager.AssertExpectations(t)
+			feedbagService.AssertExpectations(t)
+			blmFeedbagService.AssertExpectations(t)
+		})
+	}
+}
+
+func TestBuddyListHandler_AddGroup(t *testing.T) {
+	type setupFunc func(*MockWebAPISessionManager, *MockFeedbagService, *MockFeedbagService, string) *state.WebAPISession
+
+	newSession := func(aimsid string) *state.WebAPISession {
+		return &state.WebAPISession{
+			AimSID:       aimsid,
+			ScreenName:   state.DisplayScreenName("testuser"),
+			EventQueue:   types.NewEventQueue(100),
+			LastAccessed: time.Now(),
+		}
+	}
+
+	tests := []struct {
+		name               string
+		queryParams        map[string][]string
+		setup              setupFunc
+		expectedStatusCode int
+		expectedResponse   string
+	}{
+		{
+			name:        "Error_MissingGroupParam",
+			queryParams: map[string][]string{"aimsid": {"sess"}},
+			setup: func(sm *MockWebAPISessionManager, fs *MockFeedbagService, blmFs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				return newSession(aimsid)
+			},
+			expectedStatusCode: http.StatusBadRequest,
+			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing group parameter"}}`,
+		},
+		{
+			name:        "Success_GroupAdded",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"NewGroup"}},
+			setup: func(sm *MockWebAPISessionManager, fs *MockFeedbagService, blmFs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				sess := newSession(aimsid)
+				// Empty feedbag — AddGroup will create root + NewGroup in pending
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
+				fs.On("UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return((*wire.SNACMessage)(nil), nil).Once()
+				blmFs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
+				return sess
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+		},
+		{
+			name:        "Success_EventPushSkippedOnBLMError",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"NewGroup"}},
+			setup: func(sm *MockWebAPISessionManager, fs *MockFeedbagService, blmFs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				sess := newSession(aimsid)
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
+				fs.On("UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return((*wire.SNACMessage)(nil), nil).Once()
+				blmFs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{}, errors.New("feedbag unavailable")).Once()
+				return sess
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+		},
+		{
+			name:        "Success_GroupAlreadyExists",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"Friends"}},
+			setup: func(sm *MockWebAPISessionManager, fs *MockFeedbagService, blmFs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				sess := newSession(aimsid)
+				items := []wire.FeedbagItem{
+					{GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
+				}
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
+				return sess
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"alreadyExists"}}}`,
+		},
+		{
+			name:        "Error_FeedbagQueryFails",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"NewGroup"}},
+			setup: func(sm *MockWebAPISessionManager, fs *MockFeedbagService, blmFs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				sess := newSession(aimsid)
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{}, errors.New("feedbag error"))
+				return sess
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"error"}}}`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sm := &MockWebAPISessionManager{}
+			fs := &MockFeedbagService{}
+			blmFs := &MockFeedbagService{}
+			blm := NewBuddyListManager(blmFs, &MockSessionRetriever{}, slog.Default())
+
+			aimsid := ""
+			if v := tt.queryParams["aimsid"]; len(v) > 0 {
+				aimsid = v[0]
+			}
+			session := tt.setup(sm, fs, blmFs, aimsid)
+
+			handler := &BuddyListHandler{
+				SessionManager:   sm,
+				FeedbagService:   fs,
+				BuddyListManager: blm,
+				Logger:           slog.Default(),
+			}
+
+			values := url.Values{}
+			for k, vs := range tt.queryParams {
+				for _, v := range vs {
+					values.Add(k, v)
+				}
+			}
+			req, _ := http.NewRequest("GET", "/buddylist/addGroup?"+values.Encode(), nil)
+			rr := httptest.NewRecorder()
+			handler.AddGroup(rr, req, session)
+
+			assert.Equal(t, tt.expectedStatusCode, rr.Code)
+			assert.Equal(t, tt.expectedResponse, strings.TrimSpace(rr.Body.String()))
+			fs.AssertExpectations(t)
+			blmFs.AssertExpectations(t)
+		})
+	}
+}
+
+func TestBuddyListHandler_RemoveBuddy(t *testing.T) {
+	type setupFunc func(*MockWebAPISessionManager, *BuddyListManager, *MockFeedbagService, string) *state.WebAPISession
+
+	newBuddyListManager := func(fs *MockFeedbagService) *BuddyListManager {
+		return NewBuddyListManager(fs, &MockSessionRetriever{}, slog.Default())
+	}
+
+	tests := []struct {
+		name               string
+		queryParams        map[string][]string
+		setup              setupFunc
+		expectedStatusCode int
+		expectedResponse   string
+	}{
+		{
+			name:        "Error_MissingBuddyParam",
+			queryParams: map[string][]string{"aimsid": {"sess"}},
+			setup: func(sm *MockWebAPISessionManager, blm *BuddyListManager, fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				return &state.WebAPISession{AimSID: aimsid, ScreenName: state.DisplayScreenName("testuser"), LastAccessed: time.Now()}
+			},
+			expectedStatusCode: http.StatusBadRequest,
+			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy parameter"}}`,
+		},
+		{
+			name:        "Success_BuddyRemoved",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "buddy": {"someBuddy"}, "group": {"Friends"}},
+			setup: func(sm *MockWebAPISessionManager, blm *BuddyListManager, fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				sess := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("testuser"),
+					OSCARSession: state.NewSession().AddInstance(),
+					EventQueue:   types.NewEventQueue(100),
+					LastAccessed: time.Now(),
+				}
+				items := []wire.FeedbagItem{
+					{GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
+					{GroupID: 1, ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "someBuddy"},
+				}
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
+				fs.On("DeleteItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return((*wire.SNACMessage)(nil), nil).Once()
+				fs.On("UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return((*wire.SNACMessage)(nil), nil).Once()
+				// Second Query for GetBuddyListForUser event push
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
+				return sess
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+		},
+		{
+			name:        "Success_EventPushSkippedOnBLMError",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "buddy": {"someBuddy"}, "group": {"Friends"}},
+			setup: func(sm *MockWebAPISessionManager, blm *BuddyListManager, fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				sess := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("testuser"),
+					OSCARSession: state.NewSession().AddInstance(),
+					EventQueue:   types.NewEventQueue(100),
+					LastAccessed: time.Now(),
+				}
+				items := []wire.FeedbagItem{
+					{GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
+					{GroupID: 1, ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "someBuddy"},
+				}
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
+				fs.On("DeleteItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return((*wire.SNACMessage)(nil), nil).Once()
+				fs.On("UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return((*wire.SNACMessage)(nil), nil).Once()
+				// BLM query fails — response should still be success
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{}, errors.New("feedbag unavailable")).Once()
+				return sess
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+		},
+		{
+			name:        "Success_BuddyNotFound",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "buddy": {"ghost"}, "group": {"Friends"}},
+			setup: func(sm *MockWebAPISessionManager, blm *BuddyListManager, fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				sess := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("testuser"),
+					OSCARSession: state.NewSession().AddInstance(),
+					LastAccessed: time.Now(),
+				}
+				// Group exists but "ghost" is not in it
+				items := []wire.FeedbagItem{
+					{GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
+				}
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
+				return sess
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"notFound"}}}`,
+		},
+		{
+			name:        "Success_GroupNotFound",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "buddy": {"someBuddy"}, "group": {"NoSuchGroup"}},
+			setup: func(sm *MockWebAPISessionManager, blm *BuddyListManager, fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				sess := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("testuser"),
+					OSCARSession: state.NewSession().AddInstance(),
+					LastAccessed: time.Now(),
+				}
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil)
+				return sess
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"notFound"}}}`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sm := &MockWebAPISessionManager{}
+			fs := &MockFeedbagService{}
+			blm := newBuddyListManager(fs)
+
+			aimsid := ""
+			if v := tt.queryParams["aimsid"]; len(v) > 0 {
+				aimsid = v[0]
+			}
+			session := tt.setup(sm, blm, fs, aimsid)
+
+			handler := &BuddyListHandler{
+				SessionManager:   sm,
+				BuddyListManager: blm,
+				Logger:           slog.Default(),
+			}
+
+			values := url.Values{}
+			for k, vs := range tt.queryParams {
+				for _, v := range vs {
+					values.Add(k, v)
+				}
+			}
+			req, _ := http.NewRequest("GET", "/buddylist/removeBuddy?"+values.Encode(), nil)
+			rr := httptest.NewRecorder()
+			handler.RemoveBuddy(rr, req, session)
+
+			assert.Equal(t, tt.expectedStatusCode, rr.Code)
+			assert.Equal(t, tt.expectedResponse, strings.TrimSpace(rr.Body.String()))
+			fs.AssertExpectations(t)
+		})
+	}
+}
+
+func TestBuddyListHandler_RemoveGroup(t *testing.T) {
+	type setupFunc func(*MockWebAPISessionManager, *BuddyListManager, *MockFeedbagService, string) *state.WebAPISession
+
+	newBuddyListManager := func(fs *MockFeedbagService) *BuddyListManager {
+		return NewBuddyListManager(fs, &MockSessionRetriever{}, slog.Default())
+	}
+
+	tests := []struct {
+		name               string
+		queryParams        map[string][]string
+		setup              setupFunc
+		expectedStatusCode int
+		expectedResponse   string
+	}{
+		{
+			name:        "Error_MissingGroupParam",
+			queryParams: map[string][]string{"aimsid": {"sess"}},
+			setup: func(sm *MockWebAPISessionManager, blm *BuddyListManager, fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				return &state.WebAPISession{AimSID: aimsid, ScreenName: state.DisplayScreenName("testuser"), LastAccessed: time.Now()}
+			},
+			expectedStatusCode: http.StatusBadRequest,
+			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing group parameter"}}`,
+		},
+		{
+			name:        "Success_GroupRemoved",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"Friends"}},
+			setup: func(sm *MockWebAPISessionManager, blm *BuddyListManager, fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				sess := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("testuser"),
+					OSCARSession: state.NewSession().AddInstance(),
+					EventQueue:   types.NewEventQueue(100),
+					LastAccessed: time.Now(),
+				}
+				// Root order record + Friends group; DeleteGroup will delete Friends and update root.
+				items := []wire.FeedbagItem{
+					{
+						GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "",
+						TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1}),
+						}},
+					},
+					{GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
+				}
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
+				fs.On("DeleteItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return((*wire.SNACMessage)(nil), nil).Once()
+				fs.On("UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return((*wire.SNACMessage)(nil), nil).Once()
+				// Second Query for GetBuddyListForUser event push
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
+				return sess
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+		},
+		{
+			name:        "Success_EventPushSkippedOnBLMError",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"Friends"}},
+			setup: func(sm *MockWebAPISessionManager, blm *BuddyListManager, fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				sess := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("testuser"),
+					OSCARSession: state.NewSession().AddInstance(),
+					EventQueue:   types.NewEventQueue(100),
+					LastAccessed: time.Now(),
+				}
+				items := []wire.FeedbagItem{
+					{
+						GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "",
+						TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1}),
+						}},
+					},
+					{GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
+				}
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
+				fs.On("DeleteItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return((*wire.SNACMessage)(nil), nil).Once()
+				fs.On("UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return((*wire.SNACMessage)(nil), nil).Once()
+				// BLM query fails — response should still be success
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{}, errors.New("feedbag unavailable")).Once()
+				return sess
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+		},
+		{
+			name:        "Success_GroupNotFound",
+			queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"NoSuchGroup"}},
+			setup: func(sm *MockWebAPISessionManager, blm *BuddyListManager, fs *MockFeedbagService, aimsid string) *state.WebAPISession {
+				sess := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("testuser"),
+					OSCARSession: state.NewSession().AddInstance(),
+					LastAccessed: time.Now(),
+				}
+				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil)
+				return sess
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"notFound"}}}`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sm := &MockWebAPISessionManager{}
+			fs := &MockFeedbagService{}
+			blm := newBuddyListManager(fs)
+
+			aimsid := ""
+			if v := tt.queryParams["aimsid"]; len(v) > 0 {
+				aimsid = v[0]
+			}
+			session := tt.setup(sm, blm, fs, aimsid)
+
+			handler := &BuddyListHandler{
+				SessionManager:   sm,
+				BuddyListManager: blm,
+				Logger:           slog.Default(),
+			}
+
+			values := url.Values{}
+			for k, vs := range tt.queryParams {
+				for _, v := range vs {
+					values.Add(k, v)
+				}
+			}
+			req, _ := http.NewRequest("GET", "/buddylist/removeGroup?"+values.Encode(), nil)
+			rr := httptest.NewRecorder()
+			handler.RemoveGroup(rr, req, session)
+
+			assert.Equal(t, tt.expectedStatusCode, rr.Code)
+			assert.Equal(t, tt.expectedResponse, strings.TrimSpace(rr.Body.String()))
+			fs.AssertExpectations(t)
+		})
+	}
+}
+
+func TestBuddyListHandler_sessionMiddleware(t *testing.T) {
+	tests := []struct {
+		name               string
+		aimsid             string
+		setupMocks         func(*MockWebAPISessionManager, string)
+		expectedStatusCode int
+		expectedResponse   string
+		expectNextCalled   bool
+	}{
+		{
+			name:               "Error_MissingAimsid",
+			aimsid:             "",
+			setupMocks:         func(sm *MockWebAPISessionManager, aimsid string) {},
+			expectedStatusCode: http.StatusBadRequest,
+			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing aimsid parameter"}}`,
+			expectNextCalled:   false,
+		},
+		{
+			name:   "Error_SessionNotFound",
+			aimsid: "unknown-session",
+			setupMocks: func(sm *MockWebAPISessionManager, aimsid string) {
+				sm.On("GetSession", mock.Anything, aimsid).Return(nil, state.ErrNoWebAPISession)
+			},
+			expectedStatusCode: http.StatusNotFound,
+			expectedResponse:   `{"response":{"statusCode":404,"statusText":"session not found"}}`,
+			expectNextCalled:   false,
+		},
+		{
+			name:   "Error_SessionExpired",
+			aimsid: "expired-session",
+			setupMocks: func(sm *MockWebAPISessionManager, aimsid string) {
+				sm.On("GetSession", mock.Anything, aimsid).Return(nil, state.ErrWebAPISessionExpired)
+			},
+			expectedStatusCode: http.StatusGone,
+			expectedResponse:   `{"response":{"statusCode":410,"statusText":"session expired"}}`,
+			expectNextCalled:   false,
+		},
+		{
+			name:   "Error_InternalSessionError",
+			aimsid: "some-session",
+			setupMocks: func(sm *MockWebAPISessionManager, aimsid string) {
+				sm.On("GetSession", mock.Anything, aimsid).Return(nil, errors.New("db error"))
+			},
+			expectedStatusCode: http.StatusInternalServerError,
+			expectedResponse:   `{"response":{"statusCode":500,"statusText":"internal server error"}}`,
+			expectNextCalled:   false,
+		},
+		{
+			name:   "Success_PassesSessionToNext",
+			aimsid: "valid-session",
+			setupMocks: func(sm *MockWebAPISessionManager, aimsid string) {
+				sess := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("testuser"),
+					LastAccessed: time.Now(),
+				}
+				sm.On("GetSession", mock.Anything, aimsid).Return(sess, nil)
+				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK"}}`,
+			expectNextCalled:   true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sm := &MockWebAPISessionManager{}
+			tt.setupMocks(sm, tt.aimsid)
+
+			handler := &BuddyListHandler{
+				SessionManager: sm,
+				Logger:         slog.Default(),
+			}
+
+			nextCalled := false
+			next := func(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+				nextCalled = true
+				resp := BaseResponse{}
+				resp.Response.StatusCode = 200
+				resp.Response.StatusText = "OK"
+				SendResponse(w, r, resp, handler.Logger)
+			}
+
+			wrapped := handler.SessionMiddleware(next)
+
+			reqURL := "/buddylist/test"
+			if tt.aimsid != "" {
+				reqURL += "?aimsid=" + tt.aimsid
+			}
+			req, err := http.NewRequest("GET", reqURL, nil)
+			assert.NoError(t, err)
+
+			rr := httptest.NewRecorder()
+			wrapped.ServeHTTP(rr, req)
+
+			assert.Equal(t, tt.expectedStatusCode, rr.Code)
+			assert.Equal(t, tt.expectedResponse, strings.TrimSpace(rr.Body.String()))
+			assert.Equal(t, tt.expectNextCalled, nextCalled)
+
+			sm.AssertExpectations(t)
 		})
 		})
 	}
 	}
 }
 }

+ 47 - 3
server/webapi/handlers/common.go

@@ -43,6 +43,7 @@ type BaseResponse struct {
 type ResponseBody struct {
 type ResponseBody struct {
 	StatusCode int         `json:"statusCode" xml:"statusCode"`
 	StatusCode int         `json:"statusCode" xml:"statusCode"`
 	StatusText string      `json:"statusText" xml:"statusText"`
 	StatusText string      `json:"statusText" xml:"statusText"`
+	RequestID  string      `json:"requestId,omitempty" xml:"requestId,omitempty"`
 	Data       interface{} `json:"data,omitempty" xml:"data,omitempty"`
 	Data       interface{} `json:"data,omitempty" xml:"data,omitempty"`
 }
 }
 
 
@@ -92,20 +93,46 @@ type XMLToken struct {
 	ExpiresIn int    `xml:"expiresIn"`
 	ExpiresIn int    `xml:"expiresIn"`
 }
 }
 
 
+// requestIDFromRequest returns the Web AIM client request correlation id from the
+// "r" query parameter. JSONP callbacks require this echoed in response.requestId.
+func requestIDFromRequest(r *http.Request) string {
+	if r == nil {
+		return ""
+	}
+	return r.URL.Query().Get("r")
+}
+
+// attachRequestID copies the request's "r" parameter into BaseResponse.requestId
+// when the handler did not set one explicitly.
+func attachRequestID(r *http.Request, data interface{}) interface{} {
+	id := requestIDFromRequest(r)
+	if id == "" {
+		return data
+	}
+	br, ok := data.(BaseResponse)
+	if !ok || br.Response.RequestID != "" {
+		return data
+	}
+	br.Response.RequestID = id
+	return br
+}
+
 // SendResponse sends a response in the requested format (JSON, JSONP, XML, or AMF).
 // SendResponse sends a response in the requested format (JSON, JSONP, XML, or AMF).
 // This is the centralized function that all handlers should use for responses.
 // This is the centralized function that all handlers should use for responses.
 func SendResponse(w http.ResponseWriter, r *http.Request, data interface{}, logger *slog.Logger) {
 func SendResponse(w http.ResponseWriter, r *http.Request, data interface{}, logger *slog.Logger) {
+	data = attachRequestID(r, data)
+
 	// Check for format parameter (f for format or callback for JSONP)
 	// Check for format parameter (f for format or callback for JSONP)
 	// First check URL query parameters
 	// First check URL query parameters
 	format := strings.ToLower(r.URL.Query().Get("f"))
 	format := strings.ToLower(r.URL.Query().Get("f"))
-	callback := r.URL.Query().Get("callback")
+	callback := JSONPCallback(r)
 
 
 	// If format not in URL query, check form values (for POST requests)
 	// If format not in URL query, check form values (for POST requests)
 	if format == "" && r.Method == "POST" {
 	if format == "" && r.Method == "POST" {
 		_ = r.ParseForm()
 		_ = r.ParseForm()
 		format = strings.ToLower(r.FormValue("f"))
 		format = strings.ToLower(r.FormValue("f"))
 		if callback == "" {
 		if callback == "" {
-			callback = r.FormValue("callback")
+			callback = JSONPCallback(r)
 		}
 		}
 	}
 	}
 
 
@@ -188,10 +215,18 @@ func SendXMLError(w http.ResponseWriter, statusCode int, message string) {
 // SendJSON sends a JSON response.
 // SendJSON sends a JSON response.
 func SendJSON(w http.ResponseWriter, data interface{}, logger *slog.Logger) {
 func SendJSON(w http.ResponseWriter, data interface{}, logger *slog.Logger) {
 	w.Header().Set("Content-Type", "application/json")
 	w.Header().Set("Content-Type", "application/json")
-	if err := json.NewEncoder(w).Encode(data); err != nil {
+	body, err := json.Marshal(data)
+	if err != nil {
 		if logger != nil {
 		if logger != nil {
 			logger.Error("failed to encode JSON response", "err", err.Error())
 			logger.Error("failed to encode JSON response", "err", err.Error())
 		}
 		}
+		return
+	}
+	if logger != nil {
+		logger.Debug("JSON response", "body", string(body))
+	}
+	if _, err := w.Write(body); err != nil && logger != nil {
+		logger.Error("failed to write JSON response", "err", err.Error())
 	}
 	}
 }
 }
 
 
@@ -222,6 +257,15 @@ func SendXML(w http.ResponseWriter, data interface{}, logger *slog.Logger) {
 	_, _ = w.Write([]byte(xmlOutput))
 	_, _ = w.Write([]byte(xmlOutput))
 }
 }
 
 
+// JSONPCallback returns the JSONP callback name from the request.
+// Web AIM clients use the "c" query parameter; other callers may use "callback".
+func JSONPCallback(r *http.Request) string {
+	if callback := r.URL.Query().Get("c"); callback != "" {
+		return callback
+	}
+	return r.URL.Query().Get("callback")
+}
+
 // SendJSONP sends a JSONP response with the specified callback.
 // SendJSONP sends a JSONP response with the specified callback.
 func SendJSONP(w http.ResponseWriter, callback string, data interface{}, logger *slog.Logger) {
 func SendJSONP(w http.ResponseWriter, callback string, data interface{}, logger *slog.Logger) {
 	// Validate callback to prevent XSS
 	// Validate callback to prevent XSS

+ 74 - 0
server/webapi/handlers/common_test.go

@@ -0,0 +1,74 @@
+package handlers
+
+import (
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAttachRequestID(t *testing.T) {
+	t.Run("sets requestId from r query param", func(t *testing.T) {
+		req := httptest.NewRequest("GET", "/buddylist/addBuddy?r=abc123", nil)
+		data := attachRequestID(req, BaseResponse{
+			Response: ResponseBody{
+				StatusCode: 200,
+				StatusText: "OK",
+			},
+		})
+
+		br, ok := data.(BaseResponse)
+		assert.True(t, ok)
+		assert.Equal(t, "abc123", br.Response.RequestID)
+	})
+
+	t.Run("preserves explicit requestId", func(t *testing.T) {
+		req := httptest.NewRequest("GET", "/buddylist/addBuddy?r=abc123", nil)
+		data := attachRequestID(req, BaseResponse{
+			Response: ResponseBody{
+				StatusCode: 200,
+				StatusText: "OK",
+				RequestID:  "existing",
+			},
+		})
+
+		br, ok := data.(BaseResponse)
+		assert.True(t, ok)
+		assert.Equal(t, "existing", br.Response.RequestID)
+	})
+
+	t.Run("no-op without r param", func(t *testing.T) {
+		req := httptest.NewRequest("GET", "/buddylist/addBuddy", nil)
+		data := attachRequestID(req, BaseResponse{
+			Response: ResponseBody{
+				StatusCode: 200,
+				StatusText: "OK",
+			},
+		})
+
+		br, ok := data.(BaseResponse)
+		assert.True(t, ok)
+		assert.Empty(t, br.Response.RequestID)
+	})
+}
+
+func TestSendResponseIncludesRequestID(t *testing.T) {
+	req := httptest.NewRequest("GET", "/buddylist/addBuddy?r=req-42&f=json", nil)
+	rr := httptest.NewRecorder()
+
+	resp := BaseResponse{
+		Response: ResponseBody{
+			StatusCode: 200,
+			StatusText: "OK",
+			Data:       map[string]string{"resultCode": "success"},
+		},
+	}
+	SendResponse(rr, req, resp, slog.Default())
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	body := strings.TrimSpace(rr.Body.String())
+	assert.Equal(t, `{"response":{"statusCode":200,"statusText":"OK","requestId":"req-42","data":{"resultCode":"success"}}}`, body)
+}

+ 95 - 0
server/webapi/handlers/conversation_stub.go

@@ -0,0 +1,95 @@
+package handlers
+
+import (
+	"log/slog"
+	"net/http"
+	"strconv"
+
+	"github.com/mk6i/open-oscar-server/state"
+)
+
+// ConversationStubHandler serves Web AIM conversation/imlog endpoints the
+// client calls when syncing chat focus and read state.
+type ConversationStubHandler struct {
+	SessionManager *state.WebAPISessionManager
+	Logger         *slog.Logger
+}
+
+func (h *ConversationStubHandler) ok(w http.ResponseWriter, r *http.Request) {
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	SendResponse(w, r, resp, h.Logger)
+}
+
+// Update records active/focus time for a conversation (fire-and-forget).
+func (h *ConversationStubHandler) Update(w http.ResponseWriter, r *http.Request) {
+	h.ok(w, r)
+}
+
+// Close acknowledges a conversation was closed in the client.
+func (h *ConversationStubHandler) Close(w http.ResponseWriter, r *http.Request) {
+	h.ok(w, r)
+}
+
+// MarkRead acknowledges IM log read state for a buddy.
+func (h *ConversationStubHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
+	h.ok(w, r)
+}
+
+// FetchStoredIMs returns stored IM history for a conversation partner.
+func (h *ConversationStubHandler) FetchStoredIMs(w http.ResponseWriter, r *http.Request) {
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		SendError(w, http.StatusBadRequest, "missing required parameter: aimsid")
+		return
+	}
+
+	sess, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		if err == state.ErrNoWebAPISession || err == state.ErrWebAPISessionExpired {
+			SendError(w, http.StatusUnauthorized, "invalid or expired session")
+		} else {
+			SendError(w, http.StatusInternalServerError, "internal server error")
+		}
+		return
+	}
+
+	partner := r.URL.Query().Get("to")
+	if partner == "" {
+		SendError(w, http.StatusBadRequest, "missing required parameter: to")
+		return
+	}
+
+	q := state.StoredIMQuery{
+		PartnerAimID: partner,
+		SortOrder:    r.URL.Query().Get("sortOrder"),
+		SkipMsgID:    r.URL.Query().Get("skipMsgId"),
+		StopMsgID:    r.URL.Query().Get("stopMsgId"),
+	}
+	if n := r.URL.Query().Get("nToGet"); n != "" {
+		if v, err := strconv.Atoi(n); err == nil {
+			q.NToGet = v
+		}
+	}
+	if start := r.URL.Query().Get("startTime"); start != "" {
+		if v, err := strconv.ParseInt(start, 10, 64); err == nil {
+			q.StartTime = v
+		}
+	}
+	if end := r.URL.Query().Get("endTime"); end != "" {
+		if v, err := strconv.ParseInt(end, 10, 64); err == nil {
+			q.EndTime = v
+		}
+	}
+
+	msgs := sess.GetStoredIMs(q)
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]interface{}{
+		"msgs": msgs,
+	}
+	SendResponse(w, r, resp, h.Logger)
+}

+ 2 - 2
server/webapi/handlers/events.go

@@ -86,11 +86,11 @@ func (h *EventsHandler) FetchEvents(w http.ResponseWriter, r *http.Request) {
 		}
 		}
 	}
 	}
 
 
-	// Get timeout parameter (in seconds, convert to milliseconds)
+	// Timeout is in milliseconds (per Web API spec and client behavior).
 	timeout := time.Duration(session.FetchTimeout) * time.Millisecond
 	timeout := time.Duration(session.FetchTimeout) * time.Millisecond
 	if timeoutStr := r.URL.Query().Get("timeout"); timeoutStr != "" {
 	if timeoutStr := r.URL.Query().Get("timeout"); timeoutStr != "" {
 		if val, err := strconv.Atoi(timeoutStr); err == nil && val > 0 {
 		if val, err := strconv.Atoi(timeoutStr); err == nil && val > 0 {
-			timeout = time.Duration(val) * time.Second
+			timeout = time.Duration(val) * time.Millisecond
 		}
 		}
 	}
 	}
 
 

+ 198 - 0
server/webapi/handlers/login_psp.go

@@ -0,0 +1,198 @@
+package handlers
+
+import (
+	"fmt"
+	"html/template"
+	"net"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+)
+
+const loginPSPCookieMaxAge = 86400
+
+var loginPSPPage = template.Must(template.New("login.psp").Parse(`<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Sign in to AIM</title>
+  <style>
+    body { font-family: Arial, Helvetica, sans-serif; background: #0e95ad; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
+    .card { background: #fff; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.2); width: 360px; padding: 32px; }
+    h1 { margin: 0 0 8px; font-size: 24px; color: #222; }
+    p { margin: 0 0 20px; color: #666; font-size: 14px; }
+    label { display: block; font-size: 13px; font-weight: bold; margin-bottom: 6px; color: #333; }
+    input[type=text], input[type=password] { width: 100%; box-sizing: border-box; padding: 10px 12px; margin-bottom: 16px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
+    button { width: 100%; padding: 12px; border: 0; border-radius: 4px; background: #ff6600; color: #fff; font-size: 15px; font-weight: bold; cursor: pointer; }
+    button:hover { background: #e55c00; }
+    .error { background: #fdecea; color: #b42318; border: 1px solid #f5c2c0; border-radius: 4px; padding: 10px 12px; margin-bottom: 16px; font-size: 13px; }
+  </style>
+</head>
+<body>
+  <form class="card" method="post" action="/_cqr/login/login.psp">
+    <h1>AIM Sign In</h1>
+    <p>Sign in with your Open OSCAR account.</p>
+    {{if .Error}}<div class="error">{{.Error}}</div>{{end}}
+    <label for="loginId">Screen name</label>
+    <input id="loginId" name="loginId" type="text" autocomplete="username" value="{{.LoginID}}" required>
+    <label for="password">Password</label>
+    <input id="password" name="password" type="password" autocomplete="current-password" required>
+    <input type="hidden" name="devId" value="{{.DevID}}">
+    <input type="hidden" name="supportedIdType" value="{{.SupportedIDType}}">
+    <input type="hidden" name="succUrl" value="{{.SuccURL}}">
+    <input type="hidden" name="r" value="{{.R}}">
+    <button type="submit">Sign In</button>
+  </form>
+</body>
+</html>`))
+
+type loginPSPPageData struct {
+	Error           string
+	LoginID         string
+	DevID           string
+	SupportedIDType string
+	SuccURL         string
+	R               string
+}
+
+// LoginPSP handles GET and POST /_cqr/login/login.psp for Web AIM SSO login.
+func (h *AuthHandler) LoginPSP(w http.ResponseWriter, r *http.Request) {
+	switch r.Method {
+	case http.MethodGet:
+		h.renderLoginPSP(w, r, loginPSPPageData{
+			DevID:           r.URL.Query().Get("devId"),
+			SupportedIDType: r.URL.Query().Get("supportedIdType"),
+			SuccURL:         r.URL.Query().Get("succUrl"),
+			R:               r.URL.Query().Get("r"),
+		})
+	case http.MethodPost:
+		if err := r.ParseForm(); err != nil {
+			http.Error(w, "invalid form", http.StatusBadRequest)
+			return
+		}
+		loginID := strings.TrimSpace(r.FormValue("loginId"))
+		if loginID == "" {
+			loginID = strings.TrimSpace(r.FormValue("s"))
+		}
+		password := r.FormValue("password")
+		if password == "" {
+			password = r.FormValue("pwd")
+		}
+
+		data := loginPSPPageData{
+			LoginID:         loginID,
+			DevID:           r.FormValue("devId"),
+			SupportedIDType: r.FormValue("supportedIdType"),
+			SuccURL:         r.FormValue("succUrl"),
+			R:               r.FormValue("r"),
+		}
+
+		if loginID == "" || password == "" {
+			data.Error = "Screen name and password are required."
+			h.renderLoginPSP(w, r, data)
+			return
+		}
+
+		if err := h.authenticateCredentials(r, loginID, password); err != nil {
+			h.Logger.DebugContext(r.Context(), "login.psp failed", "loginId", loginID, "error", err)
+			data.Error = "Invalid screen name or password."
+			h.renderLoginPSP(w, r, data)
+			return
+		}
+
+		screenName := state.DisplayScreenName(loginID)
+		h.setLoginPSPCookies(w, screenName)
+		redirectURL := safeLoginRedirectURL(r, data.SuccURL)
+		h.Logger.InfoContext(r.Context(), "login.psp succeeded", "loginId", screenName, "redirect", redirectURL)
+		http.Redirect(w, r, redirectURL, http.StatusFound)
+	default:
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+	}
+}
+
+func (h *AuthHandler) renderLoginPSP(w http.ResponseWriter, r *http.Request, data loginPSPPageData) {
+	if data.SuccURL == "" {
+		data.SuccURL = defaultLoginSuccURL(r)
+	}
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	if err := loginPSPPage.Execute(w, data); err != nil {
+		h.Logger.ErrorContext(r.Context(), "failed to render login.psp", "error", err)
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+	}
+}
+
+func (h *AuthHandler) authenticateCredentials(r *http.Request, username, password string) error {
+	signonFrame := wire.FLAPSignonFrame{}
+	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsScreenName, username))
+	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsPlaintextPassword, password))
+	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsMultiConnFlags, wire.MultiConnFlagsRecentClient))
+
+	block, err := h.AuthService.FLAPLogin(r.Context(), signonFrame, "")
+	if err != nil {
+		return err
+	}
+	if block.HasTag(wire.LoginTLVTagsErrorSubcode) {
+		return fmt.Errorf("login failed")
+	}
+	return nil
+}
+
+func (h *AuthHandler) setLoginPSPCookies(w http.ResponseWriter, screenName state.DisplayScreenName) {
+	loginID := string(screenName)
+	expires := time.Now().Add(loginPSPCookieMaxAge * time.Second)
+	cookie := func(name, value string) *http.Cookie {
+		return &http.Cookie{
+			Name:     name,
+			Value:    value,
+			Path:     "/",
+			Expires:  expires,
+			MaxAge:   loginPSPCookieMaxAge,
+			HttpOnly: false,
+			SameSite: http.SameSiteLaxMode,
+		}
+	}
+	http.SetCookie(w, cookie("RSP_USER", loginID))
+	http.SetCookie(w, cookie("RSP_LOCAL", loginID))
+	http.SetCookie(w, cookie("localAuthUser", loginID+"||"+loginID))
+}
+
+func defaultLoginSuccURL(r *http.Request) string {
+	scheme := "http"
+	if r.TLS != nil {
+		scheme = "https"
+	}
+	return scheme + "://" + r.Host + "/"
+}
+
+func safeLoginRedirectURL(r *http.Request, succURL string) string {
+	succURL = strings.TrimSpace(succURL)
+	if succURL == "" {
+		return defaultLoginSuccURL(r)
+	}
+	target, err := url.Parse(succURL)
+	if err != nil {
+		return defaultLoginSuccURL(r)
+	}
+	if target.Host == "" {
+		return succURL
+	}
+	reqHost := hostnameOnly(r.Host)
+	targetHost := hostnameOnly(target.Host)
+	if targetHost == reqHost || targetHost == "localhost" || targetHost == "127.0.0.1" {
+		return succURL
+	}
+	return defaultLoginSuccURL(r)
+}
+
+func hostnameOnly(hostport string) string {
+	host, _, err := net.SplitHostPort(hostport)
+	if err != nil {
+		return hostport
+	}
+	return host
+}

+ 93 - 0
server/webapi/handlers/login_psp_test.go

@@ -0,0 +1,93 @@
+package handlers
+
+import (
+	"context"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/mk6i/open-oscar-server/wire"
+)
+
+func TestAuthHandler_LoginPSP_GET(t *testing.T) {
+	handler := &AuthHandler{Logger: slog.Default()}
+
+	req := httptest.NewRequest(http.MethodGet, "/_cqr/login/login.psp?devId=dev1&succUrl=http%3A%2F%2Flocalhost%3A8000%2F", nil)
+	rr := httptest.NewRecorder()
+
+	handler.LoginPSP(rr, req)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Header().Get("Content-Type"), "text/html")
+	assert.Contains(t, rr.Body.String(), "AIM Sign In")
+	assert.Contains(t, rr.Body.String(), `name="devId" value="dev1"`)
+}
+
+func TestAuthHandler_LoginPSP_POST_Success(t *testing.T) {
+	handler := &AuthHandler{
+		AuthService: &testAuthService{
+			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+				return successfulLoginBlock(), nil
+			},
+		},
+		Logger: slog.Default(),
+	}
+
+	form := url.Values{}
+	form.Set("loginId", "testuser")
+	form.Set("password", "secret")
+	form.Set("devId", "dev1")
+	form.Set("succUrl", "http://localhost:8000/")
+	req := httptest.NewRequest(http.MethodPost, "/_cqr/login/login.psp", strings.NewReader(form.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr := httptest.NewRecorder()
+
+	handler.LoginPSP(rr, req)
+
+	assert.Equal(t, http.StatusFound, rr.Code)
+	assert.Equal(t, "http://localhost:8000/", rr.Header().Get("Location"))
+
+	cookies := rr.Result().Cookies()
+	names := make(map[string]string, len(cookies))
+	for _, c := range cookies {
+		names[c.Name] = c.Value
+	}
+	assert.Equal(t, "testuser", names["RSP_USER"])
+	assert.Equal(t, "testuser", names["RSP_LOCAL"])
+	assert.Equal(t, "testuser||testuser", names["localAuthUser"])
+}
+
+func TestAuthHandler_LoginPSP_POST_InvalidCredentials(t *testing.T) {
+	handler := &AuthHandler{
+		AuthService: &testAuthService{
+			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+				return failedLoginBlock(), nil
+			},
+		},
+		Logger: slog.Default(),
+	}
+
+	form := url.Values{}
+	form.Set("loginId", "testuser")
+	form.Set("password", "wrong")
+	req := httptest.NewRequest(http.MethodPost, "/_cqr/login/login.psp", strings.NewReader(form.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr := httptest.NewRecorder()
+
+	handler.LoginPSP(rr, req)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid screen name or password")
+}
+
+func TestSafeLoginRedirectURL(t *testing.T) {
+	req := httptest.NewRequest(http.MethodGet, "http://localhost/_cqr/login/login.psp", nil)
+
+	assert.Equal(t, "http://localhost:8000/", safeLoginRedirectURL(req, "http://localhost:8000/"))
+	assert.Equal(t, "http://localhost/", safeLoginRedirectURL(req, "http://evil.example/"))
+}

+ 68 - 24
server/webapi/handlers/messaging.go

@@ -42,12 +42,27 @@ type MessagingHandler struct {
 	Logger                *slog.Logger
 	Logger                *slog.Logger
 }
 }
 
 
+// queryOrFormParam returns a request parameter from the query string or, for POST
+// requests, from application/x-www-form-urlencoded body fields. The Web AIM client
+// sends t/offlineIM/etc. on the query string and puts message in the POST body.
+func queryOrFormParam(r *http.Request, key string) string {
+	if v := r.URL.Query().Get(key); v != "" {
+		return v
+	}
+	if r.Method == http.MethodPost {
+		if err := r.ParseForm(); err == nil {
+			return r.FormValue(key)
+		}
+	}
+	return ""
+}
+
 // SendIM handles the /im/sendIM endpoint for sending instant messages
 // SendIM handles the /im/sendIM endpoint for sending instant messages
 func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request) {
 func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request) {
 	ctx := r.Context()
 	ctx := r.Context()
 
 
 	// Get session from aimsid
 	// Get session from aimsid
-	aimsid := r.URL.Query().Get("aimsid")
+	aimsid := queryOrFormParam(r, "aimsid")
 	if aimsid == "" {
 	if aimsid == "" {
 		h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: aimsid")
 		h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: aimsid")
 		return
 		return
@@ -69,21 +84,22 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 
 
 	// Parse parameters
 	// Parse parameters
-	recipient := r.URL.Query().Get("t")
+	recipient := queryOrFormParam(r, "t")
 	if recipient == "" {
 	if recipient == "" {
 		h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: t (recipient)")
 		h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: t (recipient)")
 		return
 		return
 	}
 	}
 
 
-	message := r.URL.Query().Get("message")
+	message := queryOrFormParam(r, "message")
 	if message == "" {
 	if message == "" {
 		h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: message")
 		h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: message")
 		return
 		return
 	}
 	}
 
 
 	// Parse optional parameters
 	// Parse optional parameters
-	autoResponse := r.URL.Query().Get("autoResponse") == "1"
-	offlineIM := r.URL.Query().Get("offlineIM") != "0" // default to true
+	autoResponse := queryOrFormParam(r, "autoResponse") == "1"
+	offlineIMParam := queryOrFormParam(r, "offlineIM")
+	offlineIM := offlineIMParam != "0" && offlineIMParam != "false" // default to true
 
 
 	// Create recipient identifier
 	// Create recipient identifier
 	recipientIdent := state.NewIdentScreenName(recipient)
 	recipientIdent := state.NewIdentScreenName(recipient)
@@ -143,6 +159,11 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request) {
 		binary.BigEndian.Uint16([]byte{0x80, 0x00}), // Version bits
 		binary.BigEndian.Uint16([]byte{0x80, 0x00}), // Version bits
 		time.Now().UnixNano()&0xffffffffffff)
 		time.Now().UnixNano()&0xffffffffffff)
 
 
+	now := float64(time.Now().Unix())
+	nowSec := time.Now().Unix()
+	sn := sess.ScreenName.String()
+	sess.AddStoredIM(recipient, sn, message, messageID, nowSec)
+
 	if recipientSession == nil {
 	if recipientSession == nil {
 		// Recipient is offline
 		// Recipient is offline
 		if offlineIM {
 		if offlineIM {
@@ -185,6 +206,8 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request) {
 				"from", sess.ScreenName.String(),
 				"from", sess.ScreenName.String(),
 				"to", recipient,
 				"to", recipient,
 				"count", count)
 				"count", count)
+
+			h.pushSenderWebAPIEvents(sess, sn, recipient, message, messageID, now, autoResponse)
 		} else {
 		} else {
 			// Recipient is offline and offline delivery is disabled
 			// Recipient is offline and offline delivery is disabled
 			h.sendErrorResponse(w, http.StatusNotFound, "recipient is not online")
 			h.sendErrorResponse(w, http.StatusNotFound, "recipient is not online")
@@ -219,32 +242,28 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request) {
 
 
 		// Queue IM event for the recipient's WebAPI session if they have one
 		// Queue IM event for the recipient's WebAPI session if they have one
 		if recipientWebSession, err := h.SessionManager.GetSessionByUser(r.Context(), recipientIdent); err == nil && recipientWebSession != nil {
 		if recipientWebSession, err := h.SessionManager.GetSessionByUser(r.Context(), recipientIdent); err == nil && recipientWebSession != nil {
+			recipientWebSession.AddStoredIM(sn, sn, message, messageID, nowSec)
 			eventData := types.IMEvent{
 			eventData := types.IMEvent{
-				From:      sess.ScreenName.String(),
+				Source: types.UserInfo{
+					AimID:     sn,
+					DisplayID: sn,
+					UserType:  "aim",
+					State:     "online",
+				},
 				Message:   message,
 				Message:   message,
-				Timestamp: float64(time.Now().Unix()),
+				MsgID:     messageID,
+				Timestamp: now,
 				AutoResp:  autoResponse,
 				AutoResp:  autoResponse,
 			}
 			}
 			recipientWebSession.EventQueue.Push(types.EventTypeIM, eventData)
 			recipientWebSession.EventQueue.Push(types.EventTypeIM, eventData)
+			if recipientWebSession.IsSubscribedTo("conversation") {
+				recipientWebSession.EventQueue.Push(types.EventTypeConversation, types.ConversationEventData("update", []map[string]interface{}{
+					types.ConversationEntry(sn, sn, message, messageID, sn, false, 1),
+				}))
+			}
 		}
 		}
 
 
-		// Also queue sentIM event for the sender's WebAPI session to show in their UI
-		senderEventData := types.SentIMEvent{
-			Sender: types.UserInfo{
-				AimID:     sess.ScreenName.String(),
-				DisplayID: sess.ScreenName.String(),
-				UserType:  "aim",
-			},
-			Dest: types.UserInfo{
-				AimID:     recipient,
-				DisplayID: recipient,
-				UserType:  "aim",
-			},
-			Message:   message,
-			Timestamp: float64(time.Now().Unix()),
-			AutoResp:  autoResponse,
-		}
-		sess.EventQueue.Push(types.EventTypeSentIM, senderEventData)
+		h.pushSenderWebAPIEvents(sess, sn, recipient, message, messageID, now, autoResponse)
 
 
 		h.Logger.DebugContext(ctx, "queued sentIM event for sender",
 		h.Logger.DebugContext(ctx, "queued sentIM event for sender",
 			"from", sess.ScreenName.String(),
 			"from", sess.ScreenName.String(),
@@ -269,6 +288,31 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request) {
 	SendResponse(w, r, response, h.Logger)
 	SendResponse(w, r, response, h.Logger)
 }
 }
 
 
+func (h *MessagingHandler) pushSenderWebAPIEvents(sess *state.WebAPISession, sender, recipient, message, messageID string, now float64, autoResponse bool) {
+	senderEventData := types.SentIMEvent{
+		Sender: types.UserInfo{
+			AimID:     sender,
+			DisplayID: sender,
+			UserType:  "aim",
+		},
+		Dest: types.UserInfo{
+			AimID:     recipient,
+			DisplayID: recipient,
+			UserType:  "aim",
+		},
+		Message:   message,
+		MsgID:     messageID,
+		Timestamp: now,
+		AutoResp:  autoResponse,
+	}
+	sess.EventQueue.Push(types.EventTypeSentIM, senderEventData)
+	if sess.IsSubscribedTo("conversation") {
+		sess.EventQueue.Push(types.EventTypeConversation, types.ConversationEventData("update", []map[string]interface{}{
+			types.ConversationEntry(recipient, recipient, message, messageID, sender, true, 0),
+		}))
+	}
+}
+
 // encodeIMMessage encodes a text message into the OSCAR IM format
 // encodeIMMessage encodes a text message into the OSCAR IM format
 func (h *MessagingHandler) encodeIMMessage(text string, autoResponse bool) []byte {
 func (h *MessagingHandler) encodeIMMessage(text string, autoResponse bool) []byte {
 	// Create ICBM fragment list for the message
 	// Create ICBM fragment list for the message

+ 38 - 0
server/webapi/handlers/messaging_test.go

@@ -5,6 +5,7 @@ import (
 	"log/slog"
 	"log/slog"
 	"net/http"
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptest"
+	"net/url"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
 
 
@@ -241,6 +242,43 @@ func TestMessagingHandler_SendIM(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestMessagingHandler_SendIM_POST(t *testing.T) {
+	messageRelayer := &MockMessageRelayer{}
+	sessionRetriever := &MockSessionRetriever{}
+	relFetcher := &MockRelationshipFetcher{}
+
+	sessionMgr, aimsid := createTestSessionManager("testuser")
+
+	relFetcher.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("recipient")).
+		Return(state.Relationship{}, nil)
+	sessionRetriever.On("RetrieveSession", state.NewIdentScreenName("recipient")).
+		Return(&state.Session{})
+	messageRelayer.On("RelayToScreenName", mock.Anything, state.NewIdentScreenName("recipient"), mock.AnythingOfType("wire.SNACMessage")).
+		Return()
+
+	handler := &MessagingHandler{
+		SessionManager:      sessionMgr,
+		MessageRelayer:      messageRelayer,
+		SessionRetriever:    sessionRetriever,
+		RelationshipFetcher: relFetcher,
+		Logger:              slog.Default(),
+	}
+
+	body := strings.NewReader("message=" + url.QueryEscape("hello from post"))
+	req, err := http.NewRequest(http.MethodPost, "/im/sendIM?aimsid="+aimsid+"&f=json&t=recipient&r=1", body)
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+	rr := httptest.NewRecorder()
+	handler.SendIM(rr, req)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), `"msgId"`)
+	messageRelayer.AssertExpectations(t)
+	sessionRetriever.AssertExpectations(t)
+	relFetcher.AssertExpectations(t)
+}
+
 func TestMessagingHandler_SendIM_MissingAimsid(t *testing.T) {
 func TestMessagingHandler_SendIM_MissingAimsid(t *testing.T) {
 	handler := &MessagingHandler{
 	handler := &MessagingHandler{
 		SessionManager: state.NewWebAPISessionManager(),
 		SessionManager: state.NewWebAPISessionManager(),

+ 24 - 1
server/webapi/handlers/presence.go

@@ -53,6 +53,7 @@ type BuddyPresenceInfo struct {
 	State      string `json:"state" xml:"state"` // "online", "offline", "away", "idle"
 	State      string `json:"state" xml:"state"` // "online", "offline", "away", "idle"
 	StatusMsg  string `json:"statusMsg,omitempty" xml:"statusMsg,omitempty"`
 	StatusMsg  string `json:"statusMsg,omitempty" xml:"statusMsg,omitempty"`
 	AwayMsg    string `json:"awayMsg,omitempty" xml:"awayMsg,omitempty"`
 	AwayMsg    string `json:"awayMsg,omitempty" xml:"awayMsg,omitempty"`
+	ProfileMsg string `json:"profileMsg,omitempty" xml:"profileMsg,omitempty"`
 	IdleTime   int    `json:"idleTime,omitempty" xml:"idleTime,omitempty"`
 	IdleTime   int    `json:"idleTime,omitempty" xml:"idleTime,omitempty"`
 	OnlineTime int64  `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"`
 	OnlineTime int64  `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"`
 	UserType   string `json:"userType" xml:"userType"` // "aim", "icq", "admin"
 	UserType   string `json:"userType" xml:"userType"` // "aim", "icq", "admin"
@@ -90,6 +91,7 @@ func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request) {
 
 
 	// Check if buddy list is requested
 	// Check if buddy list is requested
 	getBuddyList := r.URL.Query().Get("bl") == "1"
 	getBuddyList := r.URL.Query().Get("bl") == "1"
+	wantProfileMsg := r.URL.Query().Get("profileMsg") == "1"
 
 
 	// Get target users if specified
 	// Get target users if specified
 	targetUsers := r.URL.Query().Get("t")
 	targetUsers := r.URL.Query().Get("t")
@@ -148,6 +150,11 @@ func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request) {
 				presenceList = append(presenceList, presence)
 				presenceList = append(presenceList, presence)
 			} else {
 			} else {
 				presence := h.getUserPresence(userScreenName)
 				presence := h.getUserPresence(userScreenName)
+				if wantProfileMsg && presence.ProfileMsg == "" && h.SessionRetriever != nil {
+					if oscarSess := h.SessionRetriever.RetrieveSession(userScreenName); oscarSess != nil {
+						presence.ProfileMsg = oscarSess.Profile().ProfileText
+					}
+				}
 				presenceList = append(presenceList, presence)
 				presenceList = append(presenceList, presence)
 			}
 			}
 		}
 		}
@@ -359,9 +366,14 @@ func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request) {
 		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
 		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
 	}
 	}
 
 
-	// Get the requested state
 	stateParam := r.URL.Query().Get("state")
 	stateParam := r.URL.Query().Get("state")
+	if stateParam == "" {
+		stateParam = r.URL.Query().Get("view")
+	}
 	awayMsg := r.URL.Query().Get("awayMsg")
 	awayMsg := r.URL.Query().Get("awayMsg")
+	if awayMsg == "" {
+		awayMsg = r.URL.Query().Get("away")
+	}
 
 
 	// Get OSCAR session if available
 	// Get OSCAR session if available
 	oscarSession := session.OSCARSession
 	oscarSession := session.OSCARSession
@@ -384,8 +396,10 @@ func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request) {
 	case "online":
 	case "online":
 		statusBitmask = 0x0000 // Clear all status bits
 		statusBitmask = 0x0000 // Clear all status bits
 		oscarSession.SetAwayMessage("")
 		oscarSession.SetAwayMessage("")
+		oscarSession.ClearUserInfoFlag(wire.OServiceUserFlagUnavailable)
 	case "away":
 	case "away":
 		statusBitmask = wire.OServiceUserStatusAway
 		statusBitmask = wire.OServiceUserStatusAway
+		oscarSession.SetUserInfoFlag(wire.OServiceUserFlagUnavailable)
 		if awayMsg != "" {
 		if awayMsg != "" {
 			oscarSession.SetAwayMessage(awayMsg)
 			oscarSession.SetAwayMessage(awayMsg)
 		}
 		}
@@ -427,6 +441,15 @@ func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request) {
 	response := BaseResponse{}
 	response := BaseResponse{}
 	response.Response.StatusCode = 200
 	response.Response.StatusCode = 200
 	response.Response.StatusText = "OK"
 	response.Response.StatusText = "OK"
+	response.Response.Data = map[string]interface{}{
+		"aimId":      session.ScreenName.String(),
+		"displayId":  session.ScreenName.String(),
+		"state":      stateParam,
+		"awayMsg":    awayMsg,
+		"statusMsg":  "",
+		"userType":   "aim",
+		"onlineTime": time.Now().Unix(),
+	}
 	SendResponse(w, r, response, h.Logger)
 	SendResponse(w, r, response, h.Logger)
 }
 }
 
 

+ 55 - 0
server/webapi/handlers/presence_test.go

@@ -36,6 +36,61 @@ func (m *MockFeedbagRetriever) RelationshipsByUser(ctx context.Context, screenNa
 	return nil, args.Error(1)
 	return nil, args.Error(1)
 }
 }
 
 
+// MockFeedbagService is a mock implementation of FeedbagService
+type MockFeedbagService struct {
+	mock.Mock
+}
+
+func (m *MockFeedbagService) DeleteItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (*wire.SNACMessage, error) {
+	args := m.Called(ctx, instance, inFrame, inBody)
+	if msg := args.Get(0); msg != nil {
+		return msg.(*wire.SNACMessage), args.Error(1)
+	}
+	return nil, args.Error(1)
+}
+
+func (m *MockFeedbagService) Query(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
+	args := m.Called(ctx, instance, inFrame)
+	return args.Get(0).(wire.SNACMessage), args.Error(1)
+}
+
+func (m *MockFeedbagService) QueryIfModified(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error) {
+	args := m.Called(ctx, instance, inFrame, inBody)
+	return args.Get(0).(wire.SNACMessage), args.Error(1)
+}
+
+func (m *MockFeedbagService) RespondAuthorizeToHost(ctx context.Context, instance state.IdentScreenName, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error {
+	args := m.Called(ctx, instance, inFrame, inBody)
+	return args.Error(0)
+}
+
+func (m *MockFeedbagService) RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	args := m.Called(ctx, inFrame)
+	return args.Get(0).(wire.SNACMessage)
+}
+
+func (m *MockFeedbagService) StartCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster) {
+	m.Called(ctx, instance, inFrame, inBody)
+}
+
+func (m *MockFeedbagService) EndCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) error {
+	args := m.Called(ctx, instance, inFrame)
+	return args.Error(0)
+}
+
+func (m *MockFeedbagService) UpsertItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) (*wire.SNACMessage, error) {
+	args := m.Called(ctx, instance, inFrame, items)
+	if msg := args.Get(0); msg != nil {
+		return msg.(*wire.SNACMessage), args.Error(1)
+	}
+	return nil, args.Error(1)
+}
+
+func (m *MockFeedbagService) Use(ctx context.Context, instance *state.SessionInstance) error {
+	args := m.Called(ctx, instance)
+	return args.Error(0)
+}
+
 // MockBuddyBroadcaster is a mock implementation of BuddyBroadcaster
 // MockBuddyBroadcaster is a mock implementation of BuddyBroadcaster
 type MockBuddyBroadcaster struct {
 type MockBuddyBroadcaster struct {
 	mock.Mock
 	mock.Mock

+ 225 - 73
server/webapi/handlers/session.go

@@ -2,6 +2,7 @@ package handlers
 
 
 import (
 import (
 	"context"
 	"context"
+	"encoding/base64"
 	"encoding/xml"
 	"encoding/xml"
 	"fmt"
 	"fmt"
 	"log/slog"
 	"log/slog"
@@ -25,16 +26,25 @@ type SessionHandler struct {
 	BuddyListService    BuddyListService
 	BuddyListService    BuddyListService
 	BuddyListRegistry   BuddyListRegistry
 	BuddyListRegistry   BuddyListRegistry
 	BuddyBroadcaster    BuddyBroadcaster
 	BuddyBroadcaster    BuddyBroadcaster
+	FeedbagRetriever    FeedbagRetriever
+	OSCARBuddyService   OSCARBuddyService
 	BuddyListManager    *BuddyListManager
 	BuddyListManager    *BuddyListManager
-	TokenStore          TokenStore
 	Logger              *slog.Logger
 	Logger              *slog.Logger
+	OServiceService     OServiceService
+	RecalcWarning       func(ctx context.Context, instance *state.SessionInstance) error
+	LowerWarnLevel      func(ctx context.Context, instance *state.SessionInstance)
+	ChatSessionManager  ChatSessionManager
 }
 }
 
 
 // AuthService defines methods needed for authentication.
 // AuthService defines methods needed for authentication.
 type AuthService interface {
 type AuthService interface {
 	BUCPChallenge(ctx context.Context, bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
 	BUCPChallenge(ctx context.Context, bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
 	BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, advertisedHost string) (wire.SNACMessage, error)
 	BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, advertisedHost string) (wire.SNACMessage, error)
+	CrackCookie(authCookie []byte) (state.ServerCookie, error)
 	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, conf func(sess *state.Session)) (*state.SessionInstance, error)
 	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, conf func(sess *state.Session)) (*state.SessionInstance, error)
+	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error)
+	Signout(ctx context.Context, session *state.Session)
+	SignoutChat(ctx context.Context, sess *state.Session)
 }
 }
 
 
 // SessionManager defines methods for OSCAR session management.
 // SessionManager defines methods for OSCAR session management.
@@ -55,6 +65,15 @@ type BuddyListService interface {
 	GetBuddyList(ctx context.Context, screenName state.IdentScreenName) ([]BuddyGroup, error)
 	GetBuddyList(ctx context.Context, screenName state.IdentScreenName) ([]BuddyGroup, error)
 }
 }
 
 
+// OSCARBuddyService defines the OSCAR buddy-list operations we need to emulate an OSCAR client.
+type OSCARBuddyService interface {
+	AddBuddies(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) (*wire.SNACMessage, error)
+}
+
+type ChatSessionManager interface {
+	RemoveUserFromAllChats(user state.IdentScreenName)
+}
+
 // BuddyGroup represents a group of buddies.
 // BuddyGroup represents a group of buddies.
 type BuddyGroup struct {
 type BuddyGroup struct {
 	Name    string  `json:"name"`
 	Name    string  `json:"name"`
@@ -77,9 +96,11 @@ type StartSessionResponse struct {
 		StatusText string `json:"statusText"`
 		StatusText string `json:"statusText"`
 		Data       struct {
 		Data       struct {
 			AimSID          string                 `json:"aimsid"`
 			AimSID          string                 `json:"aimsid"`
+			Ts              int64                  `json:"ts"`
 			FetchTimeout    int                    `json:"fetchTimeout"`
 			FetchTimeout    int                    `json:"fetchTimeout"`
 			TimeToNextFetch int                    `json:"timeToNextFetch"`
 			TimeToNextFetch int                    `json:"timeToNextFetch"`
 			FetchBaseURL    string                 `json:"fetchBaseURL"` // Gromit expects this directly in data!
 			FetchBaseURL    string                 `json:"fetchBaseURL"` // Gromit expects this directly in data!
+			MyInfo          map[string]interface{} `json:"myInfo,omitempty"`
 			Events          map[string]interface{} `json:"events,omitempty"`
 			Events          map[string]interface{} `json:"events,omitempty"`
 			WellKnownUrls   map[string]string      `json:"wellKnownUrls,omitempty"`
 			WellKnownUrls   map[string]string      `json:"wellKnownUrls,omitempty"`
 		} `json:"data"`
 		} `json:"data"`
@@ -97,8 +118,9 @@ type StartSessionXMLResponse struct {
 		TimeToNextFetch int    `xml:"timeToNextFetch"`
 		TimeToNextFetch int    `xml:"timeToNextFetch"`
 		FetchBaseURL    string `xml:"fetchBaseURL"` // Gromit expects this directly!
 		FetchBaseURL    string `xml:"fetchBaseURL"` // Gromit expects this directly!
 		WellKnownUrls   *struct {
 		WellKnownUrls   *struct {
-			WebApiBase   string `xml:"webApiBase"`
-			FetchBaseURL string `xml:"fetchBaseURL"`
+			WebApiBase        string `xml:"webApiBase"`
+			FetchBaseURL      string `xml:"fetchBaseURL"`
+			LifestreamApiBase string `xml:"lifestreamApiBase"`
 		} `xml:"wellKnownUrls,omitempty"`
 		} `xml:"wellKnownUrls,omitempty"`
 		MyInfo *struct {
 		MyInfo *struct {
 			AimID     string `xml:"aimId"`
 			AimID     string `xml:"aimId"`
@@ -130,7 +152,7 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	// Get API key info from context (set by auth middleware)
 	// Get API key info from context (set by auth middleware)
 	apiKey, ok := ctx.Value(middleware.ContextKeyAPIKey).(*state.WebAPIKey)
 	apiKey, ok := ctx.Value(middleware.ContextKeyAPIKey).(*state.WebAPIKey)
 	if !ok {
 	if !ok {
-		h.sendError(w, http.StatusInternalServerError, "internal server error")
+		h.sendError(w, r, http.StatusInternalServerError, "internal server error")
 		return
 		return
 	}
 	}
 
 
@@ -178,23 +200,22 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	// Determine screen name from auth token or anonymous
 	// Determine screen name from auth token or anonymous
 	var screenName state.DisplayScreenName
 	var screenName state.DisplayScreenName
 
 
+	var cookie state.ServerCookie
 	if authToken != "" {
 	if authToken != "" {
-		// Validate auth token and get screen name
-		if h.TokenStore == nil {
-			h.Logger.Error("TokenStore not configured")
-			h.sendError(w, http.StatusInternalServerError, "authentication not configured")
+		rawCookie, err := base64.URLEncoding.DecodeString(strings.TrimSpace(authToken))
+		if err != nil {
+			h.Logger.Warn("invalid authentication token (base64)", "error", err)
+			h.sendError(w, r, http.StatusUnauthorized, "invalid or expired token")
 			return
 			return
 		}
 		}
-		identScreenName, err := h.TokenStore.ValidateToken(r.Context(), authToken)
+		cookie, err = h.OSCARAuthService.CrackCookie(rawCookie)
 		if err != nil {
 		if err != nil {
 			h.Logger.Warn("invalid authentication token",
 			h.Logger.Warn("invalid authentication token",
 				"error", err)
 				"error", err)
-			h.sendError(w, http.StatusUnauthorized, "invalid or expired token")
+			h.sendError(w, r, http.StatusUnauthorized, "invalid or expired token")
 			return
 			return
 		}
 		}
-		// For WebAPI sessions, we can use the IdentScreenName directly as DisplayScreenName
-		// since WRAITH handles the display formatting
-		screenName = state.DisplayScreenName(identScreenName.String())
+		screenName = cookie.ScreenName
 		tokenPreview := authToken
 		tokenPreview := authToken
 		if len(tokenPreview) > 8 {
 		if len(tokenPreview) > 8 {
 			tokenPreview = tokenPreview[:8] + "..."
 			tokenPreview = tokenPreview[:8] + "..."
@@ -213,27 +234,104 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	var oscarInstance *state.SessionInstance
 	var oscarInstance *state.SessionInstance
 	var err error
 	var err error
 	if authToken != "" && h.OSCARSessionManager != nil {
 	if authToken != "" && h.OSCARSessionManager != nil {
+		fnCfg := func(sess *state.Session) {
+			sess.OnSessionClose(func() {
+				if !shuttingDown(ctx) {
+					if err := h.BuddyBroadcaster.BroadcastBuddyDeparted(ctx, sess.IdentScreenName()); err != nil {
+						h.Logger.ErrorContext(ctx, "error sending buddy departure notifications", "err", err.Error())
+					}
+				}
+
+				ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+				defer cancel()
+
+				// buddy list must be cleared before session is closed, otherwise
+				// there will be a race condition that could cause the buddy list
+				// be prematurely deleted.
+				if err := h.BuddyListRegistry.UnregisterBuddyList(ctx, sess.IdentScreenName()); err != nil {
+					h.Logger.ErrorContext(ctx, "error removing buddy list entry", "err", err.Error())
+				}
+				h.ChatSessionManager.RemoveUserFromAllChats(sess.IdentScreenName())
+				h.OSCARAuthService.Signout(ctx, sess)
+			})
+		}
+
 		// Create OSCAR session
 		// Create OSCAR session
-		oscarInstance, err = h.OSCARSessionManager.AddSession(ctx, screenName, true)
+		oscarInstance, err = h.OSCARAuthService.RegisterBOSSession(ctx, cookie, fnCfg)
+
 		if err != nil {
 		if err != nil {
 			h.Logger.ErrorContext(ctx, "failed to create OSCAR session", "err", err.Error())
 			h.Logger.ErrorContext(ctx, "failed to create OSCAR session", "err", err.Error())
 			// Continue without OSCAR session - WebAPI can work standalone
 			// Continue without OSCAR session - WebAPI can work standalone
+			// todo wat
 			oscarInstance = nil
 			oscarInstance = nil
 		} else {
 		} else {
+			if err = oscarInstance.Session().RunOnce(func() error {
+				// make buddy list visible to other users
+				if err := h.BuddyListRegistry.RegisterBuddyList(ctx, oscarInstance.IdentScreenName()); err != nil {
+					return fmt.Errorf("unable to init buddy list: %w", err)
+				}
+				// restore warning level from last session
+				if err := h.RecalcWarning(ctx, oscarInstance); err != nil {
+					return fmt.Errorf("failed to recalculate warning level: %w", err)
+				}
+				// periodically decay warning level
+				go h.LowerWarnLevel(ctx, oscarInstance)
+				return nil
+			}); err != nil {
+				h.Logger.ErrorContext(ctx, "failed to init session", "err", err.Error())
+				h.sendError(w, r, http.StatusInternalServerError, "internal server error")
+				return
+			}
+
+			// Update user visibility when an instance closes, as the user's overall status may change.
+			// Example: With 1 away and 1 non-away instance, the user appears available. If the non-away
+			// instance closes, the user should appear away.
+			oscarInstance.OnClose(func() {
+				if shuttingDown(ctx) {
+					return
+				}
+				if oscarInstance.Session().Invisible() {
+					if err := h.BuddyBroadcaster.BroadcastBuddyDeparted(ctx, oscarInstance.IdentScreenName()); err != nil {
+						h.Logger.ErrorContext(ctx, "error sending buddy departure notifications", "err", err.Error())
+					}
+				} else {
+					if err := h.BuddyBroadcaster.BroadcastBuddyArrived(ctx, oscarInstance.IdentScreenName(), oscarInstance.Session().TLVUserInfo()); err != nil {
+						h.Logger.ErrorContext(ctx, "error sending buddy arrival notifications", "err", err.Error())
+					}
+				}
+			})
+
 			oscarInstance.SetSignonComplete()
 			oscarInstance.SetSignonComplete()
 
 
-			// Register buddy list
-			if h.BuddyListRegistry != nil {
-				if err := h.BuddyListRegistry.RegisterBuddyList(ctx, screenName.IdentScreenName()); err != nil {
-					h.Logger.ErrorContext(ctx, "failed to register buddy list", "err", err.Error())
+			// Emulate an OSCAR client buddy watch list.
+			if h.FeedbagRetriever != nil && h.OSCARBuddyService != nil {
+				if items, err := h.FeedbagRetriever.RetrieveFeedbag(ctx, screenName.IdentScreenName()); err != nil {
+					h.Logger.ErrorContext(ctx, "failed to retrieve feedbag for buddy watch list", "err", err.Error())
+				} else {
+					var b wire.SNAC_0x03_0x04_BuddyAddBuddies
+					for _, item := range items {
+						if item.ClassID != wire.FeedbagClassIdBuddy {
+							continue
+						}
+						if strings.TrimSpace(item.Name) == "" {
+							continue
+						}
+						b.Buddies = append(b.Buddies, struct {
+							ScreenName string `oscar:"len_prefix=uint8"`
+						}{ScreenName: item.Name})
+					}
+					if len(b.Buddies) > 0 {
+						if _, err := h.OSCARBuddyService.AddBuddies(ctx, oscarInstance, wire.SNACFrame{}, b); err != nil {
+							h.Logger.ErrorContext(ctx, "failed to add OSCAR buddy watch list", "err", err.Error())
+						}
+					}
 				}
 				}
 			}
 			}
 
 
-			// Broadcast buddy arrival to OSCAR clients
-			if h.BuddyBroadcaster != nil {
-				if err := h.BuddyBroadcaster.BroadcastBuddyArrived(ctx, oscarInstance.IdentScreenName(), oscarInstance.Session().TLVUserInfo()); err != nil {
-					h.Logger.ErrorContext(ctx, "failed to broadcast buddy arrival", "err", err.Error())
-				}
+			if err := h.OServiceService.ClientOnline(ctx, wire.BOS, wire.SNAC_0x01_0x02_OServiceClientOnline{}, oscarInstance); err != nil {
+				h.Logger.ErrorContext(ctx, "failed to set client online", "err", err.Error())
+				h.sendError(w, r, http.StatusInternalServerError, "internal server error")
+				return
 			}
 			}
 		}
 		}
 	}
 	}
@@ -242,7 +340,7 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	session, err := h.SessionManager.CreateSession(r.Context(), screenName, apiKey.DevID, events, oscarInstance, h.Logger)
 	session, err := h.SessionManager.CreateSession(r.Context(), screenName, apiKey.DevID, events, oscarInstance, h.Logger)
 	if err != nil {
 	if err != nil {
 		h.Logger.ErrorContext(ctx, "failed to create session", "err", err.Error())
 		h.Logger.ErrorContext(ctx, "failed to create session", "err", err.Error())
-		h.sendError(w, http.StatusInternalServerError, "failed to create session")
+		h.sendError(w, r, http.StatusInternalServerError, "failed to create session")
 		return
 		return
 	}
 	}
 
 
@@ -251,6 +349,11 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 		"events", events,
 		"events", events,
 	)
 	)
 
 
+	// Wire buddy list refresher so feedbag SNACs from the OSCAR bridge trigger a buddylist event.
+	session.BuddyListRefresher = func(ctx context.Context) (interface{}, error) {
+		return h.BuddyListManager.GetBuddyListForUser(ctx, session)
+	}
+
 	// Store client info
 	// Store client info
 	session.ClientName = clientName
 	session.ClientName = clientName
 	session.ClientVersion = clientVersion
 	session.ClientVersion = clientVersion
@@ -264,49 +367,60 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 				myInfoData := map[string]interface{}{
 				myInfoData := map[string]interface{}{
 					"aimId":        screenName.String(),
 					"aimId":        screenName.String(),
 					"displayId":    screenName.String(),
 					"displayId":    screenName.String(),
+					"friendly":     screenName.String(),
 					"state":        "online",
 					"state":        "online",
 					"onlineTime":   time.Now().Unix(),
 					"onlineTime":   time.Now().Unix(),
 					"memberSince":  time.Now().Unix() - 86400*30, // 30 days ago
 					"memberSince":  time.Now().Unix() - 86400*30, // 30 days ago
 					"capabilities": []string{},
 					"capabilities": []string{},
 					"bot":          false,
 					"bot":          false,
-					"service":      "aim",
+					"service":      "AIM",
 				}
 				}
 				session.EventQueue.Push(types.EventType("myInfo"), myInfoData)
 				session.EventQueue.Push(types.EventType("myInfo"), myInfoData)
 				break
 				break
 			}
 			}
 		}
 		}
+		for _, event := range events {
+			if event == "conversation" {
+				session.EventQueue.Push(types.EventTypeConversation,
+					types.ConversationEventData("list", nil))
+				break
+			}
+		}
 	}
 	}
 
 
+	now := time.Now().Unix()
+	scheme := requestScheme(r)
+	baseURL := fmt.Sprintf("%s://%s", scheme, r.Host)
+
 	// Prepare response
 	// Prepare response
 	resp := StartSessionResponse{}
 	resp := StartSessionResponse{}
 	resp.Response.StatusCode = 200
 	resp.Response.StatusCode = 200
 	resp.Response.StatusText = "OK"
 	resp.Response.StatusText = "OK"
 	resp.Response.Data.AimSID = session.AimSID
 	resp.Response.Data.AimSID = session.AimSID
+	resp.Response.Data.Ts = now
 	resp.Response.Data.FetchTimeout = session.FetchTimeout
 	resp.Response.Data.FetchTimeout = session.FetchTimeout
 	resp.Response.Data.TimeToNextFetch = session.TimeToNextFetch
 	resp.Response.Data.TimeToNextFetch = session.TimeToNextFetch
 	// Gromit expects fetchBaseURL directly in data, not in wellKnownUrls
 	// Gromit expects fetchBaseURL directly in data, not in wellKnownUrls
-	resp.Response.Data.FetchBaseURL = fmt.Sprintf("http://%s/aim/fetchEvents?aimsid=%s&seqNum=0", r.Host, session.AimSID)
+	resp.Response.Data.FetchBaseURL = fmt.Sprintf("%s/aim/fetchEvents?aimsid=%s&seqNum=0", baseURL, session.AimSID)
 
 
-	// Add wellKnownUrls for other clients that might use it
+	// Add wellKnownUrls for other clients that might use it.
 	resp.Response.Data.WellKnownUrls = map[string]string{
 	resp.Response.Data.WellKnownUrls = map[string]string{
-		"webApiBase":   fmt.Sprintf("http://%s/", r.Host),
-		"fetchBaseURL": fmt.Sprintf("http://%s/aim/fetchEvents", r.Host),
+		"webApiBase":        baseURL + "/",
+		"fetchBaseURL":      baseURL + "/aim/fetchEvents",
+		"lifestreamApiBase": baseURL + "/",
 	}
 	}
 
 
-	// Add myInfo data if authenticated
 	if authToken != "" {
 	if authToken != "" {
-		if resp.Response.Data.Events == nil {
-			resp.Response.Data.Events = make(map[string]interface{})
-		}
-		resp.Response.Data.Events["myInfo"] = map[string]interface{}{
+		myInfoPayload := map[string]interface{}{
 			"aimId":        screenName.String(),
 			"aimId":        screenName.String(),
 			"displayId":    screenName.String(),
 			"displayId":    screenName.String(),
+			"friendly":     screenName.String(),
 			"state":        "online",
 			"state":        "online",
 			"onlineTime":   time.Now().Unix(),
 			"onlineTime":   time.Now().Unix(),
 			"memberSince":  time.Now().Unix() - 86400*30, // 30 days ago
 			"memberSince":  time.Now().Unix() - 86400*30, // 30 days ago
 			"capabilities": []string{},
 			"capabilities": []string{},
 			"bot":          false,
 			"bot":          false,
-			"service":      "aim",
+			"service":      "AIM",
 			"self": map[string]interface{}{
 			"self": map[string]interface{}{
 				"instNum":        1,
 				"instNum":        1,
 				"loginTime":      time.Now().Unix(),
 				"loginTime":      time.Now().Unix(),
@@ -327,38 +441,50 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 				},
 				},
 			},
 			},
 		}
 		}
+		resp.Response.Data.MyInfo = myInfoPayload
+		if resp.Response.Data.Events == nil {
+			resp.Response.Data.Events = make(map[string]interface{})
+		}
+		resp.Response.Data.Events["myInfo"] = myInfoPayload
 	}
 	}
 
 
-	// If buddy list event is subscribed, include initial buddy list
 	for _, event := range events {
 	for _, event := range events {
-		if event == "buddylist" {
+		switch types.EventType(event) {
+		case types.EventTypeBuddyList:
+			buddyGroups := []WebAPIBuddyGroup{}
 			if authToken != "" && h.BuddyListManager != nil {
 			if authToken != "" && h.BuddyListManager != nil {
-				// Fetch actual buddy list from service
-				buddyGroups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session.ScreenName.IdentScreenName())
+				var err error
+				buddyGroups, err = h.BuddyListManager.GetBuddyListForUser(ctx, session)
 				if err != nil {
 				if err != nil {
 					h.Logger.ErrorContext(ctx, "failed to get buddy list", "err", err.Error())
 					h.Logger.ErrorContext(ctx, "failed to get buddy list", "err", err.Error())
-					// Continue with empty buddy list
 					buddyGroups = []WebAPIBuddyGroup{}
 					buddyGroups = []WebAPIBuddyGroup{}
 				}
 				}
-
-				// Convert to handler format and include in response
-				if resp.Response.Data.Events == nil {
-					resp.Response.Data.Events = make(map[string]interface{})
-				}
-				resp.Response.Data.Events["buddylist"] = map[string]interface{}{
-					"groups": buddyGroups,
-				}
-
-			} else {
-				// No auth token, return empty buddy list
-				if resp.Response.Data.Events == nil {
-					resp.Response.Data.Events = make(map[string]interface{})
-				}
-				resp.Response.Data.Events["buddylist"] = map[string]interface{}{
-					"groups": []WebAPIBuddyGroup{},
-				}
 			}
 			}
-			break
+			if buddyGroups == nil {
+				buddyGroups = []WebAPIBuddyGroup{}
+			}
+			blPayload := map[string]interface{}{"groups": buddyGroups}
+			if resp.Response.Data.Events == nil {
+				resp.Response.Data.Events = make(map[string]interface{})
+			}
+			resp.Response.Data.Events["buddylist"] = blPayload
+			if authToken != "" {
+				session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
+			}
+		case types.EventTypePreference:
+			prefPayload := map[string]interface{}{
+				"showGroups":     true,
+				"showOfflineGrp": true,
+				"sortBuddyList":  false,
+				"globalOTR":      false,
+			}
+			if resp.Response.Data.Events == nil {
+				resp.Response.Data.Events = make(map[string]interface{})
+			}
+			resp.Response.Data.Events["preference"] = prefPayload
+			if authToken != "" {
+				session.EventQueue.Push(types.EventTypePreference, prefPayload)
+			}
 		}
 		}
 	}
 	}
 
 
@@ -378,15 +504,18 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 		xmlResp.Data.FetchTimeout = timeout
 		xmlResp.Data.FetchTimeout = timeout
 		xmlResp.Data.TimeToNextFetch = 500
 		xmlResp.Data.TimeToNextFetch = 500
 		// Gromit expects fetchBaseURL directly in data
 		// Gromit expects fetchBaseURL directly in data
-		xmlResp.Data.FetchBaseURL = fmt.Sprintf("http://%s/aim/fetchEvents?aimsid=%s&seqNum=0", r.Host, session.AimSID)
+		xmlResp.Data.FetchBaseURL = fmt.Sprintf("%s/aim/fetchEvents?aimsid=%s&seqNum=0", baseURL, session.AimSID)
 
 
 		// Add wellKnownUrls for other clients
 		// Add wellKnownUrls for other clients
+		xmlBase := baseURL + "/"
 		xmlResp.Data.WellKnownUrls = &struct {
 		xmlResp.Data.WellKnownUrls = &struct {
-			WebApiBase   string `xml:"webApiBase"`
-			FetchBaseURL string `xml:"fetchBaseURL"`
+			WebApiBase        string `xml:"webApiBase"`
+			FetchBaseURL      string `xml:"fetchBaseURL"`
+			LifestreamApiBase string `xml:"lifestreamApiBase"`
 		}{
 		}{
-			WebApiBase:   fmt.Sprintf("http://%s/", r.Host),
-			FetchBaseURL: fmt.Sprintf("http://%s/aim/fetchEvents", r.Host),
+			WebApiBase:        xmlBase,
+			FetchBaseURL:      baseURL + "/aim/fetchEvents",
+			LifestreamApiBase: xmlBase,
 		}
 		}
 
 
 		// Add myInfo with user data
 		// Add myInfo with user data
@@ -408,7 +537,7 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 
 
 				if authToken != "" && h.BuddyListManager != nil {
 				if authToken != "" && h.BuddyListManager != nil {
 					// Fetch actual buddy list from service
 					// Fetch actual buddy list from service
-					webAPIGroups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session.ScreenName.IdentScreenName())
+					webAPIGroups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
 					if err != nil {
 					if err != nil {
 						h.Logger.ErrorContext(ctx, "failed to get buddy list for XML response", "err", err.Error())
 						h.Logger.ErrorContext(ctx, "failed to get buddy list for XML response", "err", err.Error())
 						buddyGroups = []BuddyGroup{}
 						buddyGroups = []BuddyGroup{}
@@ -461,7 +590,7 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 		xmlData, err := xml.Marshal(xmlResp)
 		xmlData, err := xml.Marshal(xmlResp)
 		if err != nil {
 		if err != nil {
 			h.Logger.Error("failed to marshal XML response", "error", err)
 			h.Logger.Error("failed to marshal XML response", "error", err)
-			h.sendError(w, http.StatusInternalServerError, "internal server error")
+			h.sendError(w, r, http.StatusInternalServerError, "internal server error")
 			return
 			return
 		}
 		}
 
 
@@ -490,7 +619,7 @@ func (h *SessionHandler) EndSession(w http.ResponseWriter, r *http.Request) {
 	// Get session ID from parameters
 	// Get session ID from parameters
 	aimsid := r.URL.Query().Get("aimsid")
 	aimsid := r.URL.Query().Get("aimsid")
 	if aimsid == "" {
 	if aimsid == "" {
-		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		h.sendError(w, r, http.StatusBadRequest, "missing aimsid parameter")
 		return
 		return
 	}
 	}
 
 
@@ -499,11 +628,11 @@ func (h *SessionHandler) EndSession(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 	if err != nil {
 		switch err {
 		switch err {
 		case state.ErrNoWebAPISession:
 		case state.ErrNoWebAPISession:
-			h.sendError(w, http.StatusNotFound, "session not found")
+			h.sendError(w, r, http.StatusNotFound, "session not found")
 		case state.ErrWebAPISessionExpired:
 		case state.ErrWebAPISessionExpired:
-			h.sendError(w, http.StatusGone, "session expired")
+			h.sendError(w, r, http.StatusGone, "session expired")
 		default:
 		default:
-			h.sendError(w, http.StatusInternalServerError, "internal server error")
+			h.sendError(w, r, http.StatusInternalServerError, "internal server error")
 		}
 		}
 		return
 		return
 	}
 	}
@@ -532,7 +661,7 @@ func (h *SessionHandler) EndSession(w http.ResponseWriter, r *http.Request) {
 	// Remove session
 	// Remove session
 	if err := h.SessionManager.RemoveSession(r.Context(), aimsid); err != nil {
 	if err := h.SessionManager.RemoveSession(r.Context(), aimsid); err != nil {
 		h.Logger.ErrorContext(ctx, "failed to remove session", "err", err.Error())
 		h.Logger.ErrorContext(ctx, "failed to remove session", "err", err.Error())
-		h.sendError(w, http.StatusInternalServerError, "failed to end session")
+		h.sendError(w, r, http.StatusInternalServerError, "failed to end session")
 		return
 		return
 	}
 	}
 
 
@@ -550,7 +679,30 @@ func (h *SessionHandler) EndSession(w http.ResponseWriter, r *http.Request) {
 	)
 	)
 }
 }
 
 
-// sendError is a convenience method that wraps the common SendError function.
-func (h *SessionHandler) sendError(w http.ResponseWriter, statusCode int, message string) {
-	SendError(w, statusCode, message)
+// sendError sends a Web AIM API error envelope, honoring JSONP when requested.
+func (h *SessionHandler) sendError(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
+	resp := BaseResponse{}
+	resp.Response.StatusCode = statusCode
+	resp.Response.StatusText = message
+	SendResponse(w, r, resp, h.Logger)
+}
+
+func requestScheme(r *http.Request) string {
+	if r.TLS != nil {
+		return "https"
+	}
+	if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
+		return proto
+	}
+	return "http"
+}
+
+func shuttingDown(ctx context.Context) bool {
+	select {
+	case <-ctx.Done():
+		// server is shutting down, don't send buddy notifications
+		return true
+	default:
+	}
+	return false
 }
 }

+ 40 - 0
server/webapi/handlers/user_info_stub.go

@@ -0,0 +1,40 @@
+package handlers
+
+import (
+	"log/slog"
+	"net/http"
+)
+
+type UserInfoStubHandler struct {
+	Logger *slog.Logger
+}
+
+func (h *UserInfoStubHandler) GetLocationsFollowing(w http.ResponseWriter, r *http.Request) {
+	h.emptyOK(w, r)
+}
+
+func (h *UserInfoStubHandler) GetUserDetails(w http.ResponseWriter, r *http.Request) {
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]interface{}{
+		"userDetails": map[string]interface{}{
+			"services": []map[string]interface{}{
+				{"service": "aim"},
+			},
+		},
+	}
+	SendResponse(w, r, resp, h.Logger)
+}
+
+func (h *UserInfoStubHandler) EmptyOK(w http.ResponseWriter, r *http.Request) {
+	h.emptyOK(w, r)
+}
+
+func (h *UserInfoStubHandler) emptyOK(w http.ResponseWriter, r *http.Request) {
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]interface{}{}
+	SendResponse(w, r, resp, h.Logger)
+}

+ 15 - 4
server/webapi/handlers/webapi_event_converter.go

@@ -16,14 +16,21 @@ func ConvertEventForAMF3(event types.Event) map[string]interface{} {
 	case types.EventTypeIM:
 	case types.EventTypeIM:
 		if imEvent, ok := event.Data.(types.IMEvent); ok {
 		if imEvent, ok := event.Data.(types.IMEvent); ok {
 			// Gromit expects 'source' as a user object and 'autoresponse' (lowercase)
 			// Gromit expects 'source' as a user object and 'autoresponse' (lowercase)
-			result["eventData"] = map[string]interface{}{
+			eventData := map[string]interface{}{
 				"source": map[string]interface{}{
 				"source": map[string]interface{}{
-					"aimId": imEvent.From,
+					"aimId":     imEvent.Source.AimID,
+					"displayId": imEvent.Source.DisplayID,
+					"userType":  imEvent.Source.UserType,
+					"state":     imEvent.Source.State,
 				},
 				},
 				"message":      imEvent.Message,
 				"message":      imEvent.Message,
 				"timestamp":    imEvent.Timestamp, // Already float64
 				"timestamp":    imEvent.Timestamp, // Already float64
 				"autoresponse": imEvent.AutoResp,
 				"autoresponse": imEvent.AutoResp,
 			}
 			}
+			if imEvent.MsgID != "" {
+				eventData["msgId"] = imEvent.MsgID
+			}
+			result["eventData"] = eventData
 		} else if dataMap, ok := event.Data.(map[string]interface{}); ok {
 		} else if dataMap, ok := event.Data.(map[string]interface{}); ok {
 			// Already a map, ensure timestamps are float64
 			// Already a map, ensure timestamps are float64
 			if ts, exists := dataMap["timestamp"]; exists {
 			if ts, exists := dataMap["timestamp"]; exists {
@@ -39,7 +46,7 @@ func ConvertEventForAMF3(event types.Event) map[string]interface{} {
 	case types.EventTypeOfflineIM:
 	case types.EventTypeOfflineIM:
 		if imEvent, ok := event.Data.(types.IMEvent); ok {
 		if imEvent, ok := event.Data.(types.IMEvent); ok {
 			result["eventData"] = map[string]interface{}{
 			result["eventData"] = map[string]interface{}{
-				"aimId":     imEvent.From,
+				"aimId":     imEvent.Source.AimID,
 				"message":   imEvent.Message,
 				"message":   imEvent.Message,
 				"timestamp": float64(imEvent.Timestamp), // Convert to float64
 				"timestamp": float64(imEvent.Timestamp), // Convert to float64
 			}
 			}
@@ -115,7 +122,7 @@ func ConvertEventForAMF3(event types.Event) map[string]interface{} {
 		if sentIMEvent, ok := event.Data.(types.SentIMEvent); ok {
 		if sentIMEvent, ok := event.Data.(types.SentIMEvent); ok {
 			// Gromit expects both 'source' (sender) and 'dest' (recipient) for sentIM
 			// Gromit expects both 'source' (sender) and 'dest' (recipient) for sentIM
 			// The parseIM function needs source even for outgoing messages
 			// The parseIM function needs source even for outgoing messages
-			result["eventData"] = map[string]interface{}{
+			eventData := map[string]interface{}{
 				"source": map[string]interface{}{
 				"source": map[string]interface{}{
 					"aimId":     sentIMEvent.Sender.AimID,
 					"aimId":     sentIMEvent.Sender.AimID,
 					"displayId": sentIMEvent.Sender.DisplayID,
 					"displayId": sentIMEvent.Sender.DisplayID,
@@ -132,6 +139,10 @@ func ConvertEventForAMF3(event types.Event) map[string]interface{} {
 				"timestamp":    sentIMEvent.Timestamp, // Already float64
 				"timestamp":    sentIMEvent.Timestamp, // Already float64
 				"autoresponse": sentIMEvent.AutoResp,
 				"autoresponse": sentIMEvent.AutoResp,
 			}
 			}
+			if sentIMEvent.MsgID != "" {
+				eventData["msgId"] = sentIMEvent.MsgID
+			}
+			result["eventData"] = eventData
 		} else {
 		} else {
 			result["eventData"] = event.Data
 			result["eventData"] = event.Data
 		}
 		}

+ 86 - 51
server/webapi/middleware/auth.go

@@ -149,28 +149,16 @@ func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler {
 		}
 		}
 
 
 		if apiKey == "" {
 		if apiKey == "" {
-			m.sendErrorResponse(w, http.StatusBadRequest, "required parameter 'k' is missing")
+			m.sendErrorResponse(w, r, http.StatusBadRequest, "required parameter 'k' is missing")
 			return
 			return
 		}
 		}
 
 
 		// Validate API key
 		// Validate API key
 		ctx := r.Context()
 		ctx := r.Context()
-		key, err := m.Validator.GetAPIKeyByDevKey(ctx, apiKey)
-		if err != nil {
-			if err == state.ErrNoAPIKey {
-				m.Logger.DebugContext(ctx, "invalid API key attempted", "key", apiKey[:min(8, len(apiKey))]+"...")
-				m.sendErrorResponse(w, http.StatusForbidden, "invalid API key")
-				return
-			}
-			m.Logger.ErrorContext(ctx, "error validating API key", "err", err.Error())
-			m.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
-			return
-		}
-
-		// Check if key is active
-		if !key.IsActive {
-			m.Logger.DebugContext(ctx, "inactive API key used", "dev_id", key.DevID)
-			m.sendErrorResponse(w, http.StatusForbidden, "API key is inactive")
+		key := m.resolveAPIKey(ctx, apiKey)
+		if key == nil {
+			m.Logger.DebugContext(ctx, "invalid API key attempted", "key", apiKey[:min(8, len(apiKey))]+"...")
+			m.sendErrorResponse(w, r, http.StatusForbidden, "invalid API key")
 			return
 			return
 		}
 		}
 
 
@@ -190,7 +178,7 @@ func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler {
 				retryAfter = 1
 				retryAfter = 1
 			}
 			}
 			w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
 			w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
-			m.sendErrorResponse(w, http.StatusTooManyRequests, "rate limit exceeded")
+			m.sendErrorResponse(w, r, http.StatusTooManyRequests, "rate limit exceeded")
 			return
 			return
 		}
 		}
 
 
@@ -299,7 +287,7 @@ func (m *AuthMiddleware) CapabilitiesMiddleware(requiredCapability string) func(
 					"required", requiredCapability,
 					"required", requiredCapability,
 					"available", key.Capabilities,
 					"available", key.Capabilities,
 				)
 				)
-				m.sendErrorResponse(w, http.StatusForbidden, fmt.Sprintf("missing required capability: %s", requiredCapability))
+				m.sendErrorResponse(w, r, http.StatusForbidden, fmt.Sprintf("missing required capability: %s", requiredCapability))
 				return
 				return
 			}
 			}
 
 
@@ -342,19 +330,56 @@ func (m *AuthMiddleware) isOriginAllowed(origin string, allowedOrigins []string)
 	return false
 	return false
 }
 }
 
 
-// sendErrorResponse sends a JSON error response.
-func (m *AuthMiddleware) sendErrorResponse(w http.ResponseWriter, statusCode int, message string) {
-	w.Header().Set("Content-Type", "application/json")
-	w.WriteHeader(statusCode)
-
+// sendErrorResponse sends a Web AIM API error envelope, with JSONP support when requested.
+func (m *AuthMiddleware) sendErrorResponse(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
 	response := map[string]interface{}{
 	response := map[string]interface{}{
-		"error": message,
-		"code":  statusCode,
+		"response": map[string]interface{}{
+			"statusCode": statusCode,
+			"statusText": message,
+		},
 	}
 	}
 
 
-	if err := json.NewEncoder(w).Encode(response); err != nil {
+	body, err := json.Marshal(response)
+	if err != nil {
 		m.Logger.Error("failed to encode error response", "err", err.Error())
 		m.Logger.Error("failed to encode error response", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	callback := jsonpCallback(r)
+	if callback != "" && isValidJSONPCallback(callback) {
+		w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
+		_, _ = w.Write([]byte(callback))
+		_, _ = w.Write([]byte("("))
+		_, _ = w.Write(body)
+		_, _ = w.Write([]byte(");"))
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	_, _ = w.Write(body)
+}
+
+func jsonpCallback(r *http.Request) string {
+	if callback := r.URL.Query().Get("c"); callback != "" {
+		return callback
 	}
 	}
+	return r.URL.Query().Get("callback")
+}
+
+func isValidJSONPCallback(callback string) bool {
+	if len(callback) == 0 || len(callback) > 100 {
+		return false
+	}
+	for _, r := range callback {
+		if (r < 'a' || r > 'z') &&
+			(r < 'A' || r > 'Z') &&
+			(r < '0' || r > '9') &&
+			r != '_' && r != '$' && r != '.' {
+			return false
+		}
+	}
+	return true
 }
 }
 
 
 // GetAPIKeyFromContext retrieves the API key from the request context.
 // GetAPIKeyFromContext retrieves the API key from the request context.
@@ -397,12 +422,24 @@ func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
 			return
 			return
 		}
 		}
 
 
-		// Priority 2: Check for AOL token auth
+		// Priority 2: AOL token auth — user identity is in the token; k is optional.
 		if token := r.URL.Query().Get("a"); token != "" {
 		if token := r.URL.Query().Get("a"); token != "" {
-			// Token auth is present, but we still need to validate the API key
-			// The token provides user authentication while the API key identifies the app
-			m.Logger.DebugContext(ctx, "token authentication detected, will validate API key as well")
-			// Don't return here - continue to API key validation below
+			key := m.resolveAPIKey(ctx, r.URL.Query().Get("k"))
+			if key == nil {
+				devKey := r.URL.Query().Get("k")
+				key = &state.WebAPIKey{
+					DevID:     "aim_web",
+					DevKey:    devKey,
+					AppName:   "AIM Web Client",
+					IsActive:  true,
+					RateLimit: 600,
+				}
+			}
+			ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
+			ctx = context.WithValue(ctx, ContextKeyDevID, key.DevID)
+			m.Logger.DebugContext(ctx, "using token authentication", "dev_id", key.DevID)
+			next.ServeHTTP(w, r.WithContext(ctx))
+			return
 		}
 		}
 
 
 		// Priority 3: Check for signed request auth
 		// Priority 3: Check for signed request auth
@@ -424,27 +461,14 @@ func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
 		}
 		}
 
 
 		if apiKey == "" {
 		if apiKey == "" {
-			m.sendErrorResponse(w, http.StatusBadRequest, "authentication required: provide aimsid or k parameter")
+			m.sendErrorResponse(w, r, http.StatusBadRequest, "authentication required: provide aimsid or k parameter")
 			return
 			return
 		}
 		}
 
 
-		// Validate API key as before
-		key, err := m.Validator.GetAPIKeyByDevKey(ctx, apiKey)
-		if err != nil {
-			if err == state.ErrNoAPIKey {
-				m.Logger.DebugContext(ctx, "invalid API key attempted", "key", apiKey[:min(8, len(apiKey))]+"...")
-				m.sendErrorResponse(w, http.StatusForbidden, "invalid API key")
-				return
-			}
-			m.Logger.ErrorContext(ctx, "error validating API key", "err", err.Error())
-			m.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
-			return
-		}
-
-		// Check if key is active
-		if !key.IsActive {
-			m.Logger.DebugContext(ctx, "inactive API key used", "dev_id", key.DevID)
-			m.sendErrorResponse(w, http.StatusForbidden, "API key is inactive")
+		key := m.resolveAPIKey(ctx, apiKey)
+		if key == nil {
+			m.Logger.DebugContext(ctx, "invalid API key attempted", "key", apiKey[:min(8, len(apiKey))]+"...")
+			m.sendErrorResponse(w, r, http.StatusForbidden, "invalid API key")
 			return
 			return
 		}
 		}
 
 
@@ -464,7 +488,7 @@ func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
 				retryAfter = 1
 				retryAfter = 1
 			}
 			}
 			w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
 			w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
-			m.sendErrorResponse(w, http.StatusTooManyRequests, "rate limit exceeded")
+			m.sendErrorResponse(w, r, http.StatusTooManyRequests, "rate limit exceeded")
 			return
 			return
 		}
 		}
 
 
@@ -491,3 +515,14 @@ func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
 		next.ServeHTTP(w, r.WithContext(ctx))
 		next.ServeHTTP(w, r.WithContext(ctx))
 	})
 	})
 }
 }
+
+func (m *AuthMiddleware) resolveAPIKey(ctx context.Context, devKey string) *state.WebAPIKey {
+	if devKey == "" {
+		return nil
+	}
+	key, err := m.Validator.GetAPIKeyByDevKey(ctx, devKey)
+	if err != nil || key == nil || !key.IsActive {
+		return nil
+	}
+	return key
+}

+ 18 - 0
server/webapi/middleware/logging.go

@@ -0,0 +1,18 @@
+package middleware
+
+import (
+	"log/slog"
+	"net/http"
+)
+
+// RequestLogger logs each request with method, path, and raw query string.
+func RequestLogger(logger *slog.Logger, next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		logger.Info("request",
+			"method", r.Method,
+			"path", r.URL.Path,
+			"query", r.URL.RawQuery,
+		)
+		next.ServeHTTP(w, r)
+	})
+}

+ 105 - 19
server/webapi/server.go

@@ -22,10 +22,10 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 
 
 	// Create handlers
 	// Create handlers
 	authHandler := &handlers.AuthHandler{
 	authHandler := &handlers.AuthHandler{
-		UserManager: handler.UserManager,
-		TokenStore:  handler.TokenStore,
+		AuthService: handler.AuthService,
+		CookieBaker: handler.CookieBaker,
+		UserManager: handler.TOCConfigStore,
 		Logger:      logger,
 		Logger:      logger,
-		DisableAuth: handler.OSCARConfig.IsAuthDisabled(),
 	}
 	}
 
 
 	sessionHandler := &handlers.SessionHandler{
 	sessionHandler := &handlers.SessionHandler{
@@ -35,9 +35,14 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		BuddyListService:    nil,
 		BuddyListService:    nil,
 		BuddyListRegistry:   handler.BuddyListRegistry,
 		BuddyListRegistry:   handler.BuddyListRegistry,
 		BuddyBroadcaster:    handler.BuddyBroadcaster,
 		BuddyBroadcaster:    handler.BuddyBroadcaster,
+		FeedbagRetriever:    handler.FeedbagRetriever,
+		OSCARBuddyService:   handler.BuddyService,
 		BuddyListManager:    handler.BuddyListManager.(*handlers.BuddyListManager),
 		BuddyListManager:    handler.BuddyListManager.(*handlers.BuddyListManager),
-		TokenStore:          handler.TokenStore,
 		Logger:              logger,
 		Logger:              logger,
+		OServiceService:     handler.OServiceService,
+		RecalcWarning:       handler.RecalcWarning,
+		LowerWarnLevel:      handler.LowerWarnLevel,
+		ChatSessionManager:  handler.ChatSessionManager,
 	}
 	}
 
 
 	eventsHandler := &handlers.EventsHandler{
 	eventsHandler := &handlers.EventsHandler{
@@ -55,11 +60,12 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		Logger:              logger,
 		Logger:              logger,
 	}
 	}
 
 
-	buddyListHandler := &handlers.BuddyListHandler{
-		SessionManager: sessionManager,
-		FeedbagManager: handler.FeedbagManager,
-		Logger:         logger,
-	}
+	buddyListHandler := handlers.NewBuddyListHandler(
+		sessionManager,
+		handler.BuddyListManager.(*handlers.BuddyListManager),
+		logger,
+		handler.FeedbagService,
+	)
 
 
 	// Phase 2: Messaging handler
 	// Phase 2: Messaging handler
 	messagingHandler := &handlers.MessagingHandler{
 	messagingHandler := &handlers.MessagingHandler{
@@ -99,8 +105,9 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 	for _, l := range listeners {
 	for _, l := range listeners {
 		mux := http.NewServeMux()
 		mux := http.NewServeMux()
 
 
-		// Public endpoint (no auth required for hello world)
-		mux.HandleFunc("GET /", handler.GetHelloWorldHandler)
+		// Exact root only. Pattern "GET /" matches every GET path in Go 1.22+ (prefix /), which
+		// would steal /getAggregated and other lifestream URLs before stubs/404.
+		mux.HandleFunc("GET /{$}", handler.GetHelloWorldHandler)
 
 
 		// Authentication endpoint (public - no API key required for user login)
 		// Authentication endpoint (public - no API key required for user login)
 		// Using pattern with explicit method for Go 1.22+ routing
 		// Using pattern with explicit method for Go 1.22+ routing
@@ -121,6 +128,23 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 			w.WriteHeader(http.StatusNoContent)
 			w.WriteHeader(http.StatusNoContent)
 		})
 		})
 
 
+		mux.HandleFunc("GET /auth/getToken", func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Access-Control-Allow-Origin", "*")
+			w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
+			w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+			authHandler.GetToken(w, r)
+		})
+
+		mux.HandleFunc("OPTIONS /auth/getToken", func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Access-Control-Allow-Origin", "*")
+			w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
+			w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+			w.WriteHeader(http.StatusNoContent)
+		})
+
+		mux.HandleFunc("GET /_cqr/login/login.psp", authHandler.LoginPSP)
+		mux.HandleFunc("POST /_cqr/login/login.psp", authHandler.LoginPSP)
+
 		// Authenticated Web AIM API endpoints
 		// Authenticated Web AIM API endpoints
 		// SessionInstance management - supports multiple auth methods (k, a, ts+sig_sha256)
 		// SessionInstance management - supports multiple auth methods (k, a, ts+sig_sha256)
 		mux.Handle("GET /aim/startSession", authMiddleware.AuthenticateFlexible(
 		mux.Handle("GET /aim/startSession", authMiddleware.AuthenticateFlexible(
@@ -140,7 +164,28 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		// Add temp buddy - uses aimsid for auth
 		// Add temp buddy - uses aimsid for auth
 		mux.Handle("GET /aim/addTempBuddy", authMiddleware.AuthenticateFlexible(
 		mux.Handle("GET /aim/addTempBuddy", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
 			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(buddyListHandler.AddTempBuddy))))
+				buddyListHandler.SessionMiddleware(buddyListHandler.AddTempBuddy))))
+
+		mux.Handle("GET /aim/removeTempBuddy", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				buddyListHandler.SessionMiddleware(buddyListHandler.RemoveTempBuddy))))
+
+		aimStub := &handlers.AimStubHandler{Logger: logger}
+		aimRoute := func(h http.HandlerFunc) http.Handler {
+			return authMiddleware.AuthenticateFlexible(
+				authMiddleware.CORSMiddleware(http.HandlerFunc(h)))
+		}
+		mux.Handle("GET /aim/setForwardDomain", aimRoute(aimStub.SetForwardDomain))
+		mux.Handle("GET /aim/getData", aimRoute(aimStub.GetData))
+
+		conversationStub := &handlers.ConversationStubHandler{
+			SessionManager: sessionManager,
+			Logger:         logger,
+		}
+		mux.Handle("GET /conversation/update", aimRoute(conversationStub.Update))
+		mux.Handle("GET /conversation/close", aimRoute(conversationStub.Close))
+		mux.Handle("GET /imlog/markRead", aimRoute(conversationStub.MarkRead))
+		mux.Handle("GET /imlog/fetchStoredIMs", aimRoute(conversationStub.FetchStoredIMs))
 
 
 		// Presence and buddy list
 		// Presence and buddy list
 		// GetPresence supports aimsid-based auth, so we use flexible auth
 		// GetPresence supports aimsid-based auth, so we use flexible auth
@@ -148,15 +193,17 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 			authMiddleware.CORSMiddleware(
 			authMiddleware.CORSMiddleware(
 				http.HandlerFunc(presenceHandler.GetPresence))))
 				http.HandlerFunc(presenceHandler.GetPresence))))
 
 
-		mux.Handle("GET /buddylist/addBuddy", authMiddleware.Authenticate(
-			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(buddyListHandler.AddBuddy))))
+		mux.Handle("/buddylist/", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(buddyListHandler)))
 
 
 		// Phase 2: Messaging endpoints
 		// Phase 2: Messaging endpoints
-		// sendIM supports aimsid-based auth, so we use flexible auth
-		mux.Handle("GET /im/sendIM", authMiddleware.AuthenticateFlexible(
+		// sendIM supports aimsid-based auth, so we use flexible auth.
+		// The Web AIM client POSTs the message body (non-IE browsers); IE uses GET.
+		sendIMHandler := authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
 			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(messagingHandler.SendIM))))
+				http.HandlerFunc(messagingHandler.SendIM)))
+		mux.Handle("GET /im/sendIM", sendIMHandler)
+		mux.Handle("POST /im/sendIM", sendIMHandler)
 
 
 		mux.Handle("GET /im/setTyping", authMiddleware.Authenticate(
 		mux.Handle("GET /im/setTyping", authMiddleware.Authenticate(
 			authMiddleware.CORSMiddleware(
 			authMiddleware.CORSMiddleware(
@@ -214,6 +261,39 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 			authMiddleware.CORSMiddleware(
 			authMiddleware.CORSMiddleware(
 				http.HandlerFunc(expressionsHandler.Get))))
 				http.HandlerFunc(expressionsHandler.Get))))
 
 
+		// Web AIM calls lifestream/* on the API host (e.g. /lifestream/getUserDetails).
+		lifestreamStub := &handlers.UserInfoStubHandler{Logger: logger}
+		lifestreamRoute := func(h http.HandlerFunc) http.Handler {
+			return authMiddleware.AuthenticateFlexible(
+				authMiddleware.CORSMiddleware(http.HandlerFunc(h)))
+		}
+		mux.Handle("GET /lifestream/getUserDetails", lifestreamRoute(lifestreamStub.GetUserDetails))
+		mux.Handle("GET /lifestream/getLocationsFollowing", lifestreamRoute(lifestreamStub.GetLocationsFollowing))
+		for _, p := range []string{
+			"getAggregated",
+			"getNotifications",
+			"getSingle",
+			"getNotificationFilter",
+			"heyGetNotifications",
+			"heyMarkNotifications",
+			"deleteNotification",
+			"commonsFollow",
+			"commonsUnfollow",
+			"tdAddService",
+			"tdRemoveService",
+			"setUserPreference",
+			"getActivity",
+			"addComment",
+			"deleteComment",
+			"deleteActivity",
+			"addLike",
+			"deleteLike",
+			"setNotificationFilter",
+			"heyTakeAction",
+		} {
+			mux.Handle("GET /lifestream/"+p, lifestreamRoute(lifestreamStub.EmptyOK))
+		}
+
 		// Phase 5: Chat room endpoints
 		// Phase 5: Chat room endpoints
 		// All chat endpoints use aimsid for authentication
 		// All chat endpoints use aimsid for authentication
 		mux.Handle("GET /chat/createAndJoinChat", authMiddleware.AuthenticateFlexible(
 		mux.Handle("GET /chat/createAndJoinChat", authMiddleware.AuthenticateFlexible(
@@ -232,9 +312,15 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 			authMiddleware.CORSMiddleware(
 			authMiddleware.CORSMiddleware(
 				http.HandlerFunc(chatHandler.LeaveChat))))
 				http.HandlerFunc(chatHandler.LeaveChat))))
 
 
+		// Unmatched paths (pattern "/" matches anything not covered by routes above).
+		mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+			logger.Debug("webapi 404", "method", r.Method, "path", r.URL.Path)
+			handlers.SendError(w, http.StatusNotFound, "not found")
+		})
+
 		servers = append(servers, &http.Server{
 		servers = append(servers, &http.Server{
 			Addr:    l,
 			Addr:    l,
-			Handler: mux,
+			Handler: middleware.RequestLogger(logger, mux),
 		})
 		})
 	}
 	}
 
 

+ 16 - 21
server/webapi/types.go

@@ -2,7 +2,6 @@ package webapi
 
 
 import (
 import (
 	"context"
 	"context"
-	"time"
 
 
 	"github.com/google/uuid"
 	"github.com/google/uuid"
 	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/config"
@@ -126,6 +125,18 @@ type FeedbagManager interface {
 	DeleteItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error
 	DeleteItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error
 }
 }
 
 
+type FeedbagService interface {
+	DeleteItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (*wire.SNACMessage, error)
+	Query(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error)
+	QueryIfModified(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error)
+	RespondAuthorizeToHost(ctx context.Context, instance state.IdentScreenName, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error
+	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+	StartCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster)
+	EndCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) error
+	UpsertItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) (*wire.SNACMessage, error)
+	Use(ctx context.Context, instance *state.SessionInstance) error
+}
+
 // Phase 2: Additional interfaces for messaging and presence
 // Phase 2: Additional interfaces for messaging and presence
 
 
 // MessageRelayer relays messages between users
 // MessageRelayer relays messages between users
@@ -152,26 +163,6 @@ type ProfileManager interface {
 	Profile(ctx context.Context, screenName state.IdentScreenName) (state.UserProfile, error)
 	Profile(ctx context.Context, screenName state.IdentScreenName) (state.UserProfile, error)
 }
 }
 
 
-// UserManager defines methods for user authentication.
-type UserManager interface {
-	// AuthenticateUser verifies username and password
-	AuthenticateUser(ctx context.Context, username, password string) (*state.User, error)
-	// FindUserByScreenName finds a user by their screen name
-	FindUserByScreenName(ctx context.Context, screenName state.IdentScreenName) (*state.User, error)
-	// InsertUser creates a new user (for DISABLE_AUTH mode)
-	InsertUser(ctx context.Context, u state.User) error
-}
-
-// TokenStore manages authentication tokens.
-type TokenStore interface {
-	// StoreToken saves an authentication token for a user
-	StoreToken(ctx context.Context, token string, screenName state.IdentScreenName, expiresAt time.Time) error
-	// ValidateToken checks if a token is valid and returns the associated screen name
-	ValidateToken(ctx context.Context, token string) (state.IdentScreenName, error)
-	// DeleteToken removes a token
-	DeleteToken(ctx context.Context, token string) error
-}
-
 // Phase 3: Preference interfaces
 // Phase 3: Preference interfaces
 
 
 // PreferenceManager provides methods to manage user preferences.
 // PreferenceManager provides methods to manage user preferences.
@@ -209,3 +200,7 @@ type OSCARConfig interface {
 	IsSSLAvailable() bool
 	IsSSLAvailable() bool
 	IsAuthDisabled() bool
 	IsAuthDisabled() bool
 }
 }
+
+type ChatSessionManager interface {
+	RemoveUserFromAllChats(user state.IdentScreenName)
+}

+ 37 - 0
server/webapi/types/conversation.go

@@ -0,0 +1,37 @@
+package types
+
+import "time"
+
+// EventTypeConversation is the fetchEvents type for chat list sync.
+const EventTypeConversation EventType = "conversation"
+
+// ConversationEventData builds a conversation fetchEvents payload.
+func ConversationEventData(operation string, conversations []map[string]interface{}) map[string]interface{} {
+	if conversations == nil {
+		conversations = []map[string]interface{}{}
+	}
+	return map[string]interface{}{
+		"operation":     operation,
+		"conversations": conversations,
+	}
+}
+
+// ConversationEntry builds one conversation object for the Web AIM client.
+func ConversationEntry(aimID, displayID, message, msgID, sender string, sent bool, unread int) map[string]interface{} {
+	entry := map[string]interface{}{
+		"aimId":       aimID,
+		"displayId":   displayID,
+		"active":      0,
+		"unreadCount": unread,
+	}
+	if message != "" {
+		entry["lastIM"] = map[string]interface{}{
+			"message":   message,
+			"msgId":     msgID,
+			"sender":    sender,
+			"sent":      sent,
+			"timestamp": float64(time.Now().Unix()),
+		}
+	}
+	return entry
+}

+ 8 - 5
server/webapi/types/events.go

@@ -22,6 +22,7 @@ const (
 	EventTypeOfflineIM    EventType = "offlineIM"
 	EventTypeOfflineIM    EventType = "offlineIM"
 	EventTypeSessionEnded EventType = "sessionEnded"
 	EventTypeSessionEnded EventType = "sessionEnded"
 	EventTypeRateLimit    EventType = "rateLimit"
 	EventTypeRateLimit    EventType = "rateLimit"
+	EventTypePreference   EventType = "preference"
 )
 )
 
 
 // Event represents an event to be delivered to a web client.
 // Event represents an event to be delivered to a web client.
@@ -29,7 +30,7 @@ type Event struct {
 	Type      EventType   `json:"type"`
 	Type      EventType   `json:"type"`
 	SeqNum    uint64      `json:"seqNum"`
 	SeqNum    uint64      `json:"seqNum"`
 	Timestamp int64       `json:"timestamp"`
 	Timestamp int64       `json:"timestamp"`
-	Data      interface{} `json:"data"`
+	Data      interface{} `json:"eventData"`
 }
 }
 
 
 // PresenceEvent represents a presence change event.
 // PresenceEvent represents a presence change event.
@@ -45,10 +46,11 @@ type PresenceEvent struct {
 
 
 // IMEvent represents an instant message event.
 // IMEvent represents an instant message event.
 type IMEvent struct {
 type IMEvent struct {
-	From      string  `json:"from"`
-	Message   string  `json:"message"`
-	Timestamp float64 `json:"timestamp"` // float64 for AMF3 encoding
-	AutoResp  bool    `json:"autoResponse,omitempty"`
+	Source    UserInfo `json:"source"`
+	Message   string   `json:"message"`
+	MsgID     string   `json:"msgId,omitempty"`
+	Timestamp float64  `json:"timestamp"` // float64 for AMF3 encoding
+	AutoResp  bool     `json:"autoresponse,omitempty"`
 }
 }
 
 
 // SentIMEvent represents a sent instant message event.
 // SentIMEvent represents a sent instant message event.
@@ -56,6 +58,7 @@ type SentIMEvent struct {
 	Sender    UserInfo `json:"sender"` // Sender user info
 	Sender    UserInfo `json:"sender"` // Sender user info
 	Dest      UserInfo `json:"dest"`   // Destination user info
 	Dest      UserInfo `json:"dest"`   // Destination user info
 	Message   string   `json:"message"`
 	Message   string   `json:"message"`
+	MsgID     string   `json:"msgId,omitempty"`
 	Timestamp float64  `json:"timestamp"` // float64 for AMF3 encoding
 	Timestamp float64  `json:"timestamp"` // float64 for AMF3 encoding
 	AutoResp  bool     `json:"autoResponse,omitempty"`
 	AutoResp  bool     `json:"autoResponse,omitempty"`
 }
 }

+ 32 - 11
state/feedbag_list.go

@@ -1,6 +1,7 @@
 package state
 package state
 
 
 import (
 import (
+	"errors"
 	"fmt"
 	"fmt"
 	"math"
 	"math"
 	"slices"
 	"slices"
@@ -8,6 +9,9 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 )
 
 
+// ErrGroupNotFound is returned when a feedbag group cannot be found.
+var ErrGroupNotFound = errors.New("group not found")
+
 // FeedbagList provides operations for manipulating a collection of feedbag
 // FeedbagList provides operations for manipulating a collection of feedbag
 // items. It supports lookups by class/name/group, item insertion with
 // items. It supports lookups by class/name/group, item insertion with
 // automatic ID generation, and transparent root group management.
 // automatic ID generation, and transparent root group management.
@@ -138,20 +142,37 @@ func (f *FeedbagList) AddBuddy(groupName, screenName, alias, note string) (bool,
 
 
 // DeleteBuddy marks a buddy item for deletion in the given group (by name).
 // DeleteBuddy marks a buddy item for deletion in the given group (by name).
 // The parent group's order TLV is updated to remove the buddy.
 // The parent group's order TLV is updated to remove the buddy.
+// Pass "*" as groupName to remove the buddy from all groups.
 func (f *FeedbagList) DeleteBuddy(groupName, buddyName string) error {
 func (f *FeedbagList) DeleteBuddy(groupName, buddyName string) error {
-	group := f.groupByName(groupName)
-	if group == nil {
-		return fmt.Errorf("group %q not found", groupName)
+	var groups []*wire.FeedbagItem
+
+	if groupName == "*" {
+		// delete from all groups
+		for _, item := range f.items {
+			if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID != 0 {
+				groups = append(groups, item)
+			}
+		}
+	} else {
+		group := f.groupByName(groupName)
+		if group == nil {
+			return fmt.Errorf("%w: %q", ErrGroupNotFound, groupName)
+		}
+		groups = []*wire.FeedbagItem{group}
 	}
 	}
-	deleted, found := f.deleteItem(wire.FeedbagItem{
-		ClassID: wire.FeedbagClassIdBuddy,
-		GroupID: group.GroupID,
-		Name:    buddyName,
-	})
-	if found {
-		group.RemoveOrderMembers(deleted.ItemID)
-		f.trackUpdate(group)
+
+	for _, group := range groups {
+		deleted, found := f.deleteItem(wire.FeedbagItem{
+			ClassID: wire.FeedbagClassIdBuddy,
+			GroupID: group.GroupID,
+			Name:    buddyName,
+		})
+		if found {
+			group.RemoveOrderMembers(deleted.ItemID)
+			f.trackUpdate(group)
+		}
 	}
 	}
+
 	return nil
 	return nil
 }
 }
 
 

+ 10 - 0
state/migrations/0034_drop_webapi_tokens.down.sql

@@ -0,0 +1,10 @@
+-- Restore webapi_tokens (see 0016_webapi_tokens.up.sql)
+CREATE TABLE IF NOT EXISTS webapi_tokens (
+    token TEXT PRIMARY KEY,
+    screen_name TEXT NOT NULL,
+    expires_at TIMESTAMP NOT NULL,
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_webapi_tokens_expires_at ON webapi_tokens(expires_at);
+CREATE INDEX IF NOT EXISTS idx_webapi_tokens_screen_name ON webapi_tokens(screen_name);

+ 3 - 0
state/migrations/0034_drop_webapi_tokens.up.sql

@@ -0,0 +1,3 @@
+DROP INDEX IF EXISTS idx_webapi_tokens_screen_name;
+DROP INDEX IF EXISTS idx_webapi_tokens_expires_at;
+DROP TABLE IF EXISTS webapi_tokens;

+ 1 - 0
state/migrations/0035_seed_aim_web_api_key.down.sql

@@ -0,0 +1 @@
+DELETE FROM web_api_keys WHERE dev_id = 'aim_web';

+ 20 - 0
state/migrations/0035_seed_aim_web_api_key.up.sql

@@ -0,0 +1,20 @@
+-- Default dev key used by the mirrored AIM Web client (k=ao1yOLlHVHhsa3o6).
+INSERT OR IGNORE INTO web_api_keys (
+    dev_id,
+    dev_key,
+    app_name,
+    created_at,
+    is_active,
+    rate_limit,
+    allowed_origins,
+    capabilities
+) VALUES (
+    'aim_web',
+    'ao1yOLlHVHhsa3o6',
+    'AIM Web Client',
+    strftime('%s', 'now'),
+    1,
+    600,
+    '["http://localhost","https://localhost","http://localhost:8000","https://localhost:8000","http://127.0.0.1:8000","https://127.0.0.1:8000"]',
+    '[]'
+);

+ 0 - 0
state/migrations/0034_linked_accounts.down.sql → state/migrations/0036_linked_accounts.down.sql


+ 0 - 0
state/migrations/0034_linked_accounts.up.sql → state/migrations/0036_linked_accounts.up.sql


+ 0 - 114
state/webapi_auth.go

@@ -1,114 +0,0 @@
-package state
-
-import (
-	"context"
-	"database/sql"
-	"errors"
-	"fmt"
-	"time"
-)
-
-// WebAPITokenStore manages authentication tokens for Web API sessions.
-type WebAPITokenStore struct {
-	store *SQLiteUserStore
-}
-
-// NewWebAPITokenStore creates a new token store.
-func (s *SQLiteUserStore) NewWebAPITokenStore() *WebAPITokenStore {
-	return &WebAPITokenStore{store: s}
-}
-
-// StoreToken saves an authentication token for a user.
-func (s *WebAPITokenStore) StoreToken(ctx context.Context, token string, screenName IdentScreenName, expiresAt time.Time) error {
-	query := `
-		INSERT INTO webapi_tokens (token, screen_name, expires_at, created_at)
-		VALUES (?, ?, ?, ?)
-		ON CONFLICT(token) DO UPDATE SET
-			screen_name = excluded.screen_name,
-			expires_at = excluded.expires_at
-	`
-	_, err := s.store.db.ExecContext(ctx, query, token, screenName.String(), expiresAt, time.Now())
-	if err != nil {
-		return fmt.Errorf("failed to store token: %w", err)
-	}
-	return nil
-}
-
-// ValidateToken checks if a token is valid and returns the associated screen name.
-func (s *WebAPITokenStore) ValidateToken(ctx context.Context, token string) (IdentScreenName, error) {
-	var screenNameStr string
-	var expiresAt time.Time
-
-	query := `
-		SELECT screen_name, expires_at 
-		FROM webapi_tokens 
-		WHERE token = ?
-	`
-	err := s.store.db.QueryRowContext(ctx, query, token).Scan(&screenNameStr, &expiresAt)
-	if err != nil {
-		if errors.Is(err, sql.ErrNoRows) {
-			return NewIdentScreenName(""), errors.New("invalid token")
-		}
-		return NewIdentScreenName(""), fmt.Errorf("failed to validate token: %w", err)
-	}
-
-	// Check if token has expired
-	if time.Now().After(expiresAt) {
-		// Clean up expired token
-		_ = s.DeleteToken(ctx, token)
-		return NewIdentScreenName(""), errors.New("token expired")
-	}
-
-	return NewIdentScreenName(screenNameStr), nil
-}
-
-// DeleteToken removes a token.
-func (s *WebAPITokenStore) DeleteToken(ctx context.Context, token string) error {
-	query := `DELETE FROM webapi_tokens WHERE token = ?`
-	_, err := s.store.db.ExecContext(ctx, query, token)
-	if err != nil {
-		return fmt.Errorf("failed to delete token: %w", err)
-	}
-	return nil
-}
-
-// CleanupExpiredTokens removes all expired tokens from the database.
-func (s *WebAPITokenStore) CleanupExpiredTokens(ctx context.Context) error {
-	query := `DELETE FROM webapi_tokens WHERE expires_at < ?`
-	_, err := s.store.db.ExecContext(ctx, query, time.Now())
-	if err != nil {
-		return fmt.Errorf("failed to cleanup expired tokens: %w", err)
-	}
-	return nil
-}
-
-// AuthenticateUser verifies username and password.
-// This implementation uses the existing user store for authentication.
-func (u *SQLiteUserStore) AuthenticateUser(ctx context.Context, username, password string) (*User, error) {
-	// Convert username to IdentScreenName for lookup
-	identSN := NewIdentScreenName(username)
-
-	// Try to find the user
-	user, err := u.User(ctx, identSN)
-	if err != nil {
-		return nil, fmt.Errorf("user not found: %w", err)
-	}
-
-	// In development mode with DISABLE_AUTH=true, accept any password
-	// In production, this would verify the password hash
-	// For now, we'll accept any non-empty password if the user exists
-	if password == "" {
-		return nil, errors.New("password required")
-	}
-
-	// TODO: In production, verify password hash here
-	// For development with DISABLE_AUTH, we just check if user exists
-
-	return user, nil
-}
-
-// FindUserByScreenName finds a user by their screen name.
-// This is just an alias for the User method to satisfy the UserManager interface.
-func (u *SQLiteUserStore) FindUserByScreenName(ctx context.Context, screenName IdentScreenName) (*User, error) {
-	return u.User(ctx, screenName)
-}

+ 119 - 0
state/webapi_imlog.go

@@ -0,0 +1,119 @@
+package state
+
+import (
+	"sort"
+	"strings"
+)
+
+// WebAPIStoredIM is one message in a Web AIM session's in-memory IM log.
+// The Web AIM client expects fetchStoredIMs entries with sender, message, msgId, and date.
+type WebAPIStoredIM struct {
+	Sender  string
+	Message string
+	MsgID   string
+	Date    int64 // Unix seconds
+}
+
+// AddStoredIM appends a message to the per-partner log for this session.
+func (s *WebAPISession) AddStoredIM(partnerAimID, sender, message, msgID string, date int64) {
+	if s == nil || partnerAimID == "" || message == "" {
+		return
+	}
+	s.imLogMu.Lock()
+	defer s.imLogMu.Unlock()
+	if s.imLog == nil {
+		s.imLog = make(map[string][]WebAPIStoredIM)
+	}
+	s.imLog[normalizeWebAPIAimID(partnerAimID)] = append(s.imLog[normalizeWebAPIAimID(partnerAimID)], WebAPIStoredIM{
+		Sender:  sender,
+		Message: message,
+		MsgID:   msgID,
+		Date:    date,
+	})
+}
+
+// StoredIMQuery describes filters for fetchStoredIMs.
+type StoredIMQuery struct {
+	PartnerAimID string
+	StartTime    int64
+	EndTime      int64
+	NToGet       int
+	SortOrder    string
+	SkipMsgID    string
+	StopMsgID    string
+}
+
+// GetStoredIMs returns stored messages for a conversation partner, filtered and sorted
+// per the Web AIM client's fetchStoredIMs parameters.
+func (s *WebAPISession) GetStoredIMs(q StoredIMQuery) []map[string]interface{} {
+	if s == nil || q.PartnerAimID == "" {
+		return nil
+	}
+
+	s.imLogMu.Lock()
+	msgs := append([]WebAPIStoredIM(nil), s.imLog[normalizeWebAPIAimID(q.PartnerAimID)]...)
+	s.imLogMu.Unlock()
+
+	if len(msgs) == 0 {
+		return []map[string]interface{}{}
+	}
+
+	filtered := make([]WebAPIStoredIM, 0, len(msgs))
+	for _, msg := range msgs {
+		if q.StartTime > 0 && msg.Date < q.StartTime {
+			continue
+		}
+		if q.EndTime > 0 && msg.Date > q.EndTime {
+			continue
+		}
+		filtered = append(filtered, msg)
+	}
+
+	descending := strings.EqualFold(q.SortOrder, "descendingDate")
+	sort.Slice(filtered, func(i, j int) bool {
+		if descending {
+			return filtered[i].Date > filtered[j].Date
+		}
+		return filtered[i].Date < filtered[j].Date
+	})
+
+	if q.SkipMsgID != "" {
+		for i, msg := range filtered {
+			if msg.MsgID == q.SkipMsgID {
+				filtered = filtered[i+1:]
+				break
+			}
+		}
+	}
+	if q.StopMsgID != "" {
+		for i, msg := range filtered {
+			if msg.MsgID == q.StopMsgID {
+				filtered = filtered[:i]
+				break
+			}
+		}
+	}
+
+	n := q.NToGet
+	if n <= 0 {
+		n = 100
+	}
+	if len(filtered) > n {
+		filtered = filtered[:n]
+	}
+
+	out := make([]map[string]interface{}, len(filtered))
+	for i, msg := range filtered {
+		out[i] = map[string]interface{}{
+			"sender":  msg.Sender,
+			"message": msg.Message,
+			"msgId":   msg.MsgID,
+			"date":    float64(msg.Date),
+		}
+	}
+	return out
+}
+
+func normalizeWebAPIAimID(aimID string) string {
+	return strings.ToLower(aimID)
+}

+ 33 - 0
state/webapi_imlog_test.go

@@ -0,0 +1,33 @@
+package state
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestWebAPISession_GetStoredIMs(t *testing.T) {
+	sess := &WebAPISession{}
+	sess.AddStoredIM("buddy1", "me", "hello", "msg-1", 100)
+	sess.AddStoredIM("buddy1", "buddy1", "hi back", "msg-2", 200)
+	sess.AddStoredIM("buddy2", "buddy2", "other chat", "msg-3", 150)
+
+	msgs := sess.GetStoredIMs(StoredIMQuery{
+		PartnerAimID: "buddy1",
+		SortOrder:    "descendingDate",
+		NToGet:       10,
+	})
+	assert.Len(t, msgs, 2)
+	assert.Equal(t, "msg-2", msgs[0]["msgId"])
+	assert.Equal(t, float64(200), msgs[0]["date"])
+	assert.Equal(t, "hello", msgs[1]["message"])
+
+	msgs = sess.GetStoredIMs(StoredIMQuery{
+		PartnerAimID: "buddy1",
+		SortOrder:    "ascendingDate",
+		StartTime:    150,
+		EndTime:      250,
+	})
+	assert.Len(t, msgs, 1)
+	assert.Equal(t, "msg-2", msgs[0]["msgId"])
+}

+ 80 - 18
state/webapi_session.go

@@ -6,6 +6,7 @@ import (
 	"encoding/hex"
 	"encoding/hex"
 	"errors"
 	"errors"
 	"log/slog"
 	"log/slog"
+	"strconv"
 	"sync"
 	"sync"
 	"time"
 	"time"
 
 
@@ -22,22 +23,25 @@ var (
 
 
 // WebAPISession represents an active Web AIM API session.
 // WebAPISession represents an active Web AIM API session.
 type WebAPISession struct {
 type WebAPISession struct {
-	AimSID          string            // Unique session ID for web client
-	ScreenName      DisplayScreenName // User identity
-	OSCARSession    *SessionInstance  // Bridge to existing OSCAR session
-	Events          []string          // Subscribed event types
-	EventQueue      *types.EventQueue // Per-session event queue
-	DevID           string            // Developer ID that created this session
-	ClientName      string            // Client application name
-	ClientVersion   string            // Client application version
-	CreatedAt       time.Time         // SessionInstance creation time
-	LastAccessed    time.Time         // Last activity time
-	ExpiresAt       time.Time         // SessionInstance expiration time
-	FetchTimeout    int               // Long-polling timeout in milliseconds
-	TimeToNextFetch int               // Suggested delay before next fetch
-	RemoteAddr      string            // Client IP address
-	TempBuddies     map[string]bool   // Temporary buddies for this session only
-	logger          *slog.Logger      // Logger for debugging
+	AimSID             string                                         // Unique session ID for web client
+	ScreenName         DisplayScreenName                              // User identity
+	OSCARSession       *SessionInstance                               // Bridge to existing OSCAR session
+	Events             []string                                       // Subscribed event types
+	EventQueue         *types.EventQueue                              // Per-session event queue
+	DevID              string                                         // Developer ID that created this session
+	ClientName         string                                         // Client application name
+	ClientVersion      string                                         // Client application version
+	CreatedAt          time.Time                                      // SessionInstance creation time
+	LastAccessed       time.Time                                      // Last activity time
+	ExpiresAt          time.Time                                      // SessionInstance expiration time
+	FetchTimeout       int                                            // Long-polling timeout in milliseconds
+	TimeToNextFetch    int                                            // Suggested delay before next fetch
+	RemoteAddr         string                                         // Client IP address
+	TempBuddies        map[string]bool                                // Temporary buddies for this session only
+	BuddyListRefresher func(ctx context.Context) (interface{}, error) // Called on feedbag changes to push buddylist event
+	imLog              map[string][]WebAPIStoredIM
+	imLogMu            sync.Mutex
+	logger             *slog.Logger // Logger for debugging
 }
 }
 
 
 // IsExpired checks if the session has expired.
 // IsExpired checks if the session has expired.
@@ -103,6 +107,8 @@ func (s *WebAPISession) handleSNACMessage(msg wire.SNACMessage) {
 		s.handleICBMMessage(msg)
 		s.handleICBMMessage(msg)
 	case wire.Buddy:
 	case wire.Buddy:
 		s.handleBuddyMessage(msg)
 		s.handleBuddyMessage(msg)
+	case wire.Feedbag:
+		s.handleFeedbagMessage(msg)
 	}
 	}
 }
 }
 
 
@@ -142,15 +148,40 @@ func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
 	// Check if it's an auto-response (channel 2)
 	// Check if it's an auto-response (channel 2)
 	autoResponse := body.ChannelID == 0x0002
 	autoResponse := body.ChannelID == 0x0002
 
 
+	msgID := strconv.FormatUint(body.Cookie, 10)
+	partner := body.ScreenName
+	nowSec := time.Now().Unix()
+	s.AddStoredIM(partner, partner, messageText, msgID, nowSec)
+
 	// Create IM event
 	// Create IM event
 	imEvent := types.IMEvent{
 	imEvent := types.IMEvent{
-		From:      body.ScreenName,
+		Source: types.UserInfo{
+			AimID:     body.ScreenName,
+			DisplayID: body.ScreenName,
+			UserType:  "aim",
+			State:     "online",
+		},
 		Message:   messageText,
 		Message:   messageText,
+		MsgID:     msgID,
 		Timestamp: float64(time.Now().Unix()),
 		Timestamp: float64(time.Now().Unix()),
 		AutoResp:  autoResponse,
 		AutoResp:  autoResponse,
 	}
 	}
 
 
 	s.EventQueue.Push(types.EventTypeIM, imEvent)
 	s.EventQueue.Push(types.EventTypeIM, imEvent)
+
+	if s.IsSubscribedTo("conversation") {
+		s.EventQueue.Push(types.EventTypeConversation, types.ConversationEventData("update", []map[string]interface{}{
+			types.ConversationEntry(
+				body.ScreenName,
+				body.ScreenName,
+				messageText,
+				msgID,
+				body.ScreenName,
+				false,
+				1,
+			),
+		}))
+	}
 }
 }
 
 
 // handleTypingNotification handles typing notifications.
 // handleTypingNotification handles typing notifications.
@@ -196,9 +227,25 @@ func (s *WebAPISession) handleBuddyArrived(msg wire.SNACMessage) {
 		return
 		return
 	}
 	}
 
 
+	stateStr := "online"
+	// For BuddyArrived updates, infer presence state from the TLVUserInfo.
+	// Away and invisible transitions are typically broadcast using BuddyArrived
+	// with updated user flags/status bits, not BuddyDeparted.
+	if body.IsInvisible() {
+		stateStr = "offline"
+	} else if body.IsAway() {
+		stateStr = "away"
+	} else if mask, ok := body.Uint32BE(wire.OServiceUserInfoStatus); ok {
+		if mask&wire.OServiceUserStatusDND == wire.OServiceUserStatusDND {
+			stateStr = "dnd"
+		} else if mask&wire.OServiceUserStatusAway == wire.OServiceUserStatusAway {
+			stateStr = "away"
+		}
+	}
+
 	presenceEvent := types.PresenceEvent{
 	presenceEvent := types.PresenceEvent{
 		AimID:    body.ScreenName,
 		AimID:    body.ScreenName,
-		State:    "online",
+		State:    stateStr,
 		UserType: "aim",
 		UserType: "aim",
 	}
 	}
 
 
@@ -225,6 +272,21 @@ func (s *WebAPISession) handleBuddyDeparted(msg wire.SNACMessage) {
 	s.EventQueue.Push(types.EventTypePresence, presenceEvent)
 	s.EventQueue.Push(types.EventTypePresence, presenceEvent)
 }
 }
 
 
+func (s *WebAPISession) handleFeedbagMessage(msg wire.SNACMessage) {
+	switch msg.Frame.SubGroup {
+	case wire.FeedbagInsertItem, wire.FeedbagUpdateItem, wire.FeedbagDeleteItem:
+		if s.BuddyListRefresher == nil {
+			return
+		}
+		groups, err := s.BuddyListRefresher(context.Background())
+		if err != nil {
+			s.logger.Error("failed to refresh buddy list after feedbag change", "err", err)
+			return
+		}
+		s.EventQueue.Push(types.EventTypeBuddyList, map[string]interface{}{"groups": groups})
+	}
+}
+
 // WebAPISessionManager manages Web API sessions with thread-safe operations.
 // WebAPISessionManager manages Web API sessions with thread-safe operations.
 type WebAPISessionManager struct {
 type WebAPISessionManager struct {
 	sessions      map[string]*WebAPISession          // Keyed by aimsid
 	sessions      map[string]*WebAPISession          // Keyed by aimsid