Преглед изворни кода

webapi: code cleanup

- consolidate files
- consolidate /aim/* handlers
- consolidate packages, convert mocks to mockery
- remove dead code paths
Mike пре 1 недеља
родитељ
комит
6e10fb4459
66 измењених фајлова са 5676 додато и 5032 уклоњено
  1. 26 0
      .mockery.yaml
  2. 7 22
      cmd/server/factory.go
  3. 420 88
      server/webapi/aim_handler.go
  4. 499 0
      server/webapi/aim_handler_test.go
  5. 222 37
      server/webapi/amf.go
  6. 76 86
      server/webapi/amf_test.go
  7. 191 33
      server/webapi/auth_handler.go
  8. 181 1
      server/webapi/auth_handler_test.go
  9. 87 19
      server/webapi/buddy_list_manager.go
  10. 77 61
      server/webapi/buddy_list_manager_test.go
  11. 34 200
      server/webapi/buddylist_handler.go
  12. 138 389
      server/webapi/buddylist_handler_test.go
  13. 1 1
      server/webapi/crossdomain.go
  14. 65 2
      server/webapi/events.go
  15. 161 30
      server/webapi/expressions_handler.go
  16. 289 57
      server/webapi/expressions_handler_test.go
  17. 0 51
      server/webapi/handler.go
  18. 0 47
      server/webapi/handlers/aim_stub.go
  19. 0 44
      server/webapi/handlers/alias.go
  20. 0 173
      server/webapi/handlers/buddy_icon.go
  21. 0 263
      server/webapi/handlers/buddy_icon_test.go
  22. 0 82
      server/webapi/handlers/conversation_stub.go
  23. 0 129
      server/webapi/handlers/events.go
  24. 0 194
      server/webapi/handlers/login_psp.go
  25. 0 197
      server/webapi/handlers/login_psp_test.go
  26. 0 59
      server/webapi/handlers/mocks_test.go
  27. 0 174
      server/webapi/handlers/oscar_bridge.go
  28. 0 221
      server/webapi/handlers/oscar_bridge_test.go
  29. 0 208
      server/webapi/handlers/ratelimit.go
  30. 0 28
      server/webapi/handlers/service_stub.go
  31. 0 46
      server/webapi/handlers/session_test.go
  32. 0 226
      server/webapi/handlers/webapi_event_converter.go
  33. 0 64
      server/webapi/handlers/webapi_event_converter_test.go
  34. 104 0
      server/webapi/helpers_test.go
  35. 81 54
      server/webapi/im_handler.go
  36. 84 122
      server/webapi/im_handler_test.go
  37. 13 49
      server/webapi/memberdir_handler.go
  38. 35 58
      server/webapi/memberdir_handler_test.go
  39. 191 166
      server/webapi/middleware.go
  40. 0 311
      server/webapi/middleware/cors_test.go
  41. 0 18
      server/webapi/middleware/logging.go
  42. 288 116
      server/webapi/middleware_test.go
  43. 190 0
      server/webapi/mock_bart_service_test.go
  44. 160 0
      server/webapi/mock_buddy_broadcaster_test.go
  45. 108 0
      server/webapi/mock_buddy_icon_retriever_test.go
  46. 111 0
      server/webapi/mock_dir_search_service_test.go
  47. 329 0
      server/webapi/mock_feedbag_service_test.go
  48. 261 0
      server/webapi/mock_icbm_service_test.go
  49. 331 0
      server/webapi/mock_locate_service_test.go
  50. 163 0
      server/webapi/mock_webapi_session_resolver_test.go
  51. 21 43
      server/webapi/preference_handler.go
  52. 17 17
      server/webapi/preference_handler_test.go
  53. 21 63
      server/webapi/presence_handler.go
  54. 44 125
      server/webapi/presence_handler_test.go
  55. 75 9
      server/webapi/response.go
  56. 1 1
      server/webapi/response_test.go
  57. 83 61
      server/webapi/server.go
  58. 198 69
      server/webapi/session.go
  59. 215 175
      server/webapi/session_test.go
  60. 18 26
      server/webapi/stub_handler.go
  61. 1 1
      server/webapi/stub_handler_test.go
  62. 53 61
      server/webapi/types.go
  63. 0 67
      server/webapi/types/conversation.go
  64. 6 7
      server/webapi/xml_shape_test.go
  65. 0 133
      state/webapi_imlog.go
  66. 0 48
      state/webapi_imlog_test.go

+ 26 - 0
.mockery.yaml

@@ -280,3 +280,29 @@ packages:
       ICBMService:
         config:
           filename: "mock_icbm_service_test.go"
+  github.com/mk6i/open-oscar-server/server/webapi:
+    interfaces:
+      BARTService:
+        config:
+          filename: "mock_bart_service_test.go"
+      BuddyBroadcaster:
+        config:
+          filename: "mock_buddy_broadcaster_test.go"
+      BuddyIconRetriever:
+        config:
+          filename: "mock_buddy_icon_retriever_test.go"
+      DirSearchService:
+        config:
+          filename: "mock_dir_search_service_test.go"
+      FeedbagService:
+        config:
+          filename: "mock_feedbag_service_test.go"
+      ICBMService:
+        config:
+          filename: "mock_icbm_service_test.go"
+      LocateService:
+        config:
+          filename: "mock_locate_service_test.go"
+      SessionResolver:
+        config:
+          filename: "mock_webapi_session_resolver_test.go"

+ 7 - 22
cmd/server/factory.go

@@ -23,12 +23,11 @@ import (
 	oscarmiddleware "github.com/mk6i/open-oscar-server/server/oscar/middleware"
 	"github.com/mk6i/open-oscar-server/server/toc"
 	"github.com/mk6i/open-oscar-server/server/webapi"
-	"github.com/mk6i/open-oscar-server/server/webapi/handlers"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-// Container groups together common dependencies.
+// Container groups common dependencies together.
 type Container struct {
 	cfg                    config.Config
 	chatSessionManager     *state.InMemoryChatSessionManager
@@ -39,7 +38,7 @@ type Container struct {
 	rateLimitClasses       wire.RateLimitClasses
 	snacRateLimits         wire.SNACRateLimits
 	sqLiteUserStore        *state.SQLiteUserStore
-	webAPISessionManager   *state.WebAPISessionManager
+	webAPISessionManager   *webapi.SessionManager
 	Listeners              []config.ListenerGroup
 	feedbagSvc             *foodgroup.FeedbagService
 	icqService             *foodgroup.ICQService
@@ -80,7 +79,7 @@ func MakeCommonDeps() (Container, error) {
 	c.logger = oscarmiddleware.NewLogger(c.cfg)
 	c.inMemorySessionManager = state.NewInMemorySessionManager(c.logger)
 	c.chatSessionManager = state.NewInMemoryChatSessionManager(c.logger)
-	c.webAPISessionManager = state.NewWebAPISessionManager()
+	c.webAPISessionManager = webapi.NewSessionManager()
 	c.rateLimitClasses = wire.DefaultRateLimitClasses()
 	c.snacRateLimits = wire.DefaultSNACRateLimits()
 
@@ -525,7 +524,6 @@ func WebAPI(deps Container) *webapi.Server {
 		deps.inMemorySessionManager,
 		deps.sqLiteUserStore,
 	)
-
 	bartService := foodgroup.NewBARTService(
 		logger,
 		deps.sqLiteUserStore,
@@ -533,22 +531,17 @@ func WebAPI(deps Container) *webapi.Server {
 		deps.sqLiteUserStore,
 		deps.inMemorySessionManager,
 	)
-
-	iconSource := handlers.BuddyIconSource{
+	iconSource := webapi.BuddyIconSource{
 		IconRetriever: deps.sqLiteUserStore,
 		BARTService:   bartService,
 		Logger:        logger,
 	}
-
-	// Create WebAPI buddy list manager (local to WebAPI)
-	buddyListManager := handlers.NewBuddyListManager(
+	buddyListManager := webapi.NewBuddyListManager(
 		deps.feedbagSvc,
 		locateService,
 		iconSource,
 		logger,
 	)
-
-	// Create the OSCAR buddy broadcaster for WebAPI to use
 	oscarBuddyBroadcaster := foodgroup.NewBuddyService(
 		deps.inMemorySessionManager,
 		deps.sqLiteUserStore,
@@ -557,7 +550,6 @@ func WebAPI(deps Container) *webapi.Server {
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 	)
-
 	handler := webapi.Handler{
 		AuthService: foodgroup.NewAuthService(
 			deps.cfg,
@@ -575,7 +567,6 @@ func WebAPI(deps Container) *webapi.Server {
 			logger,
 		),
 		BuddyListRegistry: deps.sqLiteUserStore,
-		CookieBaker:       deps.hmacCookieBaker,
 		ICBMService:       deps.icbmSvc,
 		LocateService:     locateService,
 		Logger:            logger,
@@ -594,16 +585,10 @@ func WebAPI(deps Container) *webapi.Server {
 			deps.sqLiteUserStore,
 			deps.sqLiteUserStore,
 		),
-		// New fields for WebAPI handlers
-		SessionRetriever: deps.inMemorySessionManager,
-		// Phase 2 additions
 		BuddyBroadcaster: oscarBuddyBroadcaster,
-		// Phase 4 additions for OSCAR Bridge
-		// listener groups come back in map order, so pin the web API to one
 		BOSListener: slices.MinFunc(deps.Listeners, func(a, b config.ListenerGroup) int {
 			return strings.Compare(a.Name, b.Name)
 		}),
-		// Phase 5 additions for buddy list and messaging
 		BuddyListManager:   buddyListManager,
 		ChatSessionManager: deps.chatSessionManager,
 		RecalcWarning:      deps.icbmSvc.RestoreWarningLevel,
@@ -611,10 +596,10 @@ func WebAPI(deps Container) *webapi.Server {
 		FeedbagService:     deps.feedbagSvc,
 		DirSearchService:   foodgroup.NewODirService(logger, deps.sqLiteUserStore),
 		IconSource:         iconSource,
-		BARTUploader:       bartService,
+		BARTService:        bartService,
 		SNACRateLimits:     deps.snacRateLimits,
 	}
-	// Pass SQLiteUserStore as the API key validator (it implements middleware.APIKeyValidator)
+
 	return webapi.NewServer(deps.cfg.WebAPIListeners, logger, handler, deps.sqLiteUserStore, deps.webAPISessionManager)
 }
 

+ 420 - 88
server/webapi/handlers/session.go → server/webapi/aim_handler.go

@@ -1,70 +1,47 @@
-package handlers
+package webapi
 
 import (
 	"context"
 	"encoding/base64"
+	"encoding/xml"
 	"fmt"
 	"log/slog"
+	"net"
 	"net/http"
 	"slices"
 	"strconv"
 	"strings"
 	"time"
 
-	"github.com/google/uuid"
 	"github.com/mk6i/open-oscar-server/config"
-	"github.com/mk6i/open-oscar-server/server/webapi/middleware"
-	"github.com/mk6i/open-oscar-server/server/webapi/types"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-// SessionHandler handles Web AIM API session management endpoints.
-type SessionHandler struct {
-	SessionManager   *state.WebAPISessionManager
-	OSCARAuthService AuthService
+// AimHandler serves the /aim/* Web AIM API endpoints: the session lifecycle,
+// the long-poll event fetch, session-local temp buddies, the OSCAR protocol
+// handoff, and the stubs the client calls during startup.
+type AimHandler struct {
+	SessionManager   *SessionManager
+	AuthService      AuthService
 	FeedbagService   FeedbagService
 	ICBMService      ICBMService
+	OServiceService  OServiceService
 	BuddyListManager *BuddyListManager
 	IconSource       BuddyIconSource
-	Logger           *slog.Logger
-	OServiceService  OServiceService
-	// the same SNAC-to-rate-class mapping RateLimitMiddleware enforces against, so
-	// the class a session alerts on cannot drift from the one it is charged
+	// BOSListener is the listener group startOSCARSession advertises a BOS
+	// address from.
+	BOSListener config.ListenerGroup
+	// SNACRateLimits is the same SNAC-to-rate-class mapping RateLimitMiddleware
+	// enforces against, so the class a session alerts on cannot drift from the
+	// one it is charged.
 	SNACRateLimits  wire.SNACRateLimits
+	Logger          *slog.Logger
 	FnSessCfg       func(sess *state.Session)
 	FnSessInit      func(instance *state.SessionInstance) func() error
 	FnInstanceClose func(instance *state.SessionInstance) func()
 }
 
-// AuthService defines methods needed for authentication.
-type AuthService interface {
-	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, endpointCfg config.Endpoint) (wire.SNACMessage, error)
-	CrackCookie(authCookie []byte) (state.ServerCookie, time.Time, error)
-	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, conf func(sess *state.Session)) (*state.SessionInstance, error)
-	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error)
-	Signout(ctx context.Context, session *state.Session)
-	SignoutChat(ctx context.Context, sess *state.Session)
-}
-
-// SessionManager defines methods for OSCAR session management.
-type SessionManager interface {
-	AddSession(ctx context.Context, screenName state.DisplayScreenName, doMultiSess bool, cfg ...func(sess *state.Session)) (*state.SessionInstance, error)
-	RemoveSession(session *state.Session)
-	RelayToScreenName(ctx context.Context, screenName state.IdentScreenName, msg wire.SNACMessage)
-}
-
-// BuddyListRegistry defines methods for buddy list management.
-type BuddyListRegistry interface {
-	RegisterBuddyList(ctx context.Context, screenName state.IdentScreenName) error
-	UnregisterBuddyList(ctx context.Context, screenName state.IdentScreenName) error
-}
-
-type ChatSessionManager interface {
-	RemoveUserFromAllChats(user state.IdentScreenName)
-}
-
 // MyInfo is the user's own identity blob, which the Web AIM client renders in
 // its identity badge. It is both the startSession payload's myInfo and the
 // myInfo event's data.
@@ -135,7 +112,7 @@ type StartSessionEvents struct {
 // BuddyListData is the buddylist event payload and the buddy list half of the
 // startSession seed.
 type BuddyListData struct {
-	Groups []WebAPIBuddyGroup `json:"groups" xml:"groups>group"`
+	Groups []BuddyGroup `json:"groups" xml:"groups>group"`
 }
 
 // StartSessionData is the startSession payload.
@@ -153,13 +130,13 @@ type StartSessionData struct {
 }
 
 // StartSession handles GET /aim/startSession requests.
-func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
+func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	ctx := r.Context()
 
 	// Get API key info from context (set by auth middleware)
-	apiKey, ok := ctx.Value(middleware.ContextKeyAPIKey).(*state.WebAPIKey)
+	apiKey, ok := ctx.Value(ContextKeyAPIKey).(*state.WebAPIKey)
 	if !ok {
-		h.sendError(w, r, http.StatusInternalServerError, "internal server error")
+		SendEnvelopeStatus(w, r, http.StatusInternalServerError, "internal server error", h.Logger)
 		return
 	}
 
@@ -207,20 +184,20 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	// A Web API session must be bridged to an authenticated OSCAR session;
 	// anonymous guests are not supported.
 	if authToken == "" {
-		h.sendError(w, r, http.StatusUnauthorized, "authentication token required")
+		SendEnvelopeStatus(w, r, http.StatusUnauthorized, "authentication token required", h.Logger)
 		return
 	}
 
 	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")
+		SendEnvelopeStatus(w, r, http.StatusUnauthorized, "invalid or expired token", h.Logger)
 		return
 	}
-	cookie, _, err := h.OSCARAuthService.CrackCookie(rawCookie)
+	cookie, _, err := h.AuthService.CrackCookie(rawCookie)
 	if err != nil {
 		h.Logger.Warn("invalid authentication token", "error", err)
-		h.sendError(w, r, http.StatusUnauthorized, "invalid or expired token")
+		SendEnvelopeStatus(w, r, http.StatusUnauthorized, "invalid or expired token", h.Logger)
 		return
 	}
 	screenName := cookie.ScreenName
@@ -235,10 +212,10 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	var instance *state.SessionInstance
 
 	// Create OSCAR session
-	instance, err = h.OSCARAuthService.RegisterBOSSession(ctx, cookie, h.FnSessCfg)
+	instance, err = h.AuthService.RegisterBOSSession(ctx, cookie, h.FnSessCfg)
 	if err != nil {
 		h.Logger.ErrorContext(ctx, "failed to create OSCAR session", "err", err.Error())
-		h.sendError(w, r, http.StatusServiceUnavailable, "unable to establish session")
+		SendEnvelopeStatus(w, r, http.StatusServiceUnavailable, "unable to establish session", h.Logger)
 		return
 	}
 
@@ -247,7 +224,7 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 		// RunOnce has already closed the whole session; this is belt-and-braces,
 		// since CloseInstance is idempotent and nothing else owns the instance yet.
 		instance.CloseInstance()
-		h.sendError(w, r, http.StatusInternalServerError, "internal server error")
+		SendEnvelopeStatus(w, r, http.StatusInternalServerError, "internal server error", h.Logger)
 		return
 	}
 
@@ -270,7 +247,7 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	if err := h.OServiceService.ClientOnline(ctx, wire.BOS, wire.SNAC_0x01_0x02_OServiceClientOnline{}, instance); err != nil {
 		h.Logger.ErrorContext(ctx, "failed to set client online", "err", err.Error())
 		instance.CloseInstance()
-		h.sendError(w, r, http.StatusInternalServerError, "internal server error")
+		SendEnvelopeStatus(w, r, http.StatusInternalServerError, "internal server error", h.Logger)
 		return
 	}
 
@@ -308,7 +285,7 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 		// path a startSession racing shutdown takes. The WebAPISession that
 		// would have owned the instance was never created.
 		instance.CloseInstance()
-		h.sendError(w, r, http.StatusInternalServerError, "failed to create session")
+		SendEnvelopeStatus(w, r, http.StatusInternalServerError, "failed to create session", h.Logger)
 		return
 	}
 
@@ -440,35 +417,30 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 		myInfoData := buildMyInfo(screenName, "online", myIconURL)
 		myInfoData.OnlineTime = time.Now().Unix()
 		myInfoData.MemberSince = time.Now().Unix() - 86400*30 // 30 days ago
-		session.EventQueue.Push(types.EventTypeMyInfo, myInfoData)
+		session.EventQueue.Push(EventTypeMyInfo, myInfoData)
 	}
 
 	if slices.Contains(events, "conversation") {
-		session.EventQueue.Push(types.EventTypeConversation,
-			types.ConversationEventData("list", nil))
+		session.EventQueue.Push(EventTypeConversation,
+			ConversationEventData("list", nil))
 	}
 
 	// The remaining seeds also populate the response payload, so they stay keyed off
 	// the subscription list they are rendered into.
 	for _, event := range events {
-		switch types.EventType(event) {
-		case types.EventTypeBuddyList:
-			buddyGroups := []WebAPIBuddyGroup{}
-			if h.BuddyListManager != nil {
-				var err error
-				buddyGroups, err = h.BuddyListManager.GetBuddyListForUser(ctx, session)
-				if err != nil {
-					h.Logger.ErrorContext(ctx, "failed to get buddy list", "err", err.Error())
-					buddyGroups = []WebAPIBuddyGroup{}
-				}
+		switch EventType(event) {
+		case EventTypeBuddyList:
+			buddyGroups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
+			if err != nil {
+				h.Logger.ErrorContext(ctx, "failed to get buddy list", "err", err.Error())
 			}
 			if buddyGroups == nil {
-				buddyGroups = []WebAPIBuddyGroup{}
+				buddyGroups = []BuddyGroup{}
 			}
 			blPayload := &BuddyListData{Groups: buddyGroups}
 			data.Events.BuddyList = blPayload
-			session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
-		case types.EventTypePreference:
+			session.EventQueue.Push(EventTypeBuddyList, blPayload)
+		case EventTypePreference:
 			// Seed the client with effective preference values: the user's stored
 			// prefs where set, and the server-side spec defaults otherwise. The
 			// client reads its buddy-list display prefs (e.g. showGroups) only from
@@ -482,8 +454,8 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 				prefPayload = effectiveBuddyPrefs(item.TLVList)
 			}
 			data.Events.Preference = prefPayload
-			session.EventQueue.Push(types.EventTypePreference, prefPayload)
-		case types.EventTypePermitDeny:
+			session.EventQueue.Push(EventTypePreference, prefPayload)
+		case EventTypePermitDeny:
 			// The client keeps its privacy state solely in the model this event
 			// populates. Both the block/unblock menu action and the "blocked"
 			// presence state read that model and no-op silently while it is
@@ -495,7 +467,7 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 				pdPayload = pdd
 			}
 			data.Events.PermitDeny = pdPayload
-			session.EventQueue.Push(types.EventTypePermitDeny, pdPayload)
+			session.EventQueue.Push(EventTypePermitDeny, pdPayload)
 		}
 	}
 
@@ -523,13 +495,8 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = data
-
 	// Send response in requested format (JSON, JSONP, XML, or AMF)
-	SendResponse(w, r, resp, h.Logger)
+	SendOK(w, r, data, h.Logger)
 
 	h.Logger.DebugContext(ctx, "session started",
 		"aimsid", session.AimSID,
@@ -541,7 +508,7 @@ func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 }
 
 // EndSession handles GET /aim/endSession requests.
-func (h *SessionHandler) EndSession(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *AimHandler) EndSession(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
 	// RemoveSession evicts the session from the manager and tears it down
@@ -553,12 +520,9 @@ func (h *SessionHandler) EndSession(w http.ResponseWriter, r *http.Request, sess
 	}
 
 	// Send response
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
 
 	// Send response in requested format (JSON, JSONP, or AMF)
-	SendResponse(w, r, resp, h.Logger)
+	SendOK(w, r, nil, h.Logger)
 
 	h.Logger.DebugContext(ctx, "session ended",
 		"aimsid", session.AimSID,
@@ -566,12 +530,380 @@ func (h *SessionHandler) EndSession(w http.ResponseWriter, r *http.Request, sess
 	)
 }
 
-// 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
+// FetchEventsData contains the events and metadata.
+type FetchEventsData struct {
+	Events          []Event `json:"events" xml:"events>event"`
+	LastSeqNum      uint64  `json:"lastSeqNum" xml:"lastSeqNum"`
+	TimeToNextFetch int     `json:"timeToNextFetch" xml:"timeToNextFetch"`
+	FetchBaseURL    string  `json:"fetchBaseURL" xml:"fetchBaseURL"`
+}
+
+// FetchEvents handles GET /aim/fetchEvents requests with long-polling support.
+func (h *AimHandler) FetchEvents(w http.ResponseWriter, r *http.Request, session *Session) {
+	ctx := r.Context()
+	aimsid := session.AimSID
+
+	// Get sequence number parameter
+	var lastSeqNum uint64
+	if seqStr := r.URL.Query().Get("seqNum"); seqStr != "" {
+		if val, err := strconv.ParseUint(seqStr, 10, 64); err == nil {
+			lastSeqNum = val
+		}
+	}
+
+	// Timeout is in milliseconds (per Web API spec and client behavior).
+	timeout := time.Duration(session.FetchTimeout) * time.Millisecond
+	if timeoutStr := r.URL.Query().Get("timeout"); timeoutStr != "" {
+		if val, err := strconv.Atoi(timeoutStr); err == nil && val > 0 {
+			timeout = time.Duration(val) * time.Millisecond
+		}
+	}
+
+	// Limit maximum timeout to 60 seconds
+	if timeout > 60*time.Second {
+		timeout = 60 * time.Second
+	}
+
+	// Create a context with timeout for the fetch operation
+	fetchCtx, cancel := context.WithTimeout(ctx, timeout)
+	defer cancel()
+
+	// Fetch events from the queue (will block until events available or timeout)
+	events, err := session.EventQueue.Fetch(fetchCtx, lastSeqNum, timeout)
+	if err != nil {
+		if err == context.DeadlineExceeded {
+			// Timeout is normal - return empty events array
+			events = []Event{}
+		} else {
+			h.Logger.ErrorContext(ctx, "failed to fetch events", "err", err.Error())
+			SendError(w, r, http.StatusInternalServerError, "failed to fetch events")
+			return
+		}
+	}
+
+	// Determine the last sequence number
+	newLastSeqNum := lastSeqNum
+	if len(events) > 0 {
+		newLastSeqNum = events[len(events)-1].SeqNum
+	}
+
+	// Prepare response
+	data := &FetchEventsData{
+		Events:          events,
+		LastSeqNum:      newLastSeqNum,
+		TimeToNextFetch: session.TimeToNextFetch,
+		// Include fetchBaseURL with updated sequence number for next request
+		FetchBaseURL: fmt.Sprintf("%s/aim/fetchEvents?aimsid=%s&seqNum=%d",
+			baseURLFromRequest(r), aimsid, newLastSeqNum),
+	}
+
+	// AMF3 clients (e.g. Gromit) take the events reshaped: timestamps as floats
+	// and the source/dest user objects flattened. That is a payload difference,
+	// not just an encoding one, so it stays here rather than in the encoder.
+	format := strings.ToLower(r.URL.Query().Get("f"))
+	if format == "amf" || format == "amf3" {
+		amfResp := map[string]interface{}{
+			"response": map[string]interface{}{
+				"data": map[string]interface{}{
+					"events":          ConvertEventsForAMF3(events),
+					"lastSeqNum":      newLastSeqNum,
+					"timeToNextFetch": session.TimeToNextFetch,
+					"fetchBaseURL": fmt.Sprintf("%s/aim/fetchEvents?aimsid=%s&seqNum=%d",
+						baseURLFromRequest(r), aimsid, newLastSeqNum),
+				},
+				"statusCode":       200,
+				"statusText":       "OK",
+				"statusDetailCode": 0,
+			},
+		}
+		SendResponse(w, r, amfResp, h.Logger)
+	} else {
+		// Send response in requested format (JSON, JSONP, or XML)
+		SendOK(w, r, data, h.Logger)
+	}
+
+	if len(events) > 0 {
+		h.Logger.DebugContext(ctx, "events fetched",
+			"aimsid", aimsid,
+			"count", len(events),
+			"last_seq", newLastSeqNum,
+		)
+	}
+}
+
+// AddTempBuddy handles GET /aim/addTempBuddy requests.
+// 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.
+func (h *AimHandler) AddTempBuddy(w http.ResponseWriter, r *http.Request, session *Session) {
+	ctx := r.Context()
+	aimsid := r.URL.Query().Get("aimsid")
+
+	buddyNames := r.URL.Query()["t"]
+	if len(buddyNames) == 0 {
+		SendError(w, r, http.StatusBadRequest, "missing buddy names (t parameter)")
+		return
+	}
+
+	// Store temporary buddies in the session
+	// Note: These are not persisted to the feedbag database
+	if session.TempBuddies == nil {
+		session.TempBuddies = make(map[string]bool)
+	}
+
+	for _, buddyName := range buddyNames {
+		buddyName = strings.TrimSpace(buddyName)
+		if buddyName != "" {
+			session.TempBuddies[buddyName] = true
+		}
+	}
+
+	// Prepare response
+	responseData := &ResultCodeData{ResultCode: "success", BuddyNames: buddyNames}
+
+	SendOK(w, r, responseData, h.Logger)
+
+	// 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",
+		"aimsid", aimsid,
+		"buddies", buddyNames,
+		"count", len(buddyNames),
+	)
+}
+
+// RemoveTempBuddy handles GET /aim/removeTempBuddy requests.
+// This removes temporary session buddies added via addTempBuddy.
+func (h *AimHandler) RemoveTempBuddy(w http.ResponseWriter, r *http.Request, session *Session) {
+	ctx := r.Context()
+	aimsid := r.URL.Query().Get("aimsid")
+
+	buddyNames := r.URL.Query()["t"]
+	if len(buddyNames) == 0 {
+		SendError(w, r, 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)
+	}
+
+	SendOK(w, r, &ResultCodeData{ResultCode: "success", BuddyNames: removed}, h.Logger)
+
+	h.Logger.InfoContext(ctx, "temporary buddies removed",
+		"aimsid", aimsid,
+		"buddies", removed,
+		"count", len(removed),
+	)
+}
+
+// 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 *AimHandler) SetForwardDomain(w http.ResponseWriter, r *http.Request) {
+	SendOK(w, r, nil, h.Logger)
+}
+
+// ReportAction acknowledges a client-side UI telemetry ping. The Web AIM client
+// fires this on menu clicks and similar interactions with an action param of the
+// form "type=click,id=block-user-chatmenu"; it ignores the response.
+func (h *AimHandler) ReportAction(w http.ResponseWriter, r *http.Request) {
+	SendOK(w, r, nil, h.Logger)
+}
+
+// StoredDataItems is the client-side data blob store, which this server does
+// not keep, so it always answers with an empty items list.
+type StoredDataItems struct {
+	Items []string `json:"items" xml:"items>item"`
+}
+
+// GetData returns empty client-side data blobs (buddy list favorites, etc.).
+func (h *AimHandler) GetData(w http.ResponseWriter, r *http.Request) {
+	SendOK(w, r, &StoredDataItems{Items: []string{}}, h.Logger)
+}
+
+// StartOSCARSessionResponse represents the response for startOSCARSession endpoint.
+type StartOSCARSessionResponse struct {
+	Response struct {
+		StatusCode int    `json:"statusCode" xml:"statusCode"`
+		StatusText string `json:"statusText" xml:"statusText"`
+		Data       struct {
+			Host   string `json:"host" xml:"host"`
+			Port   int    `json:"port" xml:"port"`
+			Cookie string `json:"cookie" xml:"cookie"`
+			// TLSCertName is the certificate name the client verifies BOS against.
+			// Omitted rather than sent empty: its absence means connect in the clear.
+			TLSCertName string `json:"tlsCertName,omitempty" xml:"tlsCertName,omitempty"`
+		} `json:"data" xml:"data"`
+	} `json:"response"`
+}
+
+// MarshalXML renders the envelope with the same flat root as BaseResponse.
+func (s StartOSCARSessionResponse) MarshalXML(e *xml.Encoder, _ xml.StartElement) error {
+	return e.EncodeElement(s.Response, xml.StartElement{Name: xml.Name{Local: "response"}})
+}
+
+// StartOSCARSession handles GET /aim/startOSCARSession requests, which hand a
+// client that authenticated over HTTP the address of a BOS server and the
+// cookie to sign on with. The token in "a" is the auth cookie clientLogin
+// minted, already what BOS expects, so it is handed straight back.
+//
+// The sig_sha256 the client computes over the query string is not checked: that
+// signature is keyed by HMAC(password, sessionSecret), and clientLogin keeps
+// neither past the response.
+func (h *AimHandler) StartOSCARSession(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	h.Logger.InfoContext(ctx, "startOSCARSession requested",
+		"method", r.Method,
+		"remote_addr", r.RemoteAddr,
+		"user_agent", r.UserAgent())
+
+	// Get API key info from context (set by auth middleware)
+	apiKey, ok := ctx.Value(ContextKeyAPIKey).(*state.WebAPIKey)
+	if !ok {
+		h.Logger.Error("API key not found in context")
+		SendError(w, r, http.StatusInternalServerError, "internal server error")
+		return
+	}
+
+	// Verify that this API key has permission to create OSCAR sessions
+	if !hasOSCARBridgeCapability(apiKey) {
+		h.Logger.Warn("API key lacks OSCAR bridge capability",
+			"dev_id", apiKey.DevID)
+		SendError(w, r, http.StatusForbidden, "OSCAR bridge not enabled for this application")
+		return
+	}
+
+	params := r.URL.Query()
+
+	token := params.Get("a")
+	if token == "" {
+		h.Logger.Warn("missing authentication token")
+		SendError(w, r, http.StatusUnauthorized, "authentication token required")
+		return
+	}
+
+	rawCookie, err := base64.URLEncoding.DecodeString(strings.TrimSpace(token))
+	if err != nil {
+		h.Logger.WarnContext(ctx, "invalid authentication token (base64)", "err", err.Error())
+		SendError(w, r, http.StatusUnauthorized, "invalid or expired token")
+		return
+	}
+
+	cookie, _, err := h.AuthService.CrackCookie(rawCookie)
+	if err != nil {
+		h.Logger.WarnContext(ctx, "invalid authentication token", "err", err.Error())
+		SendError(w, r, http.StatusUnauthorized, "invalid or expired token")
+		return
+	}
+
+	// Encryption the server cannot provide degrades to a plaintext host, which a
+	// client doing opportunistic encryption expects when no certificate is named.
+	// The sign-on cookie then crosses the wire in the clear, so the downgrade is
+	// logged rather than left to be inferred from the absent tlsCertName.
+	useTLS := parseBoolParam(params.Get("useTLS"))
+	endpoint := h.BOSListener.PlainEndpoint()
+	if useTLS {
+		ssl, ok := h.BOSListener.SSLEndpoint()
+		if !ok {
+			h.Logger.WarnContext(ctx, "TLS requested but no SSL listener is configured, advertising a plaintext BOS host",
+				"screen_name", cookie.ScreenName)
+			useTLS = false
+		} else {
+			endpoint = ssl
+		}
+	}
+
+	host, portStr, err := net.SplitHostPort(endpoint.AdvertisedHost())
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "unable to split advertised BOS host", "err", err.Error())
+		SendError(w, r, http.StatusInternalServerError, "internal server error")
+		return
+	}
+	port, _ := strconv.Atoi(portStr)
+
+	resp := &StartOSCARSessionResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data.Host = host
+	resp.Response.Data.Port = port
+	// Base64, the encoding the client decodes the cookie with.
+	resp.Response.Data.Cookie = base64.StdEncoding.EncodeToString(rawCookie)
+	if useTLS {
+		// The advertised SSL host is the name the certificate is issued to.
+		resp.Response.Data.TLSCertName = host
+	}
+
 	SendResponse(w, r, resp, h.Logger)
+
+	h.Logger.InfoContext(ctx, "OSCAR session bridge created",
+		"screen_name", cookie.ScreenName,
+		"bos_host", host,
+		"bos_port", port,
+		"use_tls", useTLS)
+}
+
+// hasOSCARBridgeCapability checks if the API key has permission to create OSCAR bridges.
+func hasOSCARBridgeCapability(apiKey *state.WebAPIKey) bool {
+	if len(apiKey.Capabilities) == 0 {
+		return true // No restrictions if capabilities not specified
+	}
+
+	// Check if OSCAR bridge is explicitly enabled
+	for _, cap := range apiKey.Capabilities {
+		if cap == "oscar_bridge" || cap == "*" {
+			return true
+		}
+	}
+
+	return false
+}
+
+// parseBoolParam parses a boolean parameter from query string.
+func parseBoolParam(value string) bool {
+	value = strings.ToLower(value)
+	return value == "true" || value == "1" || value == "yes"
+}
+
+// seedRateLimitAlert raises the client's rate limit alert when a session starts
+// on an account that is already rate limited.
+//
+// The monitor broadcasts transitions, not current state, so a session signing on
+// mid-limit missed the one that raised the alert — and the client's alert is
+// sticky, so the eventual "clear" would arrive with nothing to dismiss. An OSCAR
+// client learns the current state from the rate params it gets at handshake; this
+// is the Web API's equivalent.
+//
+// Only the limited state is seeded: alert is a warning the user cannot act on,
+// and seeding clear would render nothing.
+func seedRateLimitAlert(session *Session, classID wire.RateLimitClassID) {
+	if classID == 0 {
+		return
+	}
+
+	status := session.OSCARSession.Session().RateLimitStates()[classID-1].CurrentStatus
+	if status != wire.RateLimitStatusLimited {
+		return
+	}
+
+	session.EventQueue.Push(EventTypeRateLimit, RateLimitEvent{
+		Classes: []RateLimitClass{
+			{
+				ID:     int(classID),
+				Status: rateLimitStatusName(status),
+			},
+		},
+	})
 }
 
 // buildMyInfo assembles the shared base of a myInfo payload — the user's own

+ 499 - 0
server/webapi/aim_handler_test.go

@@ -0,0 +1,499 @@
+package webapi
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/mk6i/open-oscar-server/config"
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+)
+
+func TestBuildMyInfo_UserTypeAndService(t *testing.T) {
+	tests := []struct {
+		name       string
+		screenName string
+		wantType   string
+		wantSvc    string
+	}{
+		{"aim screen name", "mikekelly", "aim", "AIM"},
+		{"icq uin", "123456789", "icq", "ICQ"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			mi := buildMyInfo(state.DisplayScreenName(tt.screenName), "online", "")
+			assert.Equal(t, tt.wantType, mi.UserType)
+			assert.Equal(t, tt.wantSvc, mi.Service)
+		})
+	}
+}
+
+func TestBuildMyInfo_BuddyIcon(t *testing.T) {
+	t.Run("included when set", func(t *testing.T) {
+		mi := buildMyInfo(state.DisplayScreenName("mikekelly"), "away", "http://x/icon")
+		assert.Equal(t, "http://x/icon", mi.BuddyIcon)
+	})
+	t.Run("omitted when empty so the client merge preserves the current icon", func(t *testing.T) {
+		mi := buildMyInfo(state.DisplayScreenName("mikekelly"), "away", "")
+		assert.Empty(t, mi.BuddyIcon)
+
+		// omitempty is what actually keeps it out of the payload.
+		body, err := json.Marshal(mi)
+		assert.NoError(t, err)
+		assert.NotContains(t, string(body), "buddyIcon")
+	})
+}
+
+func TestAimHandler_AddTempBuddy(t *testing.T) {
+	tests := []struct {
+		name               string
+		queryParams        map[string][]string
+		session            *Session
+		expectedStatusCode int
+		expectedResponse   string
+		checkSession       func(*testing.T, *Session)
+	}{
+		{
+			name: "Success_SingleBuddy",
+			queryParams: map[string][]string{
+				"aimsid": {"test-session-id"},
+				"t":      {"buddy1"},
+			},
+			session: &Session{
+				AimSID:       "test-session-id",
+				ScreenName:   state.DisplayScreenName("testuser"),
+				EventQueue:   NewEventQueue(100),
+				TempBuddies:  nil,
+				LastAccessed: time.Now(),
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["buddy1"],"resultCode":"success"}}}`,
+			checkSession: func(t *testing.T, session *Session) {
+				assert.NotNil(t, session.TempBuddies)
+				assert.True(t, session.TempBuddies["buddy1"])
+				assert.Equal(t, 1, len(session.TempBuddies))
+			},
+		},
+		{
+			name: "Success_MultipleBuddies",
+			queryParams: map[string][]string{
+				"aimsid": {"test-session-id"},
+				"t":      {"buddy1", "buddy2", "buddy3"},
+			},
+			session: &Session{
+				AimSID:       "test-session-id",
+				ScreenName:   state.DisplayScreenName("testuser"),
+				EventQueue:   NewEventQueue(100),
+				TempBuddies:  nil,
+				LastAccessed: time.Now(),
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["buddy1","buddy2","buddy3"],"resultCode":"success"}}}`,
+			checkSession: func(t *testing.T, session *Session) {
+				assert.NotNil(t, session.TempBuddies)
+				assert.True(t, session.TempBuddies["buddy1"])
+				assert.True(t, session.TempBuddies["buddy2"])
+				assert.True(t, session.TempBuddies["buddy3"])
+				assert.Equal(t, 3, len(session.TempBuddies))
+			},
+		},
+		{
+			name: "Success_AddToExistingTempBuddies",
+			queryParams: map[string][]string{
+				"aimsid": {"test-session-id"},
+				"t":      {"buddy2"},
+			},
+			session: &Session{
+				AimSID:     "test-session-id",
+				ScreenName: state.DisplayScreenName("testuser"),
+				EventQueue: NewEventQueue(100),
+				TempBuddies: map[string]bool{
+					"buddy1": true,
+				},
+				LastAccessed: time.Now(),
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["buddy2"],"resultCode":"success"}}}`,
+			checkSession: func(t *testing.T, session *Session) {
+				assert.NotNil(t, session.TempBuddies)
+				assert.True(t, session.TempBuddies["buddy1"])
+				assert.True(t, session.TempBuddies["buddy2"])
+				assert.Equal(t, 2, len(session.TempBuddies))
+			},
+		},
+		{
+			name: "Error_MissingBuddyNames",
+			queryParams: map[string][]string{
+				"aimsid": {"test-session-id"},
+			},
+			session: &Session{
+				AimSID:       "test-session-id",
+				ScreenName:   state.DisplayScreenName("testuser"),
+				EventQueue:   NewEventQueue(100),
+				LastAccessed: time.Now(),
+			},
+			expectedStatusCode: http.StatusBadRequest,
+			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy names (t parameter)","data":{}}}`,
+		},
+		{
+			name: "Success_WithWhitespace",
+			queryParams: map[string][]string{
+				"aimsid": {"test-session-id"},
+				"t":      {"  buddy1  ", "buddy2 ", " buddy3"},
+			},
+			session: &Session{
+				AimSID:       "test-session-id",
+				ScreenName:   state.DisplayScreenName("testuser"),
+				EventQueue:   NewEventQueue(100),
+				TempBuddies:  nil,
+				LastAccessed: time.Now(),
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["  buddy1  ","buddy2 "," buddy3"],"resultCode":"success"}}}`,
+			checkSession: func(t *testing.T, session *Session) {
+				assert.NotNil(t, session.TempBuddies)
+				assert.True(t, session.TempBuddies["buddy1"])
+				assert.True(t, session.TempBuddies["buddy2"])
+				assert.True(t, session.TempBuddies["buddy3"])
+				assert.Equal(t, 3, len(session.TempBuddies))
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			handler := &AimHandler{
+				Logger: slog.Default(),
+			}
+
+			reqURL := "/aim/addTempBuddy"
+			if len(tt.queryParams) > 0 {
+				values := url.Values{}
+				for key, vals := range tt.queryParams {
+					for _, val := range vals {
+						values.Add(key, val)
+					}
+				}
+				reqURL += "?" + values.Encode()
+			}
+
+			req, err := http.NewRequest("GET", reqURL, nil)
+			assert.NoError(t, err)
+
+			rr := httptest.NewRecorder()
+			handler.AddTempBuddy(rr, req, tt.session)
+
+			assert.Equal(t, tt.expectedStatusCode, rr.Code)
+			assert.JSONEq(t, tt.expectedResponse, rr.Body.String())
+
+			if tt.checkSession != nil && tt.session != nil {
+				tt.checkSession(t, tt.session)
+			}
+		})
+	}
+}
+
+func TestAimHandler_AddTempBuddy_DoesNotPushBuddyListEvent(t *testing.T) {
+	handler := &AimHandler{Logger: slog.Default()}
+
+	eventQueue := NewEventQueue(100)
+	session := &Session{
+		AimSID:       "test-session",
+		ScreenName:   state.DisplayScreenName("testuser"),
+		EventQueue:   eventQueue,
+		TempBuddies:  nil,
+		LastAccessed: time.Now(),
+	}
+
+	req, err := http.NewRequest("GET", "/aim/addTempBuddy?aimsid=test-session&t=buddy1&t=buddy2", nil)
+	assert.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	handler.AddTempBuddy(rr, req, session)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Empty(t, eventQueue.GetAllEvents(), "addTempBuddy must not push buddylist events")
+}
+
+func TestAimHandler_RemoveTempBuddy(t *testing.T) {
+	handler := &AimHandler{Logger: slog.Default()}
+
+	session := &Session{
+		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"])
+}
+
+// testListener is a listener group whose SSL half is present only when the
+// test asks for it.
+func testListener(sslAvailable bool) config.ListenerGroup {
+	g := config.ListenerGroup{
+		Name:                   "local",
+		BOSListenAddress:       "0.0.0.0:5190",
+		BOSAdvertisedHostPlain: "bos.example.com:5190",
+	}
+	if sslAvailable {
+		g.BOSListenAddressSSL = "0.0.0.0:5191"
+		g.BOSAdvertisedHostSSL = "ssl.example.com:5193"
+	}
+	return g
+}
+
+// bridgeRequest builds a startOSCARSession request carrying the API key the
+// middleware would have put on the context.
+func bridgeRequest(query string, apiKey *state.WebAPIKey) *http.Request {
+	req := httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?"+query, nil)
+	if apiKey != nil {
+		req = req.WithContext(context.WithValue(req.Context(), ContextKeyAPIKey, apiKey))
+	}
+	return req
+}
+
+// bridgeData is the data object of a successful startOSCARSession response.
+type bridgeData struct {
+	Response struct {
+		StatusCode int `json:"statusCode"`
+		Data       struct {
+			Host        string `json:"host"`
+			Port        int    `json:"port"`
+			Cookie      string `json:"cookie"`
+			TLSCertName string `json:"tlsCertName"`
+		} `json:"data"`
+	} `json:"response"`
+}
+
+func TestAimHandler_StartOSCARSession(t *testing.T) {
+	validToken := base64.URLEncoding.EncodeToString(signedCookieFor("testuser"))
+	unrestrictedKey := &state.WebAPIKey{DevID: "dev123"}
+
+	tests := []struct {
+		name         string
+		query        string
+		apiKey       *state.WebAPIKey
+		sslAvailable bool
+		expectedCode int
+		checkBody    func(t *testing.T, body string)
+	}{
+		{
+			// No tlsCertName, which is how the client reads "connect in the clear".
+			name:         "Success_Plaintext",
+			query:        "a=" + validToken,
+			apiKey:       unrestrictedKey,
+			expectedCode: http.StatusOK,
+			checkBody: func(t *testing.T, body string) {
+				got := decodeBridgeData(t, body)
+				assert.Equal(t, 200, got.Response.StatusCode)
+				assert.Equal(t, "bos.example.com", got.Response.Data.Host)
+				assert.Equal(t, 5190, got.Response.Data.Port)
+				assert.Empty(t, got.Response.Data.TLSCertName)
+			},
+		},
+		{
+			name:         "Success_TLS",
+			query:        "a=" + validToken + "&useTLS=1",
+			apiKey:       unrestrictedKey,
+			sslAvailable: true,
+			expectedCode: http.StatusOK,
+			checkBody: func(t *testing.T, body string) {
+				got := decodeBridgeData(t, body)
+				assert.Equal(t, "ssl.example.com", got.Response.Data.Host)
+				assert.Equal(t, 5193, got.Response.Data.Port)
+				// The certificate is issued to the host the client is sent to.
+				assert.Equal(t, "ssl.example.com", got.Response.Data.TLSCertName)
+			},
+		},
+		{
+			// Encryption the server cannot provide degrades to a plaintext host
+			// rather than failing the handoff.
+			name:         "TLSRequestedButUnavailable_DegradesToPlaintext",
+			query:        "a=" + validToken + "&useTLS=true",
+			apiKey:       unrestrictedKey,
+			sslAvailable: false,
+			expectedCode: http.StatusOK,
+			checkBody: func(t *testing.T, body string) {
+				got := decodeBridgeData(t, body)
+				assert.Equal(t, "bos.example.com", got.Response.Data.Host)
+				assert.Empty(t, got.Response.Data.TLSCertName)
+			},
+		},
+		{
+			name:         "Error_MissingToken",
+			query:        "",
+			apiKey:       unrestrictedKey,
+			expectedCode: http.StatusUnauthorized,
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, "authentication token required")
+			},
+		},
+		{
+			name:         "Error_TokenNotBase64",
+			query:        "a=not!valid!base64",
+			apiKey:       unrestrictedKey,
+			expectedCode: http.StatusUnauthorized,
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, "invalid or expired token")
+			},
+		},
+		{
+			// A well-formed token the baker refuses to crack: wrong signature or
+			// past its expiry.
+			name:         "Error_TokenFailsSignatureCheck",
+			query:        "a=" + base64.URLEncoding.EncodeToString([]byte("forged")),
+			apiKey:       unrestrictedKey,
+			expectedCode: http.StatusUnauthorized,
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, "invalid or expired token")
+			},
+		},
+		{
+			name:         "Error_NoAPIKeyOnContext",
+			query:        "a=" + validToken,
+			apiKey:       nil,
+			expectedCode: http.StatusInternalServerError,
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, "internal server error")
+			},
+		},
+		{
+			name:         "Error_APIKeyLacksBridgeCapability",
+			query:        "a=" + validToken,
+			apiKey:       &state.WebAPIKey{DevID: "dev123", Capabilities: []string{"presence"}},
+			expectedCode: http.StatusForbidden,
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, "OSCAR bridge not enabled")
+			},
+		},
+		{
+			name:         "Success_APIKeyGrantsBridgeCapability",
+			query:        "a=" + validToken,
+			apiKey:       &state.WebAPIKey{DevID: "dev123", Capabilities: []string{"presence", "oscar_bridge"}},
+			expectedCode: http.StatusOK,
+			checkBody: func(t *testing.T, body string) {
+				assert.Equal(t, 200, decodeBridgeData(t, body).Response.StatusCode)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			handler := &AimHandler{
+				AuthService: &testAuthService{crackCookie: crackSignedCookie},
+				BOSListener: testListener(tt.sslAvailable),
+				Logger:      slog.Default(),
+			}
+
+			rr := httptest.NewRecorder()
+			handler.StartOSCARSession(rr, bridgeRequest(tt.query, tt.apiKey))
+
+			assert.Equal(t, tt.expectedCode, rr.Code)
+			tt.checkBody(t, rr.Body.String())
+		})
+	}
+}
+
+// The token arrives URL-safe, the way clientLogin minted it, and goes back out in
+// standard base64, the alphabet the client decodes the sign-on cookie with. The
+// cookie bytes here encode differently under each.
+func TestAimHandler_StartOSCARSession_ReencodesCookie(t *testing.T) {
+	rawCookie := []byte{0xff, 0xef, 0xbe}
+	urlSafe := base64.URLEncoding.EncodeToString(rawCookie)
+	standard := base64.StdEncoding.EncodeToString(rawCookie)
+	assert.NotEqual(t, urlSafe, standard, "test cookie must distinguish the two alphabets")
+
+	var cracked []byte
+	handler := &AimHandler{
+		AuthService: &testAuthService{
+			crackCookie: func(authCookie []byte) (state.ServerCookie, time.Time, error) {
+				cracked = authCookie
+				return state.ServerCookie{ScreenName: "testuser"}, time.Now().Add(shortTermTTL), nil
+			},
+		},
+		BOSListener: testListener(false),
+		Logger:      slog.Default(),
+	}
+
+	rr := httptest.NewRecorder()
+	handler.StartOSCARSession(rr, bridgeRequest("a="+urlSafe, &state.WebAPIKey{DevID: "dev123"}))
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Equal(t, rawCookie, cracked, "the baker sees the decoded cookie")
+	assert.Equal(t, standard, decodeBridgeData(t, rr.Body.String()).Response.Data.Cookie)
+}
+
+func decodeBridgeData(t *testing.T, body string) bridgeData {
+	t.Helper()
+	got := bridgeData{}
+	assert.NoError(t, json.Unmarshal([]byte(body), &got))
+	return got
+}
+
+// The monitor broadcasts transitions, not current state, so without a seed a
+// session signing on mid-limit shows no banner while its sends are rejected — and
+// the client's alert is sticky, so the eventual "clear" has nothing to dismiss.
+func TestSeedRateLimitAlert(t *testing.T) {
+	imClass, ok := wire.DefaultSNACRateLimits().RateClassLookup(wire.ICBM, wire.ICBMChannelMsgToHost)
+	require.True(t, ok)
+
+	// limitedSession returns a session on an account already in the limited state.
+	limitedSession := func(t *testing.T) *Session {
+		t.Helper()
+
+		session := newTestWebAPISession(t, tightRateLimitClasses())
+		sess := session.OSCARSession.Session()
+		for i := 0; sess.RateLimitStates()[imClass-1].CurrentStatus != wire.RateLimitStatusLimited; i++ {
+			require.Less(t, i, 100, "class never reached the limited state")
+			sess.EvaluateRateLimit(time.Now(), imClass)
+		}
+		return session
+	}
+
+	t.Run("a session starting on a limited account is told", func(t *testing.T) {
+		session := limitedSession(t)
+
+		seedRateLimitAlert(session, imClass)
+
+		assert.Equal(t, []string{"limit"}, rateLimitEventStatuses(t, session))
+	})
+
+	t.Run("a session starting on a clear account is told nothing", func(t *testing.T) {
+		session := newTestWebAPISession(t, tightRateLimitClasses())
+
+		seedRateLimitAlert(session, imClass)
+
+		assert.Empty(t, rateLimitEventStatuses(t, session))
+	})
+
+	t.Run("a zero class id disables the alert", func(t *testing.T) {
+		session := limitedSession(t)
+
+		seedRateLimitAlert(session, 0)
+
+		assert.Empty(t, rateLimitEventStatuses(t, session))
+	})
+}

+ 222 - 37
server/webapi/handlers/amf_encoder.go → server/webapi/amf.go

@@ -1,9 +1,8 @@
-package handlers
+package webapi
 
 import (
 	"fmt"
 	"log/slog"
-	"net/http"
 	"reflect"
 	"strings"
 	"time"
@@ -11,13 +10,6 @@ import (
 	goAMF3 "github.com/breign/goAMF3"
 )
 
-// AMFVersion represents the AMF encoding version
-type AMFVersion int
-
-const (
-	AMF3 AMFVersion = 3
-)
-
 // AMFEncoder handles AMF encoding operations for WebAPI responses
 type AMFEncoder struct {
 	logger *slog.Logger
@@ -29,7 +21,7 @@ func NewAMFEncoder(logger *slog.Logger) *AMFEncoder {
 }
 
 // EncodeAMF encodes data to AMF3 format (only supported version)
-func (e *AMFEncoder) EncodeAMF(data interface{}, version AMFVersion) ([]byte, error) {
+func (e *AMFEncoder) EncodeAMF(data interface{}) ([]byte, error) {
 	// For AMF3, use goAMF3 which properly supports it
 	// Convert to a regular map structure (no ECMAArray needed)
 	amfData := e.toAMF3Compatible(data)
@@ -156,6 +148,14 @@ func (e *AMFEncoder) errorResponseToMap(err ErrorResponse) map[string]interface{
 		"statusCode": err.Response.StatusCode,
 		"statusText": err.Response.StatusText,
 	}
+	// Both are omitted when unset, matching the omitempty the JSON and XML
+	// encodings apply to the same two fields.
+	if err.Response.StatusDetailCode != 0 {
+		m["statusDetailCode"] = err.Response.StatusDetailCode
+	}
+	if err.Response.RequestID != "" {
+		m["requestId"] = err.Response.RequestID
+	}
 	// The client dereferences response.data on a failure too, so the error
 	// envelope carries one in AMF as it does in every other format.
 	if err.Response.Data != nil {
@@ -305,40 +305,225 @@ func (e *AMFEncoder) isZeroValue(v reflect.Value) bool {
 	return false
 }
 
-// DetectAMFVersion determines which AMF version to use based on the request
-func DetectAMFVersion(r *http.Request) AMFVersion {
-	if r == nil {
-		return AMF3
+// ConvertEventForAMF3 converts a WebAPIEvent to a map suitable for AMF3 encoding,
+// ensuring all timestamps are float64 to avoid uint29 overflow issues.
+func ConvertEventForAMF3(event Event) map[string]interface{} {
+	result := map[string]interface{}{
+		"type":      string(event.Type),
+		"seqNum":    event.SeqNum,
+		"timestamp": float64(event.Timestamp), // Convert to float64
 	}
 
-	// Check query parameter first (highest priority)
-	format := strings.ToLower(r.URL.Query().Get("f"))
-	switch format {
-	case "amf3":
-		return AMF3
-	case "amf":
-		// Default to AMF3 for modern clients (Gromit expects AMF3)
-		return AMF3
-	}
+	// Convert event data based on type
+	switch event.Type {
+	case EventTypeIM:
+		if imEvent, ok := event.Data.(IMEvent); ok {
+			// Gromit expects 'source' as a user object and 'autoresponse' (lowercase)
+			eventData := map[string]interface{}{
+				"source": map[string]interface{}{
+					"aimId":     imEvent.Source.AimID,
+					"displayId": imEvent.Source.DisplayID,
+					"userType":  imEvent.Source.UserType,
+					"state":     imEvent.Source.State,
+				},
+				"message":      imEvent.Message,
+				"timestamp":    imEvent.Timestamp, // Already float64
+				"autoresponse": imEvent.AutoResp,
+			}
+			if imEvent.MsgID != "" {
+				eventData["msgId"] = imEvent.MsgID
+			}
+			result["eventData"] = eventData
+		} else if dataMap, ok := event.Data.(map[string]interface{}); ok {
+			// Already a map, ensure timestamps are float64
+			if ts, exists := dataMap["timestamp"]; exists {
+				if tsInt, ok := ts.(int64); ok {
+					dataMap["timestamp"] = float64(tsInt)
+				}
+			}
+			result["eventData"] = dataMap
+		} else {
+			result["eventData"] = event.Data
+		}
+
+	case EventTypeOfflineIM:
+		if imEvent, ok := event.Data.(OfflineIMEvent); ok {
+			eventData := map[string]interface{}{
+				"aimId":        imEvent.AimID,
+				"message":      imEvent.Message,
+				"timestamp":    imEvent.Timestamp, // Already float64
+				"autoresponse": imEvent.AutoResp,
+			}
+			// The client keys its conversation list and chat-log cache by msgId, so
+			// an event without one collides with every other offline message.
+			if imEvent.MsgID != "" {
+				eventData["msgId"] = imEvent.MsgID
+			}
+			result["eventData"] = eventData
+		} else if dataMap, ok := event.Data.(map[string]interface{}); ok {
+			// Already a map, ensure timestamps are float64
+			if ts, exists := dataMap["timestamp"]; exists {
+				if tsInt, ok := ts.(int64); ok {
+					dataMap["timestamp"] = float64(tsInt)
+				}
+			}
+			result["eventData"] = dataMap
+		} else {
+			result["eventData"] = event.Data
+		}
+
+	case EventTypePresence:
+		if presenceEvent, ok := event.Data.(PresenceEvent); ok {
+			eventData := map[string]interface{}{
+				"aimId":    presenceEvent.AimID,
+				"state":    presenceEvent.State,
+				"userType": presenceEvent.UserType,
+			}
+			// Convert timestamp fields to float64
+			if presenceEvent.OnlineTime > 0 {
+				eventData["onlineTime"] = float64(presenceEvent.OnlineTime)
+			}
+			// This branch flattens PresenceEvent through an explicit allowlist, so
+			// buddyIcon must be added here or it never reaches an AMF3 client.
+			if presenceEvent.BuddyIcon != "" {
+				eventData["buddyIcon"] = presenceEvent.BuddyIcon
+			}
+			result["eventData"] = eventData
+		} else if dataMap, ok := event.Data.(map[string]interface{}); ok {
+			// Already a map, ensure timestamps are float64
+			if ot, exists := dataMap["onlineTime"]; exists {
+				if otInt, ok := ot.(int64); ok {
+					dataMap["onlineTime"] = float64(otInt)
+				}
+			}
+			result["eventData"] = dataMap
+		} else {
+			result["eventData"] = event.Data
+		}
+
+	case EventType("myInfo"):
+		// MyInfo events often contain timestamps
+		if dataMap, ok := event.Data.(map[string]interface{}); ok {
+			// Convert any int64 timestamps to float64
+			for key, val := range dataMap {
+				if key == "onlineTime" || key == "memberSince" || key == "awayTime" || key == "statusTime" {
+					if intVal, ok := val.(int64); ok {
+						dataMap[key] = float64(intVal)
+					}
+				}
+			}
+			result["eventData"] = dataMap
+		} else {
+			result["eventData"] = event.Data
+		}
 
-	// Check Accept header for version hint
-	accept := r.Header.Get("Accept")
-	if strings.Contains(accept, "amf3") || strings.Contains(accept, "AMF3") {
-		return AMF3
+	case EventTypeBuddyList:
+		// Just pass through
+		result["eventData"] = event.Data
+
+	case EventTypeTyping:
+		result["eventData"] = event.Data
+
+	case EventTypeSentIM:
+		if sentIMEvent, ok := event.Data.(SentIMEvent); ok {
+			// Gromit expects both 'source' (sender) and 'dest' (recipient) for sentIM
+			// The parseIM function needs source even for outgoing messages
+			eventData := map[string]interface{}{
+				"source": map[string]interface{}{
+					"aimId":     sentIMEvent.Sender.AimID,
+					"displayId": sentIMEvent.Sender.DisplayID,
+					"userType":  sentIMEvent.Sender.UserType,
+					"state":     "online",
+				},
+				"dest": map[string]interface{}{
+					"aimId":     sentIMEvent.Dest.AimID,
+					"displayId": sentIMEvent.Dest.DisplayID,
+					"userType":  sentIMEvent.Dest.UserType,
+					"state":     "online",
+				},
+				"message":      sentIMEvent.Message,
+				"timestamp":    sentIMEvent.Timestamp, // Already float64
+				"autoresponse": sentIMEvent.AutoResp,
+			}
+			if sentIMEvent.MsgID != "" {
+				eventData["msgId"] = sentIMEvent.MsgID
+			}
+			result["eventData"] = eventData
+		} else {
+			result["eventData"] = event.Data
+		}
+
+	default:
+		// For unknown types, check if data is a map and convert any int64 values
+		if dataMap, ok := event.Data.(map[string]interface{}); ok {
+			result["eventData"] = convertTimestampsInMap(dataMap)
+		} else {
+			result["eventData"] = event.Data
+		}
 	}
-	if strings.Contains(accept, "amf") || strings.Contains(accept, "AMF") {
-		return AMF3 // Default to AMF3 for AMF requests
+
+	return result
+}
+
+// convertTimestampsInMap recursively converts int64 values that look like timestamps to float64
+func convertTimestampsInMap(data map[string]interface{}) map[string]interface{} {
+	result := make(map[string]interface{})
+	for key, val := range data {
+		// Check if key suggests it's a timestamp
+		if isTimestampField(key) {
+			if intVal, ok := val.(int64); ok {
+				result[key] = float64(intVal)
+				continue
+			}
+		}
+
+		// Recursively process nested maps
+		if nestedMap, ok := val.(map[string]interface{}); ok {
+			result[key] = convertTimestampsInMap(nestedMap)
+		} else if nestedSlice, ok := val.([]interface{}); ok {
+			convertedSlice := make([]interface{}, len(nestedSlice))
+			for i, item := range nestedSlice {
+				if itemMap, ok := item.(map[string]interface{}); ok {
+					convertedSlice[i] = convertTimestampsInMap(itemMap)
+				} else {
+					convertedSlice[i] = item
+				}
+			}
+			result[key] = convertedSlice
+		} else {
+			result[key] = val
+		}
 	}
+	return result
+}
 
-	// Check Content-Type header (for POST requests)
-	contentType := r.Header.Get("Content-Type")
-	if strings.Contains(contentType, "amf3") || strings.Contains(contentType, "AMF3") {
-		return AMF3
+// isTimestampField checks if a field name suggests it contains a timestamp
+func isTimestampField(fieldName string) bool {
+	timestampFields := []string{
+		"timestamp", "Timestamp",
+		"onlineTime", "OnlineTime",
+		"memberSince", "MemberSince",
+		"awayTime", "AwayTime",
+		"statusTime", "StatusTime",
+		"idleTime", "IdleTime",
+		"loginTime", "LoginTime",
+		"createdAt", "CreatedAt",
+		"updatedAt", "UpdatedAt",
 	}
-	if strings.Contains(contentType, "amf") || strings.Contains(contentType, "AMF") {
-		return AMF3 // Default to AMF3 for AMF requests
+
+	for _, tf := range timestampFields {
+		if fieldName == tf {
+			return true
+		}
 	}
+	return false
+}
 
-	// Default to AMF3 for modern clients
-	return AMF3
+// ConvertEventsForAMF3 converts a slice of WebAPIEvents for AMF3 encoding
+func ConvertEventsForAMF3(events []Event) []interface{} {
+	result := make([]interface{}, len(events))
+	for i, event := range events {
+		result[i] = ConvertEventForAMF3(event)
+	}
+	return result
 }

+ 76 - 86
server/webapi/handlers/amf_encoder_test.go → server/webapi/amf_test.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"fmt"
@@ -8,8 +8,7 @@ import (
 	"time"
 
 	goAMF3 "github.com/breign/goAMF3"
-
-	"github.com/mk6i/open-oscar-server/server/webapi/types"
+	"github.com/stretchr/testify/assert"
 )
 
 func TestAMFEncoderBasicTypes(t *testing.T) {
@@ -18,19 +17,18 @@ func TestAMFEncoderBasicTypes(t *testing.T) {
 	tests := []struct {
 		name    string
 		input   interface{}
-		version AMFVersion
 		wantErr bool
 	}{
-		{"String AMF3", "hello world", AMF3, false},
-		{"Number AMF3", 42, AMF3, false},
-		{"Float AMF3", 3.14159, AMF3, false},
-		{"Boolean AMF3", false, AMF3, false},
-		{"Null AMF3", nil, AMF3, false},
+		{"String AMF3", "hello world", false},
+		{"Number AMF3", 42, false},
+		{"Float AMF3", 3.14159, false},
+		{"Boolean AMF3", false, false},
+		{"Null AMF3", nil, false},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			data, err := encoder.EncodeAMF(tt.input, tt.version)
+			data, err := encoder.EncodeAMF(tt.input)
 			if (err != nil) != tt.wantErr {
 				t.Fatalf("EncodeAMF() error = %v, wantErr %v", err, tt.wantErr)
 			}
@@ -54,9 +52,8 @@ func TestAMFEncoderComplexTypes(t *testing.T) {
 	encoder := NewAMFEncoder(nil)
 
 	tests := []struct {
-		name    string
-		input   interface{}
-		version AMFVersion
+		name  string
+		input interface{}
 	}{
 		{
 			name: "Map",
@@ -65,7 +62,6 @@ func TestAMFEncoderComplexTypes(t *testing.T) {
 				"age":    30,
 				"active": true,
 			},
-			version: AMF3,
 		},
 		{
 			name: "Array",
@@ -75,7 +71,6 @@ func TestAMFEncoderComplexTypes(t *testing.T) {
 				true,
 				nil,
 			},
-			version: AMF3,
 		},
 		{
 			name: "BaseResponse",
@@ -93,12 +88,10 @@ func TestAMFEncoderComplexTypes(t *testing.T) {
 					},
 				},
 			},
-			version: AMF3,
 		},
 		{
-			name:    "ErrorResponse",
-			input:   newErrorResponse(404, "Not Found"),
-			version: AMF3,
+			name:  "ErrorResponse",
+			input: newErrorResponse(404, "Not Found"),
 		},
 		{
 			name: "Time",
@@ -106,13 +99,12 @@ func TestAMFEncoderComplexTypes(t *testing.T) {
 				"timestamp": time.Now(),
 				"name":      "Event",
 			},
-			version: AMF3,
 		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			data, err := encoder.EncodeAMF(tt.input, tt.version)
+			data, err := encoder.EncodeAMF(tt.input)
 			if err != nil {
 				t.Fatalf("EncodeAMF() error = %v", err)
 			}
@@ -134,62 +126,6 @@ func TestAMFEncoderComplexTypes(t *testing.T) {
 	}
 }
 
-func TestDetectAMFVersion(t *testing.T) {
-	tests := []struct {
-		name     string
-		request  *http.Request
-		expected AMFVersion
-	}{
-		{
-			name:     "Query parameter amf3",
-			request:  httptest.NewRequest("GET", "/?f=amf3", nil),
-			expected: AMF3,
-		},
-		{
-			name:     "Query parameter amf",
-			request:  httptest.NewRequest("GET", "/?f=amf", nil),
-			expected: AMF3,
-		},
-		{
-			name: "Accept header AMF3",
-			request: func() *http.Request {
-				req := httptest.NewRequest("GET", "/", nil)
-				req.Header.Set("Accept", "application/x-amf3")
-				return req
-			}(),
-			expected: AMF3,
-		},
-		{
-			name: "Accept header AMF",
-			request: func() *http.Request {
-				req := httptest.NewRequest("GET", "/", nil)
-				req.Header.Set("Accept", "application/x-amf")
-				return req
-			}(),
-			expected: AMF3,
-		},
-		{
-			name:     "No AMF indication",
-			request:  httptest.NewRequest("GET", "/", nil),
-			expected: AMF3,
-		},
-		{
-			name:     "Nil request",
-			request:  nil,
-			expected: AMF3,
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			version := DetectAMFVersion(tt.request)
-			if version != tt.expected {
-				t.Errorf("DetectAMFVersion() = %v, want %v", version, tt.expected)
-			}
-		})
-	}
-}
-
 func TestSendAMF(t *testing.T) {
 	tests := []struct {
 		name         string
@@ -227,8 +163,7 @@ func TestSendAMF(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			// First test if the encoder can handle the data
 			encoder := NewAMFEncoder(nil)
-			version := DetectAMFVersion(tt.request)
-			_, encodeErr := encoder.EncodeAMF(tt.data, version)
+			_, encodeErr := encoder.EncodeAMF(tt.data)
 			if encodeErr != nil {
 				t.Fatalf("Encoding failed: %v", encodeErr)
 			}
@@ -374,7 +309,7 @@ func BenchmarkAMFEncoding(b *testing.B) {
 
 	b.Run("AMF3", func(b *testing.B) {
 		for i := 0; i < b.N; i++ {
-			_, _ = encoder.EncodeAMF(data, AMF3)
+			_, _ = encoder.EncodeAMF(data)
 		}
 	})
 }
@@ -463,14 +398,14 @@ func TestAMFErrorEnvelopeCarriesData(t *testing.T) {
 func TestAMFEncoderPointerEventData(t *testing.T) {
 	encoder := NewAMFEncoder(nil)
 
-	event := ConvertEventForAMF3(types.Event{
-		Type:      types.EventTypeBuddyList,
+	event := ConvertEventForAMF3(Event{
+		Type:      EventTypeBuddyList,
 		SeqNum:    1,
 		Timestamp: 1787277769,
 		Data: &BuddyListData{
-			Groups: []WebAPIBuddyGroup{{
+			Groups: []BuddyGroup{{
 				Name:    "Friends",
-				Buddies: []WebAPIBuddyInfo{{AimID: "mk6i"}},
+				Buddies: []BuddyInfo{{AimID: "mk6i"}},
 			}},
 		},
 	})
@@ -478,7 +413,7 @@ func TestAMFEncoderPointerEventData(t *testing.T) {
 	encoded, err := encoder.EncodeAMF(map[string]interface{}{
 		"events":     []interface{}{event},
 		"lastSeqNum": 1,
-	}, AMF3)
+	})
 	if err != nil {
 		t.Fatalf("EncodeAMF() error = %v", err)
 	}
@@ -524,7 +459,7 @@ func TestAMFEncoderNilPointerEventData(t *testing.T) {
 	encoded, err := encoder.EncodeAMF(map[string]interface{}{
 		"eventData":  (*BuddyListData)(nil),
 		"lastSeqNum": 2,
-	}, AMF3)
+	})
 	if err != nil {
 		t.Fatalf("EncodeAMF() error = %v", err)
 	}
@@ -537,3 +472,58 @@ func TestAMFEncoderNilPointerEventData(t *testing.T) {
 		t.Errorf("lastSeqNum = %v, want 2", got)
 	}
 }
+
+// The AMF3 converter re-flattens PresenceEvent through an explicit allowlist, so a
+// field absent from that allowlist never reaches an AMF3 client. buddyIcon must be
+// on it.
+func TestConvertEventForAMF3_PresenceCarriesBuddyIcon(t *testing.T) {
+	t.Run("buddyIcon is included when set", func(t *testing.T) {
+		out := ConvertEventForAMF3(Event{
+			Type: EventTypePresence,
+			Data: PresenceEvent{
+				AimID:     "mikekelly",
+				State:     "online",
+				UserType:  "aim",
+				BuddyIcon: "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=dead",
+			},
+		})
+
+		eventData := out["eventData"].(map[string]interface{})
+		assert.Equal(t,
+			"http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=dead",
+			eventData["buddyIcon"])
+	})
+
+	t.Run("buddyIcon is omitted when empty", func(t *testing.T) {
+		out := ConvertEventForAMF3(Event{
+			Type: EventTypePresence,
+			Data: PresenceEvent{AimID: "mikekelly", State: "offline", UserType: "aim"},
+		})
+
+		eventData := out["eventData"].(map[string]interface{})
+		_, ok := eventData["buddyIcon"]
+		assert.False(t, ok)
+	})
+}
+
+// The AMF3 converter flattens OfflineIMEvent through an explicit allowlist. The
+// client keys its conversation list and chat-log cache by msgId, so an event that
+// loses it collides with every other offline message.
+func TestConvertEventForAMF3_OfflineIM(t *testing.T) {
+	out := ConvertEventForAMF3(Event{
+		Type: EventTypeOfflineIM,
+		Data: OfflineIMEvent{
+			AimID:     "mikekelly",
+			Message:   "sent while you were out",
+			MsgID:     "beefcafe",
+			Timestamp: 1700000000,
+		},
+	})
+
+	eventData := out["eventData"].(map[string]interface{})
+	assert.Equal(t, "mikekelly", eventData["aimId"])
+	assert.Equal(t, "sent while you were out", eventData["message"])
+	assert.Equal(t, "beefcafe", eventData["msgId"])
+	assert.Equal(t, float64(1700000000), eventData["timestamp"])
+	assert.Equal(t, false, eventData["autoresponse"])
+}

+ 191 - 33
server/webapi/handlers/auth.go → server/webapi/auth_handler.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"context"
@@ -6,8 +6,10 @@ import (
 	"encoding/base64"
 	"errors"
 	"fmt"
+	"html/template"
 	"log/slog"
 	"math"
+	"net"
 	"net/http"
 	"net/url"
 	"strconv"
@@ -65,11 +67,6 @@ type AuthHandler struct {
 	Logger      *slog.Logger
 }
 
-type OServiceService interface {
-	ClientOnline(ctx context.Context, service uint16, inBody wire.SNAC_0x01_0x02_OServiceClientOnline, instance *state.SessionInstance) error
-	RateParamsSubAdd(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd)
-}
-
 // 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) {
@@ -93,17 +90,13 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = &GetTokenData{
+	SendOK(w, r, &GetTokenData{
 		Token: AuthToken{
 			A:         base64.URLEncoding.EncodeToString(authCookie),
 			ExpiresIn: strconv.Itoa(int(math.Round(time.Until(expiry).Seconds()))),
 		},
 		UserData: UserData{Attributes: UserAttributes{LoginID: string(loginID)}},
-	}
-	SendResponse(w, r, resp, h.Logger)
+	}, h.Logger)
 
 	h.Logger.InfoContext(ctx, "getToken succeeded", "loginId", loginID, "devId", devID)
 }
@@ -130,19 +123,6 @@ func (h *AuthHandler) resolveGetTokenSession(r *http.Request) (state.DisplayScre
 	return serverCookie.ScreenName, rawCookie, expiry, true
 }
 
-// Web API status codes, which a client reads from the envelope rather than from
-// the HTTP status. A failed sign-in is a demand for better credentials, not an
-// error: statusMoreAuthRequired plus the detail code naming what was wrong is
-// what tells a client to say "incorrect password".
-const (
-	statusMoreAuthRequired = 330
-	statusMissingParameter = 460
-	// statusParameterError is for a parameter that is present but unusable
-	statusParameterError = 462
-
-	detailBadPassword = 3011
-)
-
 // The lifetimes the clientLogin tokenType parameter names.
 const (
 	shortTermTTL = 24 * time.Hour
@@ -307,10 +287,9 @@ func (h *AuthHandler) ClientLogin(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// Build response
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = &ClientLoginData{
+
+	// Send response in requested format (JSON, JSONP, XML, or AMF)
+	SendOK(w, r, &ClientLoginData{
 		Token: AuthToken{
 			A:         base64.URLEncoding.EncodeToString(authCookie),
 			ExpiresIn: strconv.Itoa(int(ttl.Seconds())),
@@ -321,10 +300,7 @@ func (h *AuthHandler) ClientLogin(w http.ResponseWriter, r *http.Request) {
 		HostTime:      time.Now().Unix(),
 		// A number here where token.expiresIn is a string, as the client expects.
 		TokenExpiresIn: int(ttl.Seconds()),
-	}
-
-	// Send response in requested format (JSON, JSONP, XML, or AMF)
-	SendResponse(w, r, resp, h.Logger)
+	}, h.Logger)
 
 	h.Logger.Info("user authenticated successfully",
 		"username", username,
@@ -340,3 +316,185 @@ func (h *AuthHandler) generateToken() (string, error) {
 	}
 	return base64.URLEncoding.EncodeToString(b), nil
 }
+
+// bosTokenCookie is the cookie the browser presents to getToken. The name is the
+// one AIM's own client knows, kept so a client running against the non-Web API
+// path finds what it expects.
+const bosTokenCookie = "oldAimToken"
+
+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
+		}
+
+		authCookie, err := h.authenticateCredentials(r.Context(), loginID, password, clientIDForDevID(data.DevID), shortTermTTL)
+		if err != nil {
+			if errors.Is(err, errInvalidCredentials) {
+				h.Logger.DebugContext(r.Context(), "login.psp failed", "loginId", loginID)
+				data.Error = "Invalid screen name or password."
+				h.renderLoginPSP(w, r, data)
+				return
+			}
+			h.Logger.ErrorContext(r.Context(), "login.psp could not authenticate", "loginId", loginID, "error", err)
+			http.Error(w, "internal server error", http.StatusInternalServerError)
+			return
+		}
+
+		setBOSTokenCookie(w, authCookie)
+
+		redirectURL := safeLoginRedirectURL(r, data.SuccURL)
+		h.Logger.InfoContext(r.Context(), "login.psp succeeded", "loginId", loginID, "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)
+	}
+}
+
+// setBOSTokenCookie hands the BOS token from the login response to the browser.
+func setBOSTokenCookie(w http.ResponseWriter, authCookie []byte) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     bosTokenCookie,
+		Value:    base64.URLEncoding.EncodeToString(authCookie),
+		Path:     "/",
+		Expires:  time.Now().Add(shortTermTTL),
+		MaxAge:   int(shortTermTTL.Seconds()),
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+	})
+}
+
+// clearBOSTokenCookie expires the token cookie. getToken calls it on every
+// request, spending the token whether or not it was any good, so a reload finds
+// nothing to sign in with.
+func clearBOSTokenCookie(w http.ResponseWriter) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     bosTokenCookie,
+		Value:    "",
+		Path:     "/",
+		Expires:  time.Unix(0, 0),
+		MaxAge:   -1,
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+	})
+}
+
+func defaultLoginSuccURL(r *http.Request) string {
+	return requestScheme(r) + "://" + 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
+}

+ 181 - 1
server/webapi/handlers/auth_test.go → server/webapi/auth_handler_test.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"context"
@@ -7,6 +7,7 @@ import (
 	"log/slog"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"strings"
 	"testing"
 	"time"
@@ -660,3 +661,182 @@ func TestTokenTypeTTL(t *testing.T) {
 		})
 	}
 }
+
+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_Logout(t *testing.T) {
+	handler := &AuthHandler{Logger: slog.Default()}
+
+	req := httptest.NewRequest(http.MethodGet, "/auth/logout?f=json&a=sometoken&devId=dev1&succUrl=http%3A%2F%2Flocalhost%3A8000%2F.client%2F", nil)
+	rr := httptest.NewRecorder()
+
+	handler.Logout(rr, req)
+
+	assert.Equal(t, http.StatusFound, rr.Code)
+
+	loc, err := url.Parse(rr.Header().Get("Location"))
+	assert.NoError(t, err)
+	assert.Equal(t, "/_cqr/login/login.psp", loc.Path)
+	assert.Equal(t, "dev1", loc.Query().Get("devId"))
+	assert.Equal(t, "http://localhost:8000/.client/", loc.Query().Get("succUrl"))
+
+	// Signing out spends the token cookie, whether or not getToken already did.
+	// A 24h token left behind would sign the next person in as this account.
+	cleared := rr.Result().Cookies()
+	if assert.Len(t, cleared, 1) {
+		assert.Equal(t, bosTokenCookie, cleared[0].Name)
+		assert.Empty(t, cleared[0].Value)
+		assert.Less(t, cleared[0].MaxAge, 1)
+	}
+}
+
+func TestAuthHandler_LoginPSP_POST_Success(t *testing.T) {
+	var got wire.FLAPSignonFrame
+	handler := &AuthHandler{
+		AuthService: &testAuthService{
+			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
+				got = inFrame
+				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"))
+
+	set := make(map[string]*http.Cookie)
+	for _, c := range rr.Result().Cookies() {
+		set[c.Name] = c
+	}
+
+	// The cookie carries the BOS token from the login response, unchanged.
+	tokenCookie := set[bosTokenCookie]
+	if assert.NotNil(t, tokenCookie) {
+		assert.True(t, tokenCookie.HttpOnly)
+		raw, err := base64.URLEncoding.DecodeString(tokenCookie.Value)
+		assert.NoError(t, err)
+		assert.Equal(t, loginBlockCookie, raw)
+		// The browser drops it on the same schedule the server stops honouring it.
+		assert.Equal(t, 86400, tokenCookie.MaxAge)
+	}
+
+	for _, name := range []string{"RSP_USER", "RSP_LOCAL", "localAuthUser"} {
+		assert.NotContains(t, set, name)
+	}
+
+	// The Web API asks login for a token that outlives the browser round trip.
+	ttl, ok := got.Uint32BE(wire.LoginTLVTagsTokenTTL)
+	assert.True(t, ok)
+	assert.Equal(t, uint32(86400), ttl)
+
+	// The devId names the client on the resulting session.
+	clientID, ok := got.String(wire.LoginTLVTagsClientIdentity)
+	assert.True(t, ok, "signon frame should carry a client identity")
+	assert.Equal(t, "dev1", clientID)
+}
+
+func TestAuthHandler_LoginPSP_POST_ServiceErrors(t *testing.T) {
+	tests := []struct {
+		name      string
+		flapLogin func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error)
+	}{
+		{
+			name: "LoginResponseHasNoCookie",
+			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
+				return blockWithoutCookie(), nil
+			},
+		},
+		{
+			name: "AuthServiceUnreachable",
+			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
+				return wire.TLVRestBlock{}, errors.New("boom")
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			handler := &AuthHandler{
+				AuthService: &testAuthService{flapLogin: tt.flapLogin},
+				Logger:      slog.Default(),
+			}
+
+			form := url.Values{}
+			form.Set("loginId", "testuser")
+			form.Set("password", "secret")
+			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)
+
+			// A broken auth service must not read as a mistyped password.
+			assert.Equal(t, http.StatusInternalServerError, rr.Code)
+			assert.NotContains(t, rr.Body.String(), "Invalid screen name or password")
+			assert.Empty(t, rr.Result().Cookies())
+		})
+	}
+}
+
+func TestAuthHandler_LoginPSP_POST_InvalidCredentials(t *testing.T) {
+	handler := &AuthHandler{
+		AuthService: &testAuthService{
+			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (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 TestDefaultLoginSuccURL(t *testing.T) {
+	req := httptest.NewRequest(http.MethodGet, "http://ras.dev/_cqr/login/login.psp", nil)
+	assert.Equal(t, "http://ras.dev/", defaultLoginSuccURL(req))
+
+	// TLS terminated upstream, so the scheme only survives in the header.
+	req.Header.Set("X-Forwarded-Proto", "https")
+	assert.Equal(t, "https://ras.dev/", defaultLoginSuccURL(req))
+}
+
+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/"))
+}

+ 87 - 19
server/webapi/handlers/buddy_list_manager.go → server/webapi/buddy_list_manager.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"context"
@@ -32,19 +32,19 @@ func NewBuddyListManager(feedbagService FeedbagService, locateService LocateServ
 	}
 }
 
-// WebAPIBuddyGroup represents a group in the WebAPI buddy list format.
-type WebAPIBuddyGroup struct {
-	Name    string            `json:"name" xml:"name"`
-	Buddies []WebAPIBuddyInfo `json:"buddies" xml:"buddies>buddy"`
-	Recent  bool              `json:"recent,omitempty" xml:"recent,omitempty"`
+// BuddyGroup represents a group in the WebAPI buddy list format.
+type BuddyGroup struct {
+	Name    string      `json:"name" xml:"name"`
+	Buddies []BuddyInfo `json:"buddies" xml:"buddies>buddy"`
+	Recent  bool        `json:"recent,omitempty" xml:"recent,omitempty"`
 	// Smart is null or a number. It is a pointer rather than an interface so
 	// encoding/xml can render it: a non-nil interface holding a map cannot be
 	// marshalled, while a nil pointer is simply omitted.
 	Smart *int `json:"smart,omitempty" xml:"smart,omitempty"`
 }
 
-// WebAPIBuddyInfo represents a buddy in the WebAPI format.
-type WebAPIBuddyInfo struct {
+// BuddyInfo represents a buddy in the WebAPI format.
+type BuddyInfo struct {
 	AimID        string   `json:"aimId" xml:"aimId"`
 	DisplayID    string   `json:"displayId" xml:"displayId"`
 	Friendly     string   `json:"friendly,omitempty" xml:"friendly,omitempty"` // Viewer's private alias, rendered in preference to DisplayID
@@ -63,7 +63,7 @@ type WebAPIBuddyInfo struct {
 }
 
 // GetBuddyListForUser retrieves and converts the buddy list for a user.
-func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, sess *state.WebAPISession) ([]WebAPIBuddyGroup, error) {
+func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, sess *Session) ([]BuddyGroup, error) {
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
 	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
 	if err != nil {
@@ -143,7 +143,7 @@ func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, sess *state.
 		slices.Sort(groupOrder)
 	}
 
-	var out []WebAPIBuddyGroup
+	var out []BuddyGroup
 	for _, gid := range groupOrder {
 		g, ok := bl.groups[gid]
 		if !ok {
@@ -153,7 +153,7 @@ func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, sess *state.
 		if groupName == "" {
 			groupName = "Buddies"
 		}
-		wg := WebAPIBuddyGroup{Name: groupName, Buddies: []WebAPIBuddyInfo{}}
+		wg := BuddyGroup{Name: groupName, Buddies: []BuddyInfo{}}
 		for _, bid := range g.order {
 			b, ok := g.buddies[bid]
 			if !ok {
@@ -174,7 +174,7 @@ func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, sess *state.
 
 // getBuddyInfo retrieves a buddy's current presence by issuing a locate
 // UserInfoQuery on behalf of the requesting session's OSCAR instance.
-func (m *BuddyListManager) getBuddyInfo(ctx context.Context, instance *state.SessionInstance, baseURL string, buddyName string) WebAPIBuddyInfo {
+func (m *BuddyListManager) getBuddyInfo(ctx context.Context, instance *state.SessionInstance, baseURL string, buddyName string) BuddyInfo {
 	// Default to offline. The web client keys users by the normalized aimId and
 	// shallow-merges each buddy map onto the shared user object, so a display-form
 	// aimId here overwrites the id every other event is keyed by.
@@ -183,7 +183,7 @@ func (m *BuddyListManager) getBuddyInfo(ctx context.Context, instance *state.Ses
 	// display names. DisplayID is filled in from the locate reply below when the
 	// buddy is online, or overridden by the caller's alias when one is set.
 	ident := state.NewIdentScreenName(buddyName)
-	info := WebAPIBuddyInfo{
+	info := BuddyInfo{
 		AimID:     ident.String(),
 		DisplayID: buddyName,
 		State:     "offline",
@@ -243,7 +243,7 @@ func (m *BuddyListManager) getBuddyInfo(ctx context.Context, instance *state.Ses
 }
 
 // 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) {
+func (m *BuddyListManager) RemoveBuddyFromFeedbag(ctx context.Context, sess *Session, buddyName, groupName string, allGroups bool) (resultCode string, err error) {
 	// Buddy items carry the owner's alias for the buddy, and the feedbag service
 	// relays a session's own writes only to the owner's other instances, so every
 	// method here that rewrites buddy items has to drop the alias cache itself.
@@ -302,7 +302,7 @@ func (m *BuddyListManager) RemoveBuddyFromFeedbag(ctx context.Context, sess *sta
 }
 
 // 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) {
+func (m *BuddyListManager) RemoveGroupFromFeedbag(ctx context.Context, sess *Session, requestedGroup string) (resultCode string, err error) {
 	defer sess.InvalidateAliases()
 
 	req := strings.TrimSpace(requestedGroup)
@@ -351,7 +351,7 @@ func (m *BuddyListManager) RemoveGroupFromFeedbag(ctx context.Context, sess *sta
 }
 
 // RenameGroupInFeedbag renames a buddy group, updating the group item in place.
-func (m *BuddyListManager) RenameGroupInFeedbag(ctx context.Context, sess *state.WebAPISession, oldGroup, newGroup string) (resultCode string, err error) {
+func (m *BuddyListManager) RenameGroupInFeedbag(ctx context.Context, sess *Session, oldGroup, newGroup string) (resultCode string, err error) {
 	defer sess.InvalidateAliases()
 
 	oldGroup = strings.TrimSpace(oldGroup)
@@ -401,7 +401,7 @@ func (m *BuddyListManager) RenameGroupInFeedbag(ctx context.Context, sess *state
 
 // MoveBuddyInFeedbag moves a buddy to a different group and/or repositions it
 // within a group's order.
-func (m *BuddyListManager) MoveBuddyInFeedbag(ctx context.Context, sess *state.WebAPISession, buddyName, fromGroup, toGroup, beforeBuddy string) (resultCode string, err error) {
+func (m *BuddyListManager) MoveBuddyInFeedbag(ctx context.Context, sess *Session, buddyName, fromGroup, toGroup, beforeBuddy string) (resultCode string, err error) {
 	defer sess.InvalidateAliases()
 
 	buddyName = strings.TrimSpace(buddyName)
@@ -482,7 +482,7 @@ func (m *BuddyListManager) MoveBuddyInFeedbag(ctx context.Context, sess *state.W
 
 // SetBuddyAttributeInFeedbag sets a buddy's friendly (alias) name across all
 // groups it belongs to. An empty friendly clears the alias.
-func (m *BuddyListManager) SetBuddyAttributeInFeedbag(ctx context.Context, sess *state.WebAPISession, buddyName, friendly string) (resultCode string, err error) {
+func (m *BuddyListManager) SetBuddyAttributeInFeedbag(ctx context.Context, sess *Session, buddyName, friendly string) (resultCode string, err error) {
 	defer sess.InvalidateAliases()
 
 	buddyName = strings.TrimSpace(buddyName)
@@ -523,7 +523,7 @@ func (m *BuddyListManager) SetBuddyAttributeInFeedbag(ctx context.Context, sess
 
 // SetGroupAttributeInFeedbag sets a group's collapsed state. An empty group
 // targets the unnamed default group.
-func (m *BuddyListManager) SetGroupAttributeInFeedbag(ctx context.Context, sess *state.WebAPISession, groupName string, collapsed bool) (resultCode string, err error) {
+func (m *BuddyListManager) SetGroupAttributeInFeedbag(ctx context.Context, sess *Session, groupName string, collapsed bool) (resultCode string, err error) {
 	defer sess.InvalidateAliases()
 
 	groupName = strings.TrimSpace(groupName)
@@ -568,3 +568,71 @@ func (m *BuddyListManager) SetGroupAttributeInFeedbag(ctx context.Context, sess
 
 	return "success", nil
 }
+
+// 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
+}
+
+// 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
+}
+
+// FeedbagAliases collects the aliases the feedbag owner has assigned to their
+// buddies, keyed by normalized screen name. Buddies without an alias are absent.
+func FeedbagAliases(items []wire.FeedbagItem) map[string]string {
+	aliases := make(map[string]string)
+	for _, item := range items {
+		if item.ClassID != wire.FeedbagClassIdBuddy || item.Name == "" {
+			continue
+		}
+		alias, ok := item.String(wire.FeedbagAttributesAlias)
+		if !ok || alias == "" {
+			continue
+		}
+		aliases[state.NewIdentScreenName(item.Name).String()] = alias
+	}
+	return aliases
+}
+
+// LookupBuddyAliases returns the aliases the session owner has assigned to their
+// buddies, keyed by normalized screen name.
+//
+// Aliases are private to the viewer and live only in their feedbag, so they cannot
+// be derived from a locate reply the way display names are.
+func LookupBuddyAliases(ctx context.Context, feedbagService FeedbagService, instance *state.SessionInstance) (map[string]string, error) {
+	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
+	snac, err := feedbagService.Query(ctx, instance, frame)
+	if err != nil {
+		return nil, err
+	}
+	reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+	if !ok {
+		return nil, fmt.Errorf("unexpected feedbag reply type")
+	}
+	return FeedbagAliases(reply.Items), nil
+}

+ 77 - 61
server/webapi/handlers/buddy_list_manager_test.go → server/webapi/buddy_list_manager_test.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"context"
@@ -14,8 +14,8 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-func offlineWebAPIBuddy(aimID, displayID string) WebAPIBuddyInfo {
-	return WebAPIBuddyInfo{
+func offlineWebAPIBuddy(aimID, displayID string) BuddyInfo {
+	return BuddyInfo{
 		AimID:     aimID,
 		DisplayID: displayID,
 		State:     "offline",
@@ -27,7 +27,7 @@ func offlineWebAPIBuddy(aimID, displayID string) WebAPIBuddyInfo {
 
 // withAlias sets the viewer's private name for a buddy. It travels in friendly, not
 // displayId, which keeps carrying the buddy's own screen name.
-func withAlias(b WebAPIBuddyInfo, alias string) WebAPIBuddyInfo {
+func withAlias(b BuddyInfo, alias string) BuddyInfo {
 	b.Friendly = alias
 	return b
 }
@@ -40,7 +40,7 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 		name    string
 		fb      []wire.FeedbagItem
 		fbErr   error
-		want    []WebAPIBuddyGroup
+		want    []BuddyGroup
 		wantErr string
 	}{
 		{
@@ -77,10 +77,10 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 				{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{
+			want: []BuddyGroup{
 				{
 					Name: "Buddies",
-					Buddies: []WebAPIBuddyInfo{
+					Buddies: []BuddyInfo{
 						offlineWebAPIBuddy("user1", "user1"),
 						offlineWebAPIBuddy("user2", "user2"),
 					},
@@ -117,12 +117,12 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesAlias, "Bob Smith")}},
 				},
 			},
-			want: []WebAPIBuddyGroup{
+			want: []BuddyGroup{
 				{
 					Name: "Buddies",
 					// The buddy is offline, so no locate reply supplies a display
 					// name and displayId falls back to the normalized feedbag name.
-					Buddies: []WebAPIBuddyInfo{withAlias(offlineWebAPIBuddy("bob", "bob"), "Bob Smith")},
+					Buddies: []BuddyInfo{withAlias(offlineWebAPIBuddy("bob", "bob"), "Bob Smith")},
 				},
 			},
 		},
@@ -137,10 +137,10 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
 				{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "Mike Kelly"},
 			},
-			want: []WebAPIBuddyGroup{
+			want: []BuddyGroup{
 				{
 					Name:    "Buddies",
-					Buddies: []WebAPIBuddyInfo{offlineWebAPIBuddy("mikekelly", "Mike Kelly")},
+					Buddies: []BuddyInfo{offlineWebAPIBuddy("mikekelly", "Mike Kelly")},
 				},
 			},
 		},
@@ -158,10 +158,10 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 					TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesNote, "Friend from work")}},
 				},
 			},
-			want: []WebAPIBuddyGroup{
+			want: []BuddyGroup{
 				{
 					Name:    "Buddies",
-					Buddies: []WebAPIBuddyInfo{offlineWebAPIBuddy("alice", "alice")},
+					Buddies: []BuddyInfo{offlineWebAPIBuddy("alice", "alice")},
 				},
 			},
 		},
@@ -179,14 +179,14 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 				{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{
+			want: []BuddyGroup{
 				{
 					Name:    "Buddies",
-					Buddies: []WebAPIBuddyInfo{offlineWebAPIBuddy("friend1", "friend1")},
+					Buddies: []BuddyInfo{offlineWebAPIBuddy("friend1", "friend1")},
 				},
 				{
 					Name:    "Family",
-					Buddies: []WebAPIBuddyInfo{offlineWebAPIBuddy("mom", "mom")},
+					Buddies: []BuddyInfo{offlineWebAPIBuddy("mom", "mom")},
 				},
 			},
 		},
@@ -202,10 +202,10 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 				{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{
+			want: []BuddyGroup{
 				{
 					Name: "Buddies",
-					Buddies: []WebAPIBuddyInfo{
+					Buddies: []BuddyInfo{
 						offlineWebAPIBuddy("secondinslice", "secondInSlice"),
 						offlineWebAPIBuddy("firstinslice", "firstInSlice"),
 					},
@@ -226,14 +226,14 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 				{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{
+			want: []BuddyGroup{
 				{
 					Name:    "Family",
-					Buddies: []WebAPIBuddyInfo{offlineWebAPIBuddy("infamily", "inFamily")},
+					Buddies: []BuddyInfo{offlineWebAPIBuddy("infamily", "inFamily")},
 				},
 				{
 					Name:    "Buddies",
-					Buddies: []WebAPIBuddyInfo{offlineWebAPIBuddy("inbuddies", "inBuddies")},
+					Buddies: []BuddyInfo{offlineWebAPIBuddy("inbuddies", "inBuddies")},
 				},
 			},
 		},
@@ -248,10 +248,10 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 					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{
+			want: []BuddyGroup{
 				{
 					Name:    "Buddies",
-					Buddies: []WebAPIBuddyInfo{offlineWebAPIBuddy("solo", "solo")},
+					Buddies: []BuddyInfo{offlineWebAPIBuddy("solo", "solo")},
 				},
 			},
 		},
@@ -259,22 +259,22 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			fs := &MockFeedbagService{}
+			fs := newMockFeedbagService(t)
 			// The locate query returns an error, so every buddy resolves to
 			// offline. This keeps the focus on feedbag -> group conversion.
-			ls := &MockLocateService{}
-			ls.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+			ls := newMockLocateService(t)
+			ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
 				Return(wire.SNACMessage{}, errors.New("offline")).Maybe()
 			if tt.fbErr != nil {
-				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).Return(wire.SNACMessage{}, tt.fbErr).Once()
+				fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(wire.SNACMessage{}, tt.fbErr).Once()
 			} else {
-				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).Return(
+				fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
 					wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: tt.fb}}, nil,
 				).Once()
 			}
 
-			m := NewBuddyListManager(fs, ls, newTestIconSource(), slog.Default())
-			sess := &state.WebAPISession{
+			m := NewBuddyListManager(fs, ls, newTestIconSource(t), slog.Default())
+			sess := &Session{
 				ScreenName:   state.DisplayScreenName(owner.String()),
 				OSCARSession: state.NewSession().AddInstance(),
 			}
@@ -283,13 +283,10 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 			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)
-			ls.AssertExpectations(t)
 		})
 	}
 }
@@ -309,20 +306,20 @@ func TestBuddyListManager_GetBuddyListForUser_DisplayIDFromLocateReply(t *testin
 		{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "mikekelly"},
 	}
 
-	fs := &MockFeedbagService{}
-	fs.On("Query", mock.Anything, mock.Anything, mock.Anything).Return(
+	fs := newMockFeedbagService(t)
+	fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
 		wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
 	).Once()
 
-	ls := &MockLocateService{}
-	ls.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(
+	ls := newMockLocateService(t)
+	ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(
 		wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
 			TLVUserInfo: wire.TLVUserInfo{ScreenName: "Mike Kelly"},
 		}}, nil,
 	).Once()
 
-	m := NewBuddyListManager(fs, ls, newTestIconSource(), slog.Default())
-	sess := &state.WebAPISession{
+	m := NewBuddyListManager(fs, ls, newTestIconSource(t), slog.Default())
+	sess := &Session{
 		ScreenName:   state.DisplayScreenName("listowner"),
 		OSCARSession: state.NewSession().AddInstance(),
 	}
@@ -334,9 +331,6 @@ func TestBuddyListManager_GetBuddyListForUser_DisplayIDFromLocateReply(t *testin
 	assert.Equal(t, "mikekelly", got[0].Buddies[0].AimID)
 	assert.Equal(t, "Mike Kelly", got[0].Buddies[0].DisplayID)
 	assert.Equal(t, "online", got[0].Buddies[0].State)
-
-	fs.AssertExpectations(t)
-	ls.AssertExpectations(t)
 }
 
 // Icons are published only for online, non-blocking buddies: an online buddy with
@@ -356,8 +350,8 @@ func TestBuddyListManager_GetBuddyListForUser_PublishesBuddyIcons(t *testing.T)
 		{ItemID: 3, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "onlinenoicon"},
 	}
 
-	fs := &MockFeedbagService{}
-	fs.On("Query", mock.Anything, mock.Anything, mock.Anything).Return(
+	fs := newMockFeedbagService(t)
+	fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
 		wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
 	).Once()
 
@@ -370,18 +364,18 @@ func TestBuddyListManager_GetBuddyListForUser_PublishesBuddyIcons(t *testing.T)
 		return mock.MatchedBy(func(q wire.SNAC_0x02_0x05_LocateUserInfoQuery) bool { return q.ScreenName == name })
 	}
 
-	ls := &MockLocateService{}
-	ls.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, locateFor("onlineicon")).
+	ls := newMockLocateService(t)
+	ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, locateFor("onlineicon")).
 		Return(online("onlineicon"), nil).Once()
-	ls.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, locateFor("onlinenoicon")).
+	ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, locateFor("onlinenoicon")).
 		Return(online("onlinenoicon"), nil).Once()
-	ls.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, locateFor("offlinebud")).
+	ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, locateFor("offlinebud")).
 		Return(wire.SNACMessage{}, errors.New("offline")).Once()
 
-	iconRetriever := &MockBuddyIconRetriever{}
-	iconRetriever.On("BuddyIconMetadata", mock.Anything, state.NewIdentScreenName("onlineicon")).
+	iconRetriever := newMockBuddyIconRetriever(t)
+	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("onlineicon")).
 		Return(bartID([]byte{0xab, 0xcd}), nil).Once()
-	iconRetriever.On("BuddyIconMetadata", mock.Anything, state.NewIdentScreenName("onlinenoicon")).
+	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("onlinenoicon")).
 		Return(nil, nil).Once()
 
 	m := NewBuddyListManager(fs, ls, BuddyIconSource{
@@ -389,7 +383,7 @@ func TestBuddyListManager_GetBuddyListForUser_PublishesBuddyIcons(t *testing.T)
 		Logger:        slog.Default(),
 	}, slog.Default())
 
-	sess := &state.WebAPISession{
+	sess := &Session{
 		ScreenName:   state.DisplayScreenName("listowner"),
 		OSCARSession: state.NewSession().AddInstance(),
 		BaseURL:      "http://api.example.com",
@@ -415,8 +409,6 @@ func TestBuddyListManager_GetBuddyListForUser_PublishesBuddyIcons(t *testing.T)
 	assert.Equal(t,
 		"http://api.example.com/expressions/get?t=onlinenoicon&type=buddyIcon",
 		got[0].Buddies[2].BuddyIcon)
-
-	iconRetriever.AssertExpectations(t)
 }
 
 // The feedbag service relays a session's own writes only to the owner's other
@@ -438,19 +430,19 @@ func TestBuddyListManager_SetBuddyAttributeInFeedbag_InvalidatesAliasCache(t *te
 		}
 	}
 
-	fs := &MockFeedbagService{}
+	fs := newMockFeedbagService(t)
 	// Query 1: the alias cache loads. Query 2: SetBuddyAttributeInFeedbag reads the
 	// feedbag it is about to rewrite. Query 3: the cache reloads post-invalidation,
 	// now seeing the stored rename.
-	fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+	fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
 		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: feedbag("MICHAELKELLY")}}, nil).Twice()
-	fs.On("UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+	fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
 		Return(&wire.SNACMessage{}, nil).Once()
-	fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+	fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
 		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: feedbag("MIKE")}}, nil).Once()
 
-	m := NewBuddyListManager(fs, &MockLocateService{}, newTestIconSource(), slog.Default())
-	sess := &state.WebAPISession{
+	m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
+	sess := &Session{
 		ScreenName:   state.DisplayScreenName("listowner"),
 		OSCARSession: state.NewSession().AddInstance(),
 	}
@@ -465,5 +457,29 @@ func TestBuddyListManager_SetBuddyAttributeInFeedbag_InvalidatesAliasCache(t *te
 	require.Equal(t, "success", resultCode)
 
 	assert.Equal(t, "MIKE", sess.Aliases(ctx)["mikekelly"])
-	fs.AssertExpectations(t)
+}
+
+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 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)
+
+	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)
 }

+ 34 - 200
server/webapi/handlers/buddylist.go → server/webapi/buddylist_handler.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"context"
@@ -7,7 +7,6 @@ import (
 	"net/http"
 	"strings"
 
-	"github.com/mk6i/open-oscar-server/server/webapi/types"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
@@ -19,20 +18,8 @@ type BuddyListHandler struct {
 	FeedbagService   FeedbagService
 }
 
-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
-}
-
 // AddBuddy handles GET /buddylist/addBuddy requests.
-func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 	aimsid := r.URL.Query().Get("aimsid")
 
@@ -40,7 +27,7 @@ func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request, sess
 	groupName := strings.TrimSpace(r.URL.Query().Get("group"))
 
 	if buddyName == "" {
-		h.sendError(w, r, http.StatusBadRequest, "missing buddy parameter")
+		SendError(w, r, http.StatusBadRequest, "missing buddy parameter")
 		return
 	}
 
@@ -57,11 +44,7 @@ func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request, sess
 		responseData.BuddyInfo = buddyInfo
 	}
 
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = responseData
-	SendResponse(w, r, resp, h.Logger)
+	SendOK(w, r, responseData, h.Logger)
 
 	if resultCode == "success" {
 		groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
@@ -69,7 +52,7 @@ func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request, sess
 			h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
 		} else {
 			blPayload := &BuddyListData{Groups: groups}
-			session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
+			session.EventQueue.Push(EventTypeBuddyList, blPayload)
 		}
 	}
 
@@ -82,23 +65,19 @@ func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request, sess
 }
 
 // AddGroup handles GET /buddylist/addGroup requests.
-func (h *BuddyListHandler) AddGroup(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *BuddyListHandler) AddGroup(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 	aimsid := r.URL.Query().Get("aimsid")
 
 	groupName := strings.TrimSpace(r.URL.Query().Get("group"))
 	if groupName == "" {
-		h.sendError(w, r, http.StatusBadRequest, "missing group parameter")
+		SendError(w, r, 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 = &ResultCodeData{ResultCode: resultCode}
-	SendResponse(w, r, resp, h.Logger)
+	SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
 
 	if resultCode == "success" {
 		groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
@@ -106,7 +85,7 @@ func (h *BuddyListHandler) AddGroup(w http.ResponseWriter, r *http.Request, sess
 			h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
 		} else {
 			blPayload := &BuddyListData{Groups: groups}
-			session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
+			session.EventQueue.Push(EventTypeBuddyList, blPayload)
 		}
 	}
 
@@ -117,7 +96,7 @@ func (h *BuddyListHandler) AddGroup(w http.ResponseWriter, r *http.Request, sess
 	)
 }
 
-func (h *BuddyListHandler) addGroupToFeedbag(ctx context.Context, sess *state.WebAPISession, groupName string) string {
+func (h *BuddyListHandler) addGroupToFeedbag(ctx context.Context, sess *Session, groupName string) string {
 	// A session sees no SNAC for its own feedbag writes, so it drops the alias
 	// cache itself. See WebAPISession.InvalidateAliases.
 	defer sess.InvalidateAliases()
@@ -151,41 +130,8 @@ func (h *BuddyListHandler) addGroupToFeedbag(ctx context.Context, sess *state.We
 	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
-}
-
-// 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
-}
-
 // RemoveBuddy handles GET /buddylist/removeBuddy requests.
-func (h *BuddyListHandler) RemoveBuddy(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *BuddyListHandler) RemoveBuddy(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 	aimsid := r.URL.Query().Get("aimsid")
 
@@ -194,7 +140,7 @@ func (h *BuddyListHandler) RemoveBuddy(w http.ResponseWriter, r *http.Request, s
 	allGroupsParam := r.URL.Query().Get("allGroups")
 	allGroups := allGroupsParam == "true" || allGroupsParam == "1"
 	if buddyName == "" {
-		h.sendError(w, r, http.StatusBadRequest, "missing buddy parameter")
+		SendError(w, r, http.StatusBadRequest, "missing buddy parameter")
 		return
 	}
 
@@ -203,11 +149,7 @@ func (h *BuddyListHandler) RemoveBuddy(w http.ResponseWriter, r *http.Request, s
 		h.Logger.ErrorContext(ctx, "remove buddy failed", "err", rmErr.Error())
 	}
 
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = &ResultCodeData{ResultCode: resultCode}
-	SendResponse(w, r, resp, h.Logger)
+	SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
 
 	if resultCode == "success" {
 		groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
@@ -215,7 +157,7 @@ func (h *BuddyListHandler) RemoveBuddy(w http.ResponseWriter, r *http.Request, s
 			h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
 		} else {
 			blPayload := &BuddyListData{Groups: groups}
-			session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
+			session.EventQueue.Push(EventTypeBuddyList, blPayload)
 		}
 	}
 
@@ -229,13 +171,13 @@ func (h *BuddyListHandler) RemoveBuddy(w http.ResponseWriter, r *http.Request, s
 
 // 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) {
+func (h *BuddyListHandler) RemoveGroup(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 	aimsid := r.URL.Query().Get("aimsid")
 
 	groupName := strings.TrimSpace(r.URL.Query().Get("group"))
 	if groupName == "" {
-		h.sendError(w, r, http.StatusBadRequest, "missing group parameter")
+		SendError(w, r, http.StatusBadRequest, "missing group parameter")
 		return
 	}
 
@@ -244,11 +186,7 @@ func (h *BuddyListHandler) RemoveGroup(w http.ResponseWriter, r *http.Request, s
 		h.Logger.ErrorContext(ctx, "remove group failed", "err", rmErr.Error())
 	}
 
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = &ResultCodeData{ResultCode: resultCode}
-	SendResponse(w, r, resp, h.Logger)
+	SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
 
 	if resultCode == "success" {
 		groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
@@ -256,7 +194,7 @@ func (h *BuddyListHandler) RemoveGroup(w http.ResponseWriter, r *http.Request, s
 			h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
 		} else {
 			blPayload := &BuddyListData{Groups: groups}
-			session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
+			session.EventQueue.Push(EventTypeBuddyList, blPayload)
 		}
 	}
 
@@ -268,7 +206,7 @@ func (h *BuddyListHandler) RemoveGroup(w http.ResponseWriter, r *http.Request, s
 }
 
 // addBuddyToFeedbag adds a buddy to the user's feedbag.
-func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, sess *state.WebAPISession, buddyName, groupName string) (string, *BuddyPresenceInfo) {
+func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, sess *Session, buddyName, groupName string) (string, *BuddyPresenceInfo) {
 	defer sess.InvalidateAliases()
 
 	// Retrieve current feedbag
@@ -349,102 +287,19 @@ func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, sess *state.We
 	return "success", buddyInfo
 }
 
-// AddTempBuddy handles GET /aim/addTempBuddy requests.
-// 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.
-func (h *BuddyListHandler) AddTempBuddy(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, r, http.StatusBadRequest, "missing buddy names (t parameter)")
-		return
-	}
-
-	// Store temporary buddies in the session
-	// Note: These are not persisted to the feedbag database
-	if session.TempBuddies == nil {
-		session.TempBuddies = make(map[string]bool)
-	}
-
-	for _, buddyName := range buddyNames {
-		buddyName = strings.TrimSpace(buddyName)
-		if buddyName != "" {
-			session.TempBuddies[buddyName] = true
-		}
-	}
-
-	// Prepare response
-	responseData := &ResultCodeData{ResultCode: "success", BuddyNames: buddyNames}
-
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = responseData
-	SendResponse(w, r, resp, h.Logger)
-
-	// 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",
-		"aimsid", aimsid,
-		"buddies", buddyNames,
-		"count", len(buddyNames),
-	)
-}
-
-// 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, r, 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 = &ResultCodeData{ResultCode: "success", BuddyNames: removed}
-	SendResponse(w, r, resp, h.Logger)
-
-	h.Logger.InfoContext(ctx, "temporary buddies removed",
-		"aimsid", aimsid,
-		"buddies", removed,
-		"count", len(removed),
-	)
-}
-
 // RenameGroup handles GET /buddylist/renameGroup requests.
 //
 // The Web AIM client calls this with oldGroup (current group name) and newGroup
 // (the requested new name). This is a stub; it does not yet rename the group in
 // the feedbag.
-func (h *BuddyListHandler) RenameGroup(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *BuddyListHandler) RenameGroup(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 	aimsid := r.URL.Query().Get("aimsid")
 
 	oldGroup := strings.TrimSpace(r.URL.Query().Get("oldGroup"))
 	newGroup := strings.TrimSpace(r.URL.Query().Get("newGroup"))
 	if oldGroup == "" || newGroup == "" {
-		h.sendError(w, r, http.StatusBadRequest, "missing oldGroup or newGroup parameter")
+		SendError(w, r, http.StatusBadRequest, "missing oldGroup or newGroup parameter")
 		return
 	}
 
@@ -453,11 +308,7 @@ func (h *BuddyListHandler) RenameGroup(w http.ResponseWriter, r *http.Request, s
 		h.Logger.ErrorContext(ctx, "rename group failed", "err", rnErr.Error())
 	}
 
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = &ResultCodeData{ResultCode: resultCode}
-	SendResponse(w, r, resp, h.Logger)
+	SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
 
 	if resultCode == "success" {
 		h.pushBuddyListEvent(ctx, session)
@@ -477,7 +328,7 @@ func (h *BuddyListHandler) RenameGroup(w http.ResponseWriter, r *http.Request, s
 // current group), and optionally newGroup (destination group) and beforeBuddy
 // (buddy to position it before). This is a stub; it does not yet move the buddy
 // in the feedbag.
-func (h *BuddyListHandler) MoveBuddy(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *BuddyListHandler) MoveBuddy(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 	aimsid := r.URL.Query().Get("aimsid")
 
@@ -486,11 +337,11 @@ func (h *BuddyListHandler) MoveBuddy(w http.ResponseWriter, r *http.Request, ses
 	newGroup := strings.TrimSpace(r.URL.Query().Get("newGroup"))
 	beforeBuddy := strings.TrimSpace(r.URL.Query().Get("beforeBuddy"))
 	if buddyName == "" {
-		h.sendError(w, r, http.StatusBadRequest, "missing buddy parameter")
+		SendError(w, r, http.StatusBadRequest, "missing buddy parameter")
 		return
 	}
 	if groupName == "" {
-		h.sendError(w, r, http.StatusBadRequest, "missing group parameter")
+		SendError(w, r, http.StatusBadRequest, "missing group parameter")
 		return
 	}
 
@@ -499,11 +350,7 @@ func (h *BuddyListHandler) MoveBuddy(w http.ResponseWriter, r *http.Request, ses
 		h.Logger.ErrorContext(ctx, "move buddy failed", "err", mvErr.Error())
 	}
 
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = &ResultCodeData{ResultCode: resultCode}
-	SendResponse(w, r, resp, h.Logger)
+	SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
 
 	if resultCode == "success" {
 		h.pushBuddyListEvent(ctx, session)
@@ -524,14 +371,14 @@ func (h *BuddyListHandler) MoveBuddy(w http.ResponseWriter, r *http.Request, ses
 // The Web AIM client calls this with t (the buddy) and friendly (the display
 // name / alias). This is a stub; it does not yet persist the attribute to the
 // feedbag.
-func (h *BuddyListHandler) SetBuddyAttribute(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *BuddyListHandler) SetBuddyAttribute(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 	aimsid := r.URL.Query().Get("aimsid")
 
 	buddyName := strings.TrimSpace(r.URL.Query().Get("t"))
 	friendly := strings.TrimSpace(r.URL.Query().Get("friendly"))
 	if buddyName == "" {
-		h.sendError(w, r, http.StatusBadRequest, "missing t parameter")
+		SendError(w, r, http.StatusBadRequest, "missing t parameter")
 		return
 	}
 
@@ -540,11 +387,7 @@ func (h *BuddyListHandler) SetBuddyAttribute(w http.ResponseWriter, r *http.Requ
 		h.Logger.ErrorContext(ctx, "set buddy attribute failed", "err", saErr.Error())
 	}
 
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = &ResultCodeData{ResultCode: resultCode}
-	SendResponse(w, r, resp, h.Logger)
+	SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
 
 	if resultCode == "success" {
 		h.pushBuddyListEvent(ctx, session)
@@ -563,7 +406,7 @@ func (h *BuddyListHandler) SetBuddyAttribute(w http.ResponseWriter, r *http.Requ
 // The Web AIM client calls this with collapsed (the group's collapsed state)
 // and, for named groups, group. The unnamed default group omits group. This is
 // a stub; it does not yet persist the attribute to the feedbag.
-func (h *BuddyListHandler) SetGroupAttribute(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *BuddyListHandler) SetGroupAttribute(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 	aimsid := r.URL.Query().Get("aimsid")
 
@@ -571,7 +414,7 @@ func (h *BuddyListHandler) SetGroupAttribute(w http.ResponseWriter, r *http.Requ
 	groupName := strings.TrimSpace(query.Get("group"))
 	collapsedParam := strings.TrimSpace(query.Get("collapsed"))
 	if collapsedParam == "" {
-		h.sendError(w, r, http.StatusBadRequest, "missing collapsed parameter")
+		SendError(w, r, http.StatusBadRequest, "missing collapsed parameter")
 		return
 	}
 	collapsed := collapsedParam == "true" || collapsedParam == "1"
@@ -581,11 +424,7 @@ func (h *BuddyListHandler) SetGroupAttribute(w http.ResponseWriter, r *http.Requ
 		h.Logger.ErrorContext(ctx, "set group attribute failed", "err", saErr.Error())
 	}
 
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = &ResultCodeData{ResultCode: resultCode}
-	SendResponse(w, r, resp, h.Logger)
+	SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
 
 	if resultCode == "success" {
 		h.pushBuddyListEvent(ctx, session)
@@ -601,13 +440,13 @@ func (h *BuddyListHandler) SetGroupAttribute(w http.ResponseWriter, r *http.Requ
 
 // pushBuddyListEvent refreshes the buddy list and pushes it to the session's
 // event queue so the Web client re-renders after a mutation.
-func (h *BuddyListHandler) pushBuddyListEvent(ctx context.Context, session *state.WebAPISession) {
+func (h *BuddyListHandler) pushBuddyListEvent(ctx context.Context, session *Session) {
 	groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
 	if err != nil {
 		h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
 		return
 	}
-	session.EventQueue.Push(types.EventTypeBuddyList, &BuddyListData{Groups: groups})
+	session.EventQueue.Push(EventTypeBuddyList, &BuddyListData{Groups: groups})
 }
 
 // ResultCodeData is the payload the buddy list editing methods answer with.
@@ -621,8 +460,3 @@ type ResultCodeData struct {
 	// BuddyNames accompanies the temp-buddy methods only.
 	BuddyNames []string `json:"buddyNames,omitempty" xml:"buddyNames>buddyName,omitempty"`
 }
-
-// sendError is a convenience method that wraps the common SendError function.
-func (h *BuddyListHandler) sendError(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
-	SendError(w, r, statusCode, message)
-}

Разлика између датотеке није приказан због своје велике величине
+ 138 - 389
server/webapi/buddylist_handler_test.go


+ 1 - 1
server/webapi/handlers/crossdomain.go → server/webapi/crossdomain.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"log/slog"

+ 65 - 2
server/webapi/types/events.go → server/webapi/events.go

@@ -1,4 +1,4 @@
-package types
+package webapi
 
 import (
 	"context"
@@ -21,7 +21,6 @@ const (
 	EventTypeRateLimit    EventType = "rateLimit"
 	EventTypeSentIM       EventType = "sentIM"
 	EventTypeSessionEnded EventType = "sessionEnded"
-	EventTypeStatus       EventType = "status"
 	EventTypeTyping       EventType = "typing"
 	EventTypePermitDeny   EventType = "permitDeny"
 )
@@ -268,3 +267,67 @@ func (q *EventQueue) Close() {
 		close(q.closeChan)
 	})
 }
+
+// ConversationData is a conversation event payload: an operation and the
+// conversations it applies to.
+type ConversationData struct {
+	Operation     string                  `json:"operation" xml:"operation"`
+	Conversations []ConversationEntryData `json:"conversations" xml:"conversations>conversation"`
+}
+
+// ConversationEntryData is one conversation in the client's list.
+type ConversationEntryData struct {
+	AimID string `json:"aimId" xml:"aimId"`
+	// Active is always sent, zero included, because the client reads it
+	// unconditionally.
+	Active      int `json:"active" xml:"active"`
+	UnreadCount int `json:"unreadCount" xml:"unreadCount"`
+	// DisplayID is omitted when empty rather than sent blank: the client falls
+	// back to the name it already has for aimID, whereas any value present here
+	// replaces it.
+	DisplayID string  `json:"displayId,omitempty" xml:"displayId,omitempty"`
+	LastIM    *LastIM `json:"lastIM,omitempty" xml:"lastIM,omitempty"`
+}
+
+// LastIM is the most recent message in a conversation.
+//
+// Timestamp is a float because AMF3 encodes whole numbers in 29 bits, which a
+// Unix timestamp overflows.
+type LastIM struct {
+	Message   string  `json:"message" xml:"message"`
+	MsgID     string  `json:"msgId" xml:"msgId"`
+	Sender    string  `json:"sender" xml:"sender"`
+	Sent      bool    `json:"sent" xml:"sent"`
+	Timestamp float64 `json:"timestamp" xml:"timestamp"`
+}
+
+// ConversationEventData builds a conversation fetchEvents payload.
+func ConversationEventData(operation string, conversations []ConversationEntryData) *ConversationData {
+	if conversations == nil {
+		conversations = []ConversationEntryData{}
+	}
+	return &ConversationData{
+		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) ConversationEntryData {
+	entry := ConversationEntryData{
+		AimID:       aimID,
+		Active:      0,
+		UnreadCount: unread,
+		DisplayID:   displayID,
+	}
+	if message != "" {
+		entry.LastIM = &LastIM{
+			Message:   message,
+			MsgID:     msgID,
+			Sender:    sender,
+			Sent:      sent,
+			Timestamp: float64(time.Now().Unix()),
+		}
+	}
+	return entry
+}

+ 161 - 30
server/webapi/handlers/expressions.go → server/webapi/expressions_handler.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"context"
@@ -9,6 +9,7 @@ import (
 	"log/slog"
 	"math/rand"
 	"net/http"
+	"net/url"
 
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
@@ -27,36 +28,24 @@ type Expression struct {
 
 const bartUploadMaxBytes = 64 << 10
 
-// BARTUploader stores a BART asset and returns its content-addressed ID.
-type BARTUploader interface {
-	UpsertItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x02_BARTUploadQuery) (wire.SNACMessage, error)
-}
-
-// ExpressionsFeedbagService is the slice of the feedbag service Upload needs to
-// point a user's icon reference at a newly stored asset.
-type ExpressionsFeedbagService interface {
-	Query(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error)
-	UpsertItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) (*wire.SNACMessage, error)
-}
-
 // ExpressionsHandler handles Web AIM API expressions/buddy icon endpoints.
 type ExpressionsHandler struct {
 	IconSource     BuddyIconSource
-	BARTUploader   BARTUploader
-	FeedbagService ExpressionsFeedbagService
+	BARTService    BARTService
+	FeedbagService FeedbagService
 	Logger         *slog.Logger
 }
 
 // NewExpressionsHandler creates a new ExpressionsHandler.
 func NewExpressionsHandler(
 	iconSource BuddyIconSource,
-	bartUploader BARTUploader,
-	feedbagService ExpressionsFeedbagService,
+	bartService BARTService,
+	feedbagService FeedbagService,
 	logger *slog.Logger,
 ) *ExpressionsHandler {
 	return &ExpressionsHandler{
 		IconSource:     iconSource,
-		BARTUploader:   bartUploader,
+		BARTService:    bartService,
 		FeedbagService: feedbagService,
 		Logger:         logger,
 	}
@@ -106,11 +95,7 @@ func (h *ExpressionsHandler) Get(w http.ResponseWriter, r *http.Request) {
 		expressions = append(expressions, Expression{Type: "bigBuddyIcon", URL: iconURL})
 	}
 
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = &ExpressionsData{Expressions: expressions}
-	SendResponse(w, r, resp, h.Logger)
+	SendOK(w, r, &ExpressionsData{Expressions: expressions}, h.Logger)
 }
 
 // serveIcon writes a user's buddy icon image.
@@ -175,7 +160,7 @@ type UploadData struct {
 }
 
 // Upload handles POST /expressions/upload, which stores a buddy icon.
-func (h *ExpressionsHandler) Upload(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *ExpressionsHandler) Upload(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
 	var bartType uint16
@@ -208,7 +193,7 @@ func (h *ExpressionsHandler) Upload(w http.ResponseWriter, r *http.Request, sess
 		return
 	}
 
-	uploadReply, err := h.BARTUploader.UpsertItem(ctx, session.OSCARSession,
+	uploadReply, err := h.BARTService.UpsertItem(ctx, session.OSCARSession,
 		wire.SNACFrame{FoodGroup: wire.BART, SubGroup: wire.BARTUploadQuery},
 		wire.SNAC_0x10_0x02_BARTUploadQuery{Type: bartType, Data: image})
 	if err != nil {
@@ -242,11 +227,7 @@ func (h *ExpressionsHandler) Upload(w http.ResponseWriter, r *http.Request, sess
 		"bytes", len(image),
 		"hash", fmt.Sprintf("%x", body.ID.Hash))
 
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "Ok"
-	resp.Response.Data = &UploadData{ID: fmt.Sprintf("%04x%x", bartType, body.ID.Hash)}
-	SendResponse(w, r, resp, h.Logger)
+	SendOK(w, r, &UploadData{ID: fmt.Sprintf("%04x%x", bartType, body.ID.Hash)}, h.Logger)
 }
 
 // publishIcon points the user's feedbag BART reference at hash, which is what
@@ -284,3 +265,153 @@ func (h *ExpressionsHandler) publishIcon(
 		[]wire.FeedbagItem{item})
 	return err
 }
+
+// ErrNoBuddyIcon indicates that a user has not set a buddy icon.
+var ErrNoBuddyIcon = errors.New("no buddy icon")
+
+// BuddyIconSource resolves buddy icons, both as URLs to publish to the web
+// client and as the image bytes those URLs serve.
+//
+// The client never derives an icon URL: it renders whatever string the server
+// puts in a user's buddyIcon field, falling back to a blank-person placeholder
+// when the field is absent.
+type BuddyIconSource struct {
+	IconRetriever BuddyIconRetriever
+	BARTService   BARTService
+	Logger        *slog.Logger
+}
+
+// URL returns the absolute, content-addressed URL that screenName's buddy icon
+// is served from, or an empty string if the user has no icon.
+//
+// The icon hash is part of the URL so that the URL changes whenever the user
+// changes their icon. Browsers cache icons by URL, and the client refetches a
+// user's large icon only when it observes buddyIconUrl change.
+func (s BuddyIconSource) URL(ctx context.Context, baseURL string, screenName state.IdentScreenName) string {
+	// The client loads icons from a different origin than the page it runs on,
+	// so a URL is only publishable if it can be made absolute. Callers that have
+	// no origin to build against pass an empty baseURL to opt out.
+	if baseURL == "" {
+		return ""
+	}
+
+	id, err := s.iconID(ctx, screenName)
+	if err != nil {
+		if !errors.Is(err, ErrNoBuddyIcon) {
+			s.Logger.WarnContext(ctx, "failed to resolve buddy icon",
+				"screenName", screenName.String(), "err", err.Error())
+		}
+		return ""
+	}
+
+	return iconURL(baseURL, screenName, id.Hash)
+}
+
+// PublishedURL returns a buddyIcon URL that is always non-empty when baseURL is
+// set: the content-addressed URL when the user has an icon, otherwise a hash-less
+// URL that resolves to the blank placeholder.
+//
+// Callers that publish icons into buddy-list, presence, or myInfo payloads use
+// this so the client always receives a URL. The web client's shallow user-object
+// merge never drops a stale buddyIconUrl on its own, so a user who clears their
+// icon only stops rendering it once a *different* URL arrives; the hash-less
+// placeholder URL is that different URL.
+func (s BuddyIconSource) PublishedURL(ctx context.Context, baseURL string, screenName state.IdentScreenName) string {
+	if baseURL == "" {
+		return ""
+	}
+
+	id, err := s.iconID(ctx, screenName)
+	switch {
+	case errors.Is(err, ErrNoBuddyIcon):
+		// No icon set: publish the hash-less placeholder rather than nothing, so
+		// a cleared icon propagates to the client.
+		return iconURL(baseURL, screenName, nil)
+	case err != nil:
+		s.Logger.WarnContext(ctx, "failed to resolve buddy icon",
+			"screenName", screenName.String(), "err", err.Error())
+		return ""
+	}
+
+	return iconURL(baseURL, screenName, id.Hash)
+}
+
+// URLForHash formats a buddyIcon URL for a hash already known to the caller,
+// skipping the metadata lookup that URL/PublishedURL do. The event pump uses this
+// on presence broadcasts, whose SNAC already carries the buddy's icon hash (TLV
+// wire.OServiceUserInfoBARTInfo).
+//
+// A non-empty hash yields the content-addressed URL; a nil/empty hash yields the
+// hash-less placeholder URL (which serves the blank icon), so a buddy who cleared
+// or never set an icon still gets a non-empty URL the client's shallow merge can
+// act on. An empty baseURL (no origin to build against) yields "".
+func (s BuddyIconSource) URLForHash(baseURL string, screenName state.IdentScreenName, hash []byte) string {
+	if baseURL == "" {
+		return ""
+	}
+	return iconURL(baseURL, screenName, hash)
+}
+
+// iconURL formats the expressions endpoint URL for screenName's icon. A non-empty
+// hash is content-addressed and cacheable; an empty hash yields the placeholder
+// form that serves the blank icon.
+func iconURL(baseURL string, screenName state.IdentScreenName, hash []byte) string {
+	if len(hash) == 0 {
+		return fmt.Sprintf("%s/expressions/get?t=%s&type=buddyIcon",
+			baseURL, url.QueryEscape(screenName.String()))
+	}
+	return fmt.Sprintf("%s/expressions/get?t=%s&type=buddyIcon&bartId=%x",
+		baseURL, url.QueryEscape(screenName.String()), hash)
+}
+
+// Image returns the image bytes of screenName's current buddy icon. It returns
+// ErrNoBuddyIcon if the user has no icon set.
+func (s BuddyIconSource) Image(ctx context.Context, screenName state.IdentScreenName) ([]byte, error) {
+	id, err := s.iconID(ctx, screenName)
+	if err != nil {
+		return nil, err
+	}
+	return s.ImageForHash(ctx, screenName, id.Hash)
+}
+
+// ImageForHash returns the bytes of the BART asset identified by hash for
+// screenName, independent of the user's current icon reference. This lets a
+// content-addressed URL resolve to the exact image its hash names, so a URL that
+// was cached as immutable never resolves to a different image later. It returns
+// ErrNoBuddyIcon if no asset with that hash exists. Passing the clear-icon hash
+// yields the blank placeholder image.
+func (s BuddyIconSource) ImageForHash(ctx context.Context, screenName state.IdentScreenName, hash []byte) ([]byte, error) {
+	msg, err := s.BARTService.RetrieveItem(ctx, wire.SNACFrame{}, wire.SNAC_0x10_0x04_BARTDownloadQuery{
+		ScreenName: screenName.String(),
+		BARTID: wire.BARTID{
+			Type:     wire.BARTTypesBuddyIcon,
+			BARTInfo: wire.BARTInfo{Hash: hash},
+		},
+	})
+	if err != nil {
+		return nil, fmt.Errorf("RetrieveItem: %w", err)
+	}
+
+	reply, ok := msg.Body.(wire.SNAC_0x10_0x05_BARTDownloadReply)
+	if !ok {
+		return nil, fmt.Errorf("unexpected BART reply body type %T", msg.Body)
+	}
+	if len(reply.Data) == 0 {
+		return nil, ErrNoBuddyIcon
+	}
+
+	return reply.Data, nil
+}
+
+// iconID looks up a user's buddy icon reference, translating "no icon" and
+// "icon cleared" into ErrNoBuddyIcon.
+func (s BuddyIconSource) iconID(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error) {
+	id, err := s.IconRetriever.BuddyIconMetadata(ctx, screenName)
+	if err != nil {
+		return nil, fmt.Errorf("BuddyIconMetadata: %w", err)
+	}
+	if id == nil || id.HasClearIconHash() || len(id.Hash) == 0 {
+		return nil, ErrNoBuddyIcon
+	}
+	return id, nil
+}

+ 289 - 57
server/webapi/handlers/expressions_test.go → server/webapi/expressions_handler_test.go

@@ -1,9 +1,10 @@
-package handlers
+package webapi
 
 import (
 	"bytes"
 	"context"
 	"encoding/json"
+	"errors"
 	"log/slog"
 	"net/http"
 	"net/http/httptest"
@@ -23,15 +24,15 @@ var blankIconGIF = []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0xff}
 // newExpressionsHandler builds a handler whose target user has the given icon,
 // or no icon when id is nil. Its BART mock mirrors the real service: the
 // clear-icon hash resolves to the blank placeholder, any other hash to iconGIF.
-func newExpressionsHandler(id *wire.BARTID) *ExpressionsHandler {
-	iconRetriever := &MockBuddyIconRetriever{}
-	iconRetriever.On("BuddyIconMetadata", mock.Anything, mock.Anything).Return(id, nil).Maybe()
+func newExpressionsHandler(t *testing.T, id *wire.BARTID) *ExpressionsHandler {
+	iconRetriever := newMockBuddyIconRetriever(t)
+	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).Return(id, nil).Maybe()
 
-	bartService := &MockBARTService{}
-	bartService.On("RetrieveItem", mock.Anything, mock.Anything,
+	bartService := newMockBARTService(t)
+	bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything,
 		mock.MatchedBy(func(q wire.SNAC_0x10_0x04_BARTDownloadQuery) bool { return q.HasClearIconHash() })).
 		Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: blankIconGIF}}, nil).Maybe()
-	bartService.On("RetrieveItem", mock.Anything, mock.Anything, mock.Anything).
+	bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything, mock.Anything).
 		Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: iconGIF}}, nil).Maybe()
 
 	return NewExpressionsHandler(BuddyIconSource{
@@ -42,7 +43,7 @@ func newExpressionsHandler(id *wire.BARTID) *ExpressionsHandler {
 }
 
 func TestExpressionsHandler_Get_ServesIconBytes(t *testing.T) {
-	h := newExpressionsHandler(bartID([]byte{0xde, 0xad}))
+	h := newExpressionsHandler(t, bartID([]byte{0xde, 0xad}))
 
 	r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon&bartId=dead", nil)
 	w := httptest.NewRecorder()
@@ -58,7 +59,7 @@ func TestExpressionsHandler_Get_ServesIconBytes(t *testing.T) {
 func TestExpressionsHandler_Get_IconWithoutHashIsNotCached(t *testing.T) {
 	// Without a hash the URL keeps resolving to whatever the current icon is, so
 	// caching it would pin a stale image.
-	h := newExpressionsHandler(bartID([]byte{0xde, 0xad}))
+	h := newExpressionsHandler(t, bartID([]byte{0xde, 0xad}))
 
 	r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon", nil)
 	w := httptest.NewRecorder()
@@ -72,7 +73,7 @@ func TestExpressionsHandler_Get_MissingIconServesPlaceholder(t *testing.T) {
 	// A user with no icon still serves the blank placeholder for the hash-less
 	// URL, so the client's <img> renders something and a cleared icon stops
 	// showing the previous one rather than 404ing.
-	h := newExpressionsHandler(nil)
+	h := newExpressionsHandler(t, nil)
 
 	r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon", nil)
 	w := httptest.NewRecorder()
@@ -87,13 +88,13 @@ func TestExpressionsHandler_Get_BartIdServesRequestedHash(t *testing.T) {
 	// Even though the user's *current* icon is hash B, a URL pinned to hash A must
 	// serve A's bytes and cache immutably — otherwise a cached URL could later
 	// resolve to a different image.
-	iconRetriever := &MockBuddyIconRetriever{}
-	iconRetriever.On("BuddyIconMetadata", mock.Anything, mock.Anything).
+	iconRetriever := newMockBuddyIconRetriever(t)
+	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).
 		Return(bartID([]byte{0xbb}), nil).Maybe()
 
 	bytesA := []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0xa1}
-	bartService := &MockBARTService{}
-	bartService.On("RetrieveItem", mock.Anything, mock.Anything,
+	bartService := newMockBARTService(t)
+	bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything,
 		mock.MatchedBy(func(q wire.SNAC_0x10_0x04_BARTDownloadQuery) bool {
 			return bytes.Equal(q.Hash, []byte{0xaa})
 		})).
@@ -112,17 +113,16 @@ func TestExpressionsHandler_Get_BartIdServesRequestedHash(t *testing.T) {
 	assert.Equal(t, http.StatusOK, w.Code)
 	assert.Equal(t, bytesA, w.Body.Bytes())
 	assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control"))
-	bartService.AssertExpectations(t)
 }
 
 func TestExpressionsHandler_Get_UnknownBartIdNotFound(t *testing.T) {
-	iconRetriever := &MockBuddyIconRetriever{}
-	iconRetriever.On("BuddyIconMetadata", mock.Anything, mock.Anything).
+	iconRetriever := newMockBuddyIconRetriever(t)
+	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).
 		Return(bartID([]byte{0xbb}), nil).Maybe()
 
 	// An empty reply means the hash is not stored.
-	bartService := &MockBARTService{}
-	bartService.On("RetrieveItem", mock.Anything, mock.Anything, mock.Anything).
+	bartService := newMockBARTService(t)
+	bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything, mock.Anything).
 		Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{}}, nil).Once()
 
 	h := NewExpressionsHandler(BuddyIconSource{
@@ -140,7 +140,7 @@ func TestExpressionsHandler_Get_UnknownBartIdNotFound(t *testing.T) {
 
 func TestExpressionsHandler_Get_ListsBigBuddyIcon(t *testing.T) {
 	// This is the shape the client scans for its large icon rendering.
-	h := newExpressionsHandler(bartID([]byte{0xde, 0xad}))
+	h := newExpressionsHandler(t, bartID([]byte{0xde, 0xad}))
 
 	r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly", nil)
 	r.Host = "api.example.com"
@@ -171,7 +171,7 @@ func TestExpressionsHandler_Get_ListsBigBuddyIcon(t *testing.T) {
 }
 
 func TestExpressionsHandler_Get_ListsNothingWithoutIcon(t *testing.T) {
-	h := newExpressionsHandler(nil)
+	h := newExpressionsHandler(t, nil)
 
 	r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly", nil)
 	w := httptest.NewRecorder()
@@ -182,7 +182,7 @@ func TestExpressionsHandler_Get_ListsNothingWithoutIcon(t *testing.T) {
 }
 
 func TestExpressionsHandler_Get_Redirect(t *testing.T) {
-	h := newExpressionsHandler(bartID([]byte{0xde, 0xad}))
+	h := newExpressionsHandler(t, bartID([]byte{0xde, 0xad}))
 
 	r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&f=redirect", nil)
 	r.Host = "api.example.com"
@@ -196,7 +196,7 @@ func TestExpressionsHandler_Get_Redirect(t *testing.T) {
 }
 
 func TestExpressionsHandler_Get_RedirectWithoutIcon(t *testing.T) {
-	h := newExpressionsHandler(nil)
+	h := newExpressionsHandler(t, nil)
 
 	r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&f=redirect", nil)
 	w := httptest.NewRecorder()
@@ -206,7 +206,7 @@ func TestExpressionsHandler_Get_RedirectWithoutIcon(t *testing.T) {
 }
 
 func TestExpressionsHandler_Get_MissingTarget(t *testing.T) {
-	h := newExpressionsHandler(nil)
+	h := newExpressionsHandler(t, nil)
 
 	r := httptest.NewRequest(http.MethodGet, "/expressions/get", nil)
 	w := httptest.NewRecorder()
@@ -215,18 +215,8 @@ func TestExpressionsHandler_Get_MissingTarget(t *testing.T) {
 	assert.Equal(t, http.StatusBadRequest, w.Code)
 }
 
-// MockBARTUploader is a mock implementation of BARTUploader.
-type MockBARTUploader struct {
-	mock.Mock
-}
-
-func (m *MockBARTUploader) UpsertItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x02_BARTUploadQuery) (wire.SNACMessage, error) {
-	args := m.Called(ctx, instance, inFrame, inBody)
-	return args.Get(0).(wire.SNACMessage), args.Error(1)
-}
-
-func newUploadSession() *state.WebAPISession {
-	return &state.WebAPISession{
+func newUploadSession() *Session {
+	return &Session{
 		AimSID:       "sid",
 		ScreenName:   state.DisplayScreenName("testuser"),
 		OSCARSession: state.NewSession().AddInstance(),
@@ -240,8 +230,8 @@ func TestExpressionsHandler_Upload_StoresAndPublishesIcon(t *testing.T) {
 	image := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
 	hash := []byte{0xde, 0xad, 0xbe, 0xef}
 
-	uploader := &MockBARTUploader{}
-	uploader.On("UpsertItem", mock.Anything, mock.Anything,
+	uploader := newMockBARTService(t)
+	uploader.EXPECT().UpsertItem(mock.Anything, mock.Anything,
 		wire.SNACFrame{FoodGroup: wire.BART, SubGroup: wire.BARTUploadQuery},
 		wire.SNAC_0x10_0x02_BARTUploadQuery{Type: wire.BARTTypesBuddyIcon, Data: image},
 	).Return(wire.SNACMessage{
@@ -254,8 +244,8 @@ func TestExpressionsHandler_Upload_StoresAndPublishesIcon(t *testing.T) {
 		},
 	}, nil).Once()
 
-	fs := &MockFeedbagService{}
-	fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+	fs := newMockFeedbagService(t)
+	fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
 		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{
 			Items: []wire.FeedbagItem{
 				// The root group, which lives at GroupID 0 / ItemID 0.
@@ -267,10 +257,10 @@ func TestExpressionsHandler_Upload_StoresAndPublishesIcon(t *testing.T) {
 		upserted    []wire.FeedbagItem
 		upsertFrame wire.SNACFrame
 	)
-	fs.On("UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
-		Run(func(args mock.Arguments) {
-			upsertFrame = args.Get(2).(wire.SNACFrame)
-			upserted = args.Get(3).([]wire.FeedbagItem)
+	fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		Run(func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) {
+			upsertFrame = inFrame
+			upserted = items
 		}).Return((*wire.SNACMessage)(nil), nil).Once()
 
 	h := NewExpressionsHandler(BuddyIconSource{Logger: slog.Default()}, uploader, fs, slog.Default())
@@ -309,15 +299,12 @@ func TestExpressionsHandler_Upload_StoresAndPublishesIcon(t *testing.T) {
 	info := wire.BARTInfo{}
 	assert.NoError(t, wire.UnmarshalBE(&info, bytes.NewBuffer(b)))
 	assert.Equal(t, hash, info.Hash)
-
-	uploader.AssertExpectations(t)
-	fs.AssertExpectations(t)
 }
 
 // An existing icon is replaced in place rather than added alongside.
 func TestExpressionsHandler_Upload_ReusesExistingFeedbagItem(t *testing.T) {
-	uploader := &MockBARTUploader{}
-	uploader.On("UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+	uploader := newMockBARTService(t)
+	uploader.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
 		Return(wire.SNACMessage{
 			Body: wire.SNAC_0x10_0x03_BARTUploadReply{
 				Code: wire.BARTReplyCodesSuccess,
@@ -325,8 +312,8 @@ func TestExpressionsHandler_Upload_ReusesExistingFeedbagItem(t *testing.T) {
 			},
 		}, nil).Once()
 
-	fs := &MockFeedbagService{}
-	fs.On("Query", mock.Anything, mock.Anything, mock.Anything).
+	fs := newMockFeedbagService(t)
+	fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
 		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{
 			Items: []wire.FeedbagItem{
 				{GroupID: 7, ItemID: 9, ClassID: wire.FeedbagClassIdBart, Name: "1"},
@@ -337,10 +324,10 @@ func TestExpressionsHandler_Upload_ReusesExistingFeedbagItem(t *testing.T) {
 		upserted    []wire.FeedbagItem
 		upsertFrame wire.SNACFrame
 	)
-	fs.On("UpsertItem", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
-		Run(func(args mock.Arguments) {
-			upsertFrame = args.Get(2).(wire.SNACFrame)
-			upserted = args.Get(3).([]wire.FeedbagItem)
+	fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		Run(func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) {
+			upsertFrame = inFrame
+			upserted = items
 		}).
 		Return((*wire.SNACMessage)(nil), nil).Once()
 
@@ -373,8 +360,8 @@ func TestExpressionsHandler_Upload_RejectsBadRequests(t *testing.T) {
 	}
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			uploader := &MockBARTUploader{}
-			fs := &MockFeedbagService{}
+			uploader := newMockBARTService(t)
+			fs := newMockFeedbagService(t)
 			h := NewExpressionsHandler(BuddyIconSource{Logger: slog.Default()}, uploader, fs, slog.Default())
 
 			req := httptest.NewRequest(http.MethodPost, tc.url, bytes.NewReader(tc.body))
@@ -388,3 +375,248 @@ func TestExpressionsHandler_Upload_RejectsBadRequests(t *testing.T) {
 		})
 	}
 }
+
+// iconGIF stands in for buddy icon image bytes.
+var iconGIF = []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00}
+
+func bartID(hash []byte) *wire.BARTID {
+	return &wire.BARTID{
+		Type:     wire.BARTTypesBuddyIcon,
+		BARTInfo: wire.BARTInfo{Flags: wire.BARTFlagsCustom, Hash: hash},
+	}
+}
+
+func TestBuddyIconSource_URL(t *testing.T) {
+	tests := []struct {
+		name    string
+		baseURL string
+		id      *wire.BARTID
+		idErr   error
+		want    string
+	}{
+		{
+			name:    "user with an icon gets a URL carrying the icon hash",
+			baseURL: "http://api.example.com",
+			id:      bartID([]byte{0xde, 0xad, 0xbe, 0xef}),
+			want:    "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=deadbeef",
+		},
+		{
+			name:    "user without an icon gets no URL",
+			baseURL: "http://api.example.com",
+			id:      nil,
+			want:    "",
+		},
+		{
+			name:    "a cleared icon is not published",
+			baseURL: "http://api.example.com",
+			id:      bartID(wire.GetClearIconHash()),
+			want:    "",
+		},
+		{
+			name:    "a lookup failure is not fatal, it just yields no icon",
+			baseURL: "http://api.example.com",
+			idErr:   errors.New("db exploded"),
+			want:    "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			iconRetriever := newMockBuddyIconRetriever(t)
+			iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("mikekelly")).
+				Return(tt.id, tt.idErr).Once()
+
+			s := BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()}
+			got := s.URL(context.Background(), tt.baseURL, state.NewIdentScreenName("mikekelly"))
+
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}
+
+func TestBuddyIconSource_URL_NoBaseURLSkipsLookup(t *testing.T) {
+	// Callers with no origin to build an absolute URL against opt out by passing
+	// an empty baseURL. That must not cost a lookup.
+	iconRetriever := newMockBuddyIconRetriever(t)
+
+	s := BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()}
+	got := s.URL(context.Background(), "", state.NewIdentScreenName("mikekelly"))
+
+	assert.Empty(t, got)
+	iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, mock.Anything)
+}
+
+func TestBuddyIconSource_URL_UsesNormalizedScreenName(t *testing.T) {
+	// The URL targets the normalized screen name, which is what the endpoint
+	// resolves against and what the client keys users by.
+	iconRetriever := newMockBuddyIconRetriever(t)
+	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).
+		Return(bartID([]byte{0x01}), nil).Once()
+
+	s := BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()}
+	got := s.URL(context.Background(), "http://api.example.com", state.NewIdentScreenName("Mike Kelly"))
+
+	assert.Equal(t, "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=01", got)
+}
+
+func TestBuddyIconSource_Image(t *testing.T) {
+	hash := []byte{0xde, 0xad}
+
+	iconRetriever := newMockBuddyIconRetriever(t)
+	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("mikekelly")).
+		Return(bartID(hash), nil).Once()
+
+	// Image resolves the current hash from metadata, then downloads that exact
+	// hash. The download query is keyed by hash; flags are irrelevant to the
+	// lookup, so it carries only the type and hash.
+	bartService := newMockBARTService(t)
+	bartService.EXPECT().RetrieveItem(mock.Anything, wire.SNACFrame{}, wire.SNAC_0x10_0x04_BARTDownloadQuery{
+		ScreenName: "mikekelly",
+		BARTID:     wire.BARTID{Type: wire.BARTTypesBuddyIcon, BARTInfo: wire.BARTInfo{Hash: hash}},
+	}).Return(wire.SNACMessage{
+		Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: iconGIF},
+	}, nil).Once()
+
+	s := BuddyIconSource{IconRetriever: iconRetriever, BARTService: bartService, Logger: slog.Default()}
+	got, err := s.Image(context.Background(), state.NewIdentScreenName("mikekelly"))
+
+	assert.NoError(t, err)
+	assert.Equal(t, iconGIF, got)
+}
+
+func TestBuddyIconSource_ImageForHash(t *testing.T) {
+	hash := []byte{0xca, 0xfe}
+
+	// ImageForHash downloads the requested hash directly, without consulting the
+	// user's current icon metadata.
+	bartService := newMockBARTService(t)
+	bartService.EXPECT().RetrieveItem(mock.Anything, wire.SNACFrame{}, wire.SNAC_0x10_0x04_BARTDownloadQuery{
+		ScreenName: "mikekelly",
+		BARTID:     wire.BARTID{Type: wire.BARTTypesBuddyIcon, BARTInfo: wire.BARTInfo{Hash: hash}},
+	}).Return(wire.SNACMessage{
+		Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: iconGIF},
+	}, nil).Once()
+
+	s := BuddyIconSource{BARTService: bartService, Logger: slog.Default()}
+	got, err := s.ImageForHash(context.Background(), state.NewIdentScreenName("mikekelly"), hash)
+
+	assert.NoError(t, err)
+	assert.Equal(t, iconGIF, got)
+}
+
+func TestBuddyIconSource_ImageForHash_NotFound(t *testing.T) {
+	bartService := newMockBARTService(t)
+	bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{}}, nil).Once()
+
+	s := BuddyIconSource{BARTService: bartService, Logger: slog.Default()}
+	_, err := s.ImageForHash(context.Background(), state.NewIdentScreenName("mikekelly"), []byte{0x01})
+
+	assert.ErrorIs(t, err, ErrNoBuddyIcon)
+}
+
+func TestBuddyIconSource_PublishedURL(t *testing.T) {
+	tests := []struct {
+		name    string
+		baseURL string
+		id      *wire.BARTID
+		idErr   error
+		want    string
+	}{
+		{
+			name:    "an icon yields a content-addressed URL",
+			baseURL: "http://api.example.com",
+			id:      bartID([]byte{0xde, 0xad, 0xbe, 0xef}),
+			want:    "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=deadbeef",
+		},
+		{
+			name:    "no icon still yields a hash-less placeholder URL",
+			baseURL: "http://api.example.com",
+			id:      nil,
+			want:    "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon",
+		},
+		{
+			name:    "a cleared icon yields the placeholder URL",
+			baseURL: "http://api.example.com",
+			id:      bartID(wire.GetClearIconHash()),
+			want:    "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon",
+		},
+		{
+			name:    "a lookup failure yields no URL",
+			baseURL: "http://api.example.com",
+			idErr:   errors.New("db exploded"),
+			want:    "",
+		},
+		{
+			name:    "no base URL yields no URL",
+			baseURL: "",
+			id:      bartID([]byte{0x01}),
+			want:    "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			iconRetriever := newMockBuddyIconRetriever(t)
+			iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("mikekelly")).
+				Return(tt.id, tt.idErr).Maybe()
+
+			s := BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()}
+			got := s.PublishedURL(context.Background(), tt.baseURL, state.NewIdentScreenName("mikekelly"))
+
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}
+
+func TestBuddyIconSource_Image_NoIcon(t *testing.T) {
+	iconRetriever := newMockBuddyIconRetriever(t)
+	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).Return(nil, nil).Once()
+
+	bartService := newMockBARTService(t)
+
+	s := BuddyIconSource{IconRetriever: iconRetriever, BARTService: bartService, Logger: slog.Default()}
+	_, err := s.Image(context.Background(), state.NewIdentScreenName("mikekelly"))
+
+	assert.ErrorIs(t, err, ErrNoBuddyIcon)
+	// No icon means there is nothing to ask BART for.
+	bartService.AssertNotCalled(t, "RetrieveItem", mock.Anything, mock.Anything, mock.Anything)
+}
+
+func TestBuddyIconSource_URLForHash(t *testing.T) {
+	// URLForHash never touches the retriever: the hash is supplied by the caller.
+	s := BuddyIconSource{Logger: slog.Default()}
+	sn := state.NewIdentScreenName("Mike Kelly")
+
+	t.Run("hash yields the content-addressed URL", func(t *testing.T) {
+		assert.Equal(t,
+			"http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=deadbeef",
+			s.URLForHash("http://api.example.com", sn, []byte{0xde, 0xad, 0xbe, 0xef}))
+	})
+
+	t.Run("no hash yields the placeholder URL", func(t *testing.T) {
+		assert.Equal(t,
+			"http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon",
+			s.URLForHash("http://api.example.com", sn, nil))
+	})
+
+	t.Run("empty baseURL opts out", func(t *testing.T) {
+		assert.Empty(t, s.URLForHash("", sn, []byte{0x01}))
+	})
+}
+
+func TestBuddyIconSource_Image_RetrieveFails(t *testing.T) {
+	iconRetriever := newMockBuddyIconRetriever(t)
+	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).
+		Return(bartID([]byte{0x01}), nil).Once()
+
+	bartService := newMockBARTService(t)
+	bartService.EXPECT().RetrieveItem(mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{}, errors.New("item missing")).Once()
+
+	s := BuddyIconSource{IconRetriever: iconRetriever, BARTService: bartService, Logger: slog.Default()}
+	_, err := s.Image(context.Background(), state.NewIdentScreenName("mikekelly"))
+
+	assert.ErrorContains(t, err, "item missing")
+	assert.NotErrorIs(t, err, ErrNoBuddyIcon)
+}

+ 0 - 51
server/webapi/handler.go

@@ -1,51 +0,0 @@
-package webapi
-
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-	"log/slog"
-	"net/http"
-
-	"github.com/mk6i/open-oscar-server/config"
-	"github.com/mk6i/open-oscar-server/server/webapi/handlers"
-	"github.com/mk6i/open-oscar-server/state"
-	"github.com/mk6i/open-oscar-server/wire"
-)
-
-type Handler struct {
-	AuthService        AuthService
-	BuddyListRegistry  BuddyListRegistry
-	CookieBaker        CookieBaker
-	ICBMService        ICBMService
-	LocateService      LocateService
-	Logger             *slog.Logger
-	OServiceService    OServiceService
-	SessionRetriever   SessionRetriever
-	BuddyBroadcaster   BuddyBroadcaster
-	BOSListener        config.ListenerGroup
-	BuddyListManager   interface{}
-	RecalcWarning      func(ctx context.Context, instance *state.SessionInstance) error
-	LowerWarnLevel     func(ctx context.Context, instance *state.SessionInstance)
-	ChatSessionManager ChatSessionManager
-	FeedbagService     FeedbagService
-	DirSearchService   DirSearchService
-	IconSource         handlers.BuddyIconSource
-	BARTUploader       handlers.BARTUploader
-	SNACRateLimits     wire.SNACRateLimits
-}
-
-func (h Handler) GetHelloWorldHandler(w http.ResponseWriter, r *http.Request) {
-	_, _ = 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)
-}

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

@@ -1,47 +0,0 @@
-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)
-}
-
-// ReportAction acknowledges a client-side UI telemetry ping. The Web AIM client
-// fires this on menu clicks and similar interactions with an action param of the
-// form "type=click,id=block-user-chatmenu"; it ignores the response.
-func (h *AimStubHandler) ReportAction(w http.ResponseWriter, r *http.Request) {
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	SendResponse(w, r, resp, h.Logger)
-}
-
-// StoredDataItems is the client-side data blob store, which this server does
-// not keep, so it always answers with an empty items list.
-type StoredDataItems struct {
-	Items []string `json:"items" xml:"items>item"`
-}
-
-// 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 = &StoredDataItems{Items: []string{}}
-	SendResponse(w, r, resp, h.Logger)
-}

+ 0 - 44
server/webapi/handlers/alias.go

@@ -1,44 +0,0 @@
-package handlers
-
-import (
-	"context"
-	"fmt"
-
-	"github.com/mk6i/open-oscar-server/state"
-	"github.com/mk6i/open-oscar-server/wire"
-)
-
-// FeedbagAliases collects the aliases the feedbag owner has assigned to their
-// buddies, keyed by normalized screen name. Buddies without an alias are absent.
-func FeedbagAliases(items []wire.FeedbagItem) map[string]string {
-	aliases := make(map[string]string)
-	for _, item := range items {
-		if item.ClassID != wire.FeedbagClassIdBuddy || item.Name == "" {
-			continue
-		}
-		alias, ok := item.String(wire.FeedbagAttributesAlias)
-		if !ok || alias == "" {
-			continue
-		}
-		aliases[state.NewIdentScreenName(item.Name).String()] = alias
-	}
-	return aliases
-}
-
-// LookupBuddyAliases returns the aliases the session owner has assigned to their
-// buddies, keyed by normalized screen name.
-//
-// Aliases are private to the viewer and live only in their feedbag, so they cannot
-// be derived from a locate reply the way display names are.
-func LookupBuddyAliases(ctx context.Context, feedbagService FeedbagService, instance *state.SessionInstance) (map[string]string, error) {
-	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
-	snac, err := feedbagService.Query(ctx, instance, frame)
-	if err != nil {
-		return nil, err
-	}
-	reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
-	if !ok {
-		return nil, fmt.Errorf("unexpected feedbag reply type")
-	}
-	return FeedbagAliases(reply.Items), nil
-}

+ 0 - 173
server/webapi/handlers/buddy_icon.go

@@ -1,173 +0,0 @@
-package handlers
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"log/slog"
-	"net/url"
-
-	"github.com/mk6i/open-oscar-server/state"
-	"github.com/mk6i/open-oscar-server/wire"
-)
-
-// ErrNoBuddyIcon indicates that a user has not set a buddy icon.
-var ErrNoBuddyIcon = errors.New("no buddy icon")
-
-// BARTService retrieves BART (Buddy Art) assets by hash.
-type BARTService interface {
-	RetrieveItem(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error)
-}
-
-// BuddyIconRetriever resolves a user's buddy icon reference. References live in
-// the feedbag rather than on the session, so they resolve for offline users too.
-type BuddyIconRetriever interface {
-	BuddyIconMetadata(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error)
-}
-
-// BuddyIconSource resolves buddy icons, both as URLs to publish to the web
-// client and as the image bytes those URLs serve.
-//
-// The client never derives an icon URL: it renders whatever string the server
-// puts in a user's buddyIcon field, falling back to a blank-person placeholder
-// when the field is absent.
-type BuddyIconSource struct {
-	IconRetriever BuddyIconRetriever
-	BARTService   BARTService
-	Logger        *slog.Logger
-}
-
-// URL returns the absolute, content-addressed URL that screenName's buddy icon
-// is served from, or an empty string if the user has no icon.
-//
-// The icon hash is part of the URL so that the URL changes whenever the user
-// changes their icon. Browsers cache icons by URL, and the client refetches a
-// user's large icon only when it observes buddyIconUrl change.
-func (s BuddyIconSource) URL(ctx context.Context, baseURL string, screenName state.IdentScreenName) string {
-	// The client loads icons from a different origin than the page it runs on,
-	// so a URL is only publishable if it can be made absolute. Callers that have
-	// no origin to build against pass an empty baseURL to opt out.
-	if baseURL == "" {
-		return ""
-	}
-
-	id, err := s.iconID(ctx, screenName)
-	if err != nil {
-		if !errors.Is(err, ErrNoBuddyIcon) {
-			s.Logger.WarnContext(ctx, "failed to resolve buddy icon",
-				"screenName", screenName.String(), "err", err.Error())
-		}
-		return ""
-	}
-
-	return iconURL(baseURL, screenName, id.Hash)
-}
-
-// PublishedURL returns a buddyIcon URL that is always non-empty when baseURL is
-// set: the content-addressed URL when the user has an icon, otherwise a hash-less
-// URL that resolves to the blank placeholder.
-//
-// Callers that publish icons into buddy-list, presence, or myInfo payloads use
-// this so the client always receives a URL. The web client's shallow user-object
-// merge never drops a stale buddyIconUrl on its own, so a user who clears their
-// icon only stops rendering it once a *different* URL arrives; the hash-less
-// placeholder URL is that different URL.
-func (s BuddyIconSource) PublishedURL(ctx context.Context, baseURL string, screenName state.IdentScreenName) string {
-	if baseURL == "" {
-		return ""
-	}
-
-	id, err := s.iconID(ctx, screenName)
-	switch {
-	case errors.Is(err, ErrNoBuddyIcon):
-		// No icon set: publish the hash-less placeholder rather than nothing, so
-		// a cleared icon propagates to the client.
-		return iconURL(baseURL, screenName, nil)
-	case err != nil:
-		s.Logger.WarnContext(ctx, "failed to resolve buddy icon",
-			"screenName", screenName.String(), "err", err.Error())
-		return ""
-	}
-
-	return iconURL(baseURL, screenName, id.Hash)
-}
-
-// URLForHash formats a buddyIcon URL for a hash already known to the caller,
-// skipping the metadata lookup that URL/PublishedURL do. The event pump uses this
-// on presence broadcasts, whose SNAC already carries the buddy's icon hash (TLV
-// wire.OServiceUserInfoBARTInfo).
-//
-// A non-empty hash yields the content-addressed URL; a nil/empty hash yields the
-// hash-less placeholder URL (which serves the blank icon), so a buddy who cleared
-// or never set an icon still gets a non-empty URL the client's shallow merge can
-// act on. An empty baseURL (no origin to build against) yields "".
-func (s BuddyIconSource) URLForHash(baseURL string, screenName state.IdentScreenName, hash []byte) string {
-	if baseURL == "" {
-		return ""
-	}
-	return iconURL(baseURL, screenName, hash)
-}
-
-// iconURL formats the expressions endpoint URL for screenName's icon. A non-empty
-// hash is content-addressed and cacheable; an empty hash yields the placeholder
-// form that serves the blank icon.
-func iconURL(baseURL string, screenName state.IdentScreenName, hash []byte) string {
-	if len(hash) == 0 {
-		return fmt.Sprintf("%s/expressions/get?t=%s&type=buddyIcon",
-			baseURL, url.QueryEscape(screenName.String()))
-	}
-	return fmt.Sprintf("%s/expressions/get?t=%s&type=buddyIcon&bartId=%x",
-		baseURL, url.QueryEscape(screenName.String()), hash)
-}
-
-// Image returns the image bytes of screenName's current buddy icon. It returns
-// ErrNoBuddyIcon if the user has no icon set.
-func (s BuddyIconSource) Image(ctx context.Context, screenName state.IdentScreenName) ([]byte, error) {
-	id, err := s.iconID(ctx, screenName)
-	if err != nil {
-		return nil, err
-	}
-	return s.ImageForHash(ctx, screenName, id.Hash)
-}
-
-// ImageForHash returns the bytes of the BART asset identified by hash for
-// screenName, independent of the user's current icon reference. This lets a
-// content-addressed URL resolve to the exact image its hash names, so a URL that
-// was cached as immutable never resolves to a different image later. It returns
-// ErrNoBuddyIcon if no asset with that hash exists. Passing the clear-icon hash
-// yields the blank placeholder image.
-func (s BuddyIconSource) ImageForHash(ctx context.Context, screenName state.IdentScreenName, hash []byte) ([]byte, error) {
-	msg, err := s.BARTService.RetrieveItem(ctx, wire.SNACFrame{}, wire.SNAC_0x10_0x04_BARTDownloadQuery{
-		ScreenName: screenName.String(),
-		BARTID: wire.BARTID{
-			Type:     wire.BARTTypesBuddyIcon,
-			BARTInfo: wire.BARTInfo{Hash: hash},
-		},
-	})
-	if err != nil {
-		return nil, fmt.Errorf("RetrieveItem: %w", err)
-	}
-
-	reply, ok := msg.Body.(wire.SNAC_0x10_0x05_BARTDownloadReply)
-	if !ok {
-		return nil, fmt.Errorf("unexpected BART reply body type %T", msg.Body)
-	}
-	if len(reply.Data) == 0 {
-		return nil, ErrNoBuddyIcon
-	}
-
-	return reply.Data, nil
-}
-
-// iconID looks up a user's buddy icon reference, translating "no icon" and
-// "icon cleared" into ErrNoBuddyIcon.
-func (s BuddyIconSource) iconID(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error) {
-	id, err := s.IconRetriever.BuddyIconMetadata(ctx, screenName)
-	if err != nil {
-		return nil, fmt.Errorf("BuddyIconMetadata: %w", err)
-	}
-	if id == nil || id.HasClearIconHash() || len(id.Hash) == 0 {
-		return nil, ErrNoBuddyIcon
-	}
-	return id, nil
-}

+ 0 - 263
server/webapi/handlers/buddy_icon_test.go

@@ -1,263 +0,0 @@
-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"
-)
-
-// iconGIF stands in for buddy icon image bytes.
-var iconGIF = []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00}
-
-func bartID(hash []byte) *wire.BARTID {
-	return &wire.BARTID{
-		Type:     wire.BARTTypesBuddyIcon,
-		BARTInfo: wire.BARTInfo{Flags: wire.BARTFlagsCustom, Hash: hash},
-	}
-}
-
-func TestBuddyIconSource_URL(t *testing.T) {
-	tests := []struct {
-		name    string
-		baseURL string
-		id      *wire.BARTID
-		idErr   error
-		want    string
-	}{
-		{
-			name:    "user with an icon gets a URL carrying the icon hash",
-			baseURL: "http://api.example.com",
-			id:      bartID([]byte{0xde, 0xad, 0xbe, 0xef}),
-			want:    "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=deadbeef",
-		},
-		{
-			name:    "user without an icon gets no URL",
-			baseURL: "http://api.example.com",
-			id:      nil,
-			want:    "",
-		},
-		{
-			name:    "a cleared icon is not published",
-			baseURL: "http://api.example.com",
-			id:      bartID(wire.GetClearIconHash()),
-			want:    "",
-		},
-		{
-			name:    "a lookup failure is not fatal, it just yields no icon",
-			baseURL: "http://api.example.com",
-			idErr:   errors.New("db exploded"),
-			want:    "",
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			iconRetriever := &MockBuddyIconRetriever{}
-			iconRetriever.On("BuddyIconMetadata", mock.Anything, state.NewIdentScreenName("mikekelly")).
-				Return(tt.id, tt.idErr).Once()
-
-			s := BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()}
-			got := s.URL(context.Background(), tt.baseURL, state.NewIdentScreenName("mikekelly"))
-
-			assert.Equal(t, tt.want, got)
-			iconRetriever.AssertExpectations(t)
-		})
-	}
-}
-
-func TestBuddyIconSource_URL_NoBaseURLSkipsLookup(t *testing.T) {
-	// Callers with no origin to build an absolute URL against opt out by passing
-	// an empty baseURL. That must not cost a lookup.
-	iconRetriever := &MockBuddyIconRetriever{}
-
-	s := BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()}
-	got := s.URL(context.Background(), "", state.NewIdentScreenName("mikekelly"))
-
-	assert.Empty(t, got)
-	iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, mock.Anything)
-}
-
-func TestBuddyIconSource_URL_UsesNormalizedScreenName(t *testing.T) {
-	// The URL targets the normalized screen name, which is what the endpoint
-	// resolves against and what the client keys users by.
-	iconRetriever := &MockBuddyIconRetriever{}
-	iconRetriever.On("BuddyIconMetadata", mock.Anything, mock.Anything).
-		Return(bartID([]byte{0x01}), nil).Once()
-
-	s := BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()}
-	got := s.URL(context.Background(), "http://api.example.com", state.NewIdentScreenName("Mike Kelly"))
-
-	assert.Equal(t, "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=01", got)
-}
-
-func TestBuddyIconSource_Image(t *testing.T) {
-	hash := []byte{0xde, 0xad}
-
-	iconRetriever := &MockBuddyIconRetriever{}
-	iconRetriever.On("BuddyIconMetadata", mock.Anything, state.NewIdentScreenName("mikekelly")).
-		Return(bartID(hash), nil).Once()
-
-	// Image resolves the current hash from metadata, then downloads that exact
-	// hash. The download query is keyed by hash; flags are irrelevant to the
-	// lookup, so it carries only the type and hash.
-	bartService := &MockBARTService{}
-	bartService.On("RetrieveItem", mock.Anything, wire.SNACFrame{}, wire.SNAC_0x10_0x04_BARTDownloadQuery{
-		ScreenName: "mikekelly",
-		BARTID:     wire.BARTID{Type: wire.BARTTypesBuddyIcon, BARTInfo: wire.BARTInfo{Hash: hash}},
-	}).Return(wire.SNACMessage{
-		Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: iconGIF},
-	}, nil).Once()
-
-	s := BuddyIconSource{IconRetriever: iconRetriever, BARTService: bartService, Logger: slog.Default()}
-	got, err := s.Image(context.Background(), state.NewIdentScreenName("mikekelly"))
-
-	assert.NoError(t, err)
-	assert.Equal(t, iconGIF, got)
-	iconRetriever.AssertExpectations(t)
-	bartService.AssertExpectations(t)
-}
-
-func TestBuddyIconSource_ImageForHash(t *testing.T) {
-	hash := []byte{0xca, 0xfe}
-
-	// ImageForHash downloads the requested hash directly, without consulting the
-	// user's current icon metadata.
-	bartService := &MockBARTService{}
-	bartService.On("RetrieveItem", mock.Anything, wire.SNACFrame{}, wire.SNAC_0x10_0x04_BARTDownloadQuery{
-		ScreenName: "mikekelly",
-		BARTID:     wire.BARTID{Type: wire.BARTTypesBuddyIcon, BARTInfo: wire.BARTInfo{Hash: hash}},
-	}).Return(wire.SNACMessage{
-		Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: iconGIF},
-	}, nil).Once()
-
-	s := BuddyIconSource{BARTService: bartService, Logger: slog.Default()}
-	got, err := s.ImageForHash(context.Background(), state.NewIdentScreenName("mikekelly"), hash)
-
-	assert.NoError(t, err)
-	assert.Equal(t, iconGIF, got)
-	bartService.AssertExpectations(t)
-}
-
-func TestBuddyIconSource_ImageForHash_NotFound(t *testing.T) {
-	bartService := &MockBARTService{}
-	bartService.On("RetrieveItem", mock.Anything, mock.Anything, mock.Anything).
-		Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{}}, nil).Once()
-
-	s := BuddyIconSource{BARTService: bartService, Logger: slog.Default()}
-	_, err := s.ImageForHash(context.Background(), state.NewIdentScreenName("mikekelly"), []byte{0x01})
-
-	assert.ErrorIs(t, err, ErrNoBuddyIcon)
-}
-
-func TestBuddyIconSource_PublishedURL(t *testing.T) {
-	tests := []struct {
-		name    string
-		baseURL string
-		id      *wire.BARTID
-		idErr   error
-		want    string
-	}{
-		{
-			name:    "an icon yields a content-addressed URL",
-			baseURL: "http://api.example.com",
-			id:      bartID([]byte{0xde, 0xad, 0xbe, 0xef}),
-			want:    "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=deadbeef",
-		},
-		{
-			name:    "no icon still yields a hash-less placeholder URL",
-			baseURL: "http://api.example.com",
-			id:      nil,
-			want:    "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon",
-		},
-		{
-			name:    "a cleared icon yields the placeholder URL",
-			baseURL: "http://api.example.com",
-			id:      bartID(wire.GetClearIconHash()),
-			want:    "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon",
-		},
-		{
-			name:    "a lookup failure yields no URL",
-			baseURL: "http://api.example.com",
-			idErr:   errors.New("db exploded"),
-			want:    "",
-		},
-		{
-			name:    "no base URL yields no URL",
-			baseURL: "",
-			id:      bartID([]byte{0x01}),
-			want:    "",
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			iconRetriever := &MockBuddyIconRetriever{}
-			iconRetriever.On("BuddyIconMetadata", mock.Anything, state.NewIdentScreenName("mikekelly")).
-				Return(tt.id, tt.idErr).Maybe()
-
-			s := BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()}
-			got := s.PublishedURL(context.Background(), tt.baseURL, state.NewIdentScreenName("mikekelly"))
-
-			assert.Equal(t, tt.want, got)
-		})
-	}
-}
-
-func TestBuddyIconSource_Image_NoIcon(t *testing.T) {
-	iconRetriever := &MockBuddyIconRetriever{}
-	iconRetriever.On("BuddyIconMetadata", mock.Anything, mock.Anything).Return(nil, nil).Once()
-
-	bartService := &MockBARTService{}
-
-	s := BuddyIconSource{IconRetriever: iconRetriever, BARTService: bartService, Logger: slog.Default()}
-	_, err := s.Image(context.Background(), state.NewIdentScreenName("mikekelly"))
-
-	assert.ErrorIs(t, err, ErrNoBuddyIcon)
-	// No icon means there is nothing to ask BART for.
-	bartService.AssertNotCalled(t, "RetrieveItem", mock.Anything, mock.Anything, mock.Anything)
-}
-
-func TestBuddyIconSource_URLForHash(t *testing.T) {
-	// URLForHash never touches the retriever: the hash is supplied by the caller.
-	s := BuddyIconSource{Logger: slog.Default()}
-	sn := state.NewIdentScreenName("Mike Kelly")
-
-	t.Run("hash yields the content-addressed URL", func(t *testing.T) {
-		assert.Equal(t,
-			"http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=deadbeef",
-			s.URLForHash("http://api.example.com", sn, []byte{0xde, 0xad, 0xbe, 0xef}))
-	})
-
-	t.Run("no hash yields the placeholder URL", func(t *testing.T) {
-		assert.Equal(t,
-			"http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon",
-			s.URLForHash("http://api.example.com", sn, nil))
-	})
-
-	t.Run("empty baseURL opts out", func(t *testing.T) {
-		assert.Empty(t, s.URLForHash("", sn, []byte{0x01}))
-	})
-}
-
-func TestBuddyIconSource_Image_RetrieveFails(t *testing.T) {
-	iconRetriever := &MockBuddyIconRetriever{}
-	iconRetriever.On("BuddyIconMetadata", mock.Anything, mock.Anything).
-		Return(bartID([]byte{0x01}), nil).Once()
-
-	bartService := &MockBARTService{}
-	bartService.On("RetrieveItem", mock.Anything, mock.Anything, mock.Anything).
-		Return(wire.SNACMessage{}, errors.New("item missing")).Once()
-
-	s := BuddyIconSource{IconRetriever: iconRetriever, BARTService: bartService, Logger: slog.Default()}
-	_, err := s.Image(context.Background(), state.NewIdentScreenName("mikekelly"))
-
-	assert.ErrorContains(t, err, "item missing")
-	assert.NotErrorIs(t, err, ErrNoBuddyIcon)
-}

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

@@ -1,82 +0,0 @@
-package handlers
-
-import (
-	"log/slog"
-	"net/http"
-	"strconv"
-
-	"github.com/mk6i/open-oscar-server/state"
-)
-
-// StoredIMsData is the fetchStoredIMs payload.
-type StoredIMsData struct {
-	Msgs []state.StoredIM `json:"msgs" xml:"msgs>msg"`
-}
-
-// 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, sess *state.WebAPISession) {
-	partner := r.URL.Query().Get("to")
-	if partner == "" {
-		SendError(w, r, 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 = &StoredIMsData{Msgs: msgs}
-	SendResponse(w, r, resp, h.Logger)
-}

+ 0 - 129
server/webapi/handlers/events.go

@@ -1,129 +0,0 @@
-package handlers
-
-import (
-	"context"
-	"fmt"
-	"log/slog"
-	"net/http"
-	"strconv"
-	"strings"
-	"time"
-
-	"github.com/mk6i/open-oscar-server/server/webapi/types"
-	"github.com/mk6i/open-oscar-server/state"
-)
-
-// EventsHandler handles Web AIM API event fetching endpoints.
-type EventsHandler struct {
-	SessionManager *state.WebAPISessionManager
-	Logger         *slog.Logger
-}
-
-// FetchEventsData contains the events and metadata.
-type FetchEventsData struct {
-	Events          []types.Event `json:"events" xml:"events>event"`
-	LastSeqNum      uint64        `json:"lastSeqNum" xml:"lastSeqNum"`
-	TimeToNextFetch int           `json:"timeToNextFetch" xml:"timeToNextFetch"`
-	FetchBaseURL    string        `json:"fetchBaseURL" xml:"fetchBaseURL"`
-}
-
-// FetchEvents handles GET /aim/fetchEvents requests with long-polling support.
-func (h *EventsHandler) FetchEvents(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
-	ctx := r.Context()
-	aimsid := session.AimSID
-
-	// Get sequence number parameter
-	var lastSeqNum uint64
-	if seqStr := r.URL.Query().Get("seqNum"); seqStr != "" {
-		if val, err := strconv.ParseUint(seqStr, 10, 64); err == nil {
-			lastSeqNum = val
-		}
-	}
-
-	// Timeout is in milliseconds (per Web API spec and client behavior).
-	timeout := time.Duration(session.FetchTimeout) * time.Millisecond
-	if timeoutStr := r.URL.Query().Get("timeout"); timeoutStr != "" {
-		if val, err := strconv.Atoi(timeoutStr); err == nil && val > 0 {
-			timeout = time.Duration(val) * time.Millisecond
-		}
-	}
-
-	// Limit maximum timeout to 60 seconds
-	if timeout > 60*time.Second {
-		timeout = 60 * time.Second
-	}
-
-	// Create a context with timeout for the fetch operation
-	fetchCtx, cancel := context.WithTimeout(ctx, timeout)
-	defer cancel()
-
-	// Fetch events from the queue (will block until events available or timeout)
-	events, err := session.EventQueue.Fetch(fetchCtx, lastSeqNum, timeout)
-	if err != nil {
-		if err == context.DeadlineExceeded {
-			// Timeout is normal - return empty events array
-			events = []types.Event{}
-		} else {
-			h.Logger.ErrorContext(ctx, "failed to fetch events", "err", err.Error())
-			h.sendError(w, r, http.StatusInternalServerError, "failed to fetch events")
-			return
-		}
-	}
-
-	// Determine the last sequence number
-	newLastSeqNum := lastSeqNum
-	if len(events) > 0 {
-		newLastSeqNum = events[len(events)-1].SeqNum
-	}
-
-	// Prepare response
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = &FetchEventsData{
-		Events:          events,
-		LastSeqNum:      newLastSeqNum,
-		TimeToNextFetch: session.TimeToNextFetch,
-		// Include fetchBaseURL with updated sequence number for next request
-		FetchBaseURL: fmt.Sprintf("%s/aim/fetchEvents?aimsid=%s&seqNum=%d",
-			baseURLFromRequest(r), aimsid, newLastSeqNum),
-	}
-
-	// AMF3 clients (e.g. Gromit) take the events reshaped: timestamps as floats
-	// and the source/dest user objects flattened. That is a payload difference,
-	// not just an encoding one, so it stays here rather than in the encoder.
-	format := strings.ToLower(r.URL.Query().Get("f"))
-	if format == "amf" || format == "amf3" {
-		amfResp := map[string]interface{}{
-			"response": map[string]interface{}{
-				"data": map[string]interface{}{
-					"events":          ConvertEventsForAMF3(events),
-					"lastSeqNum":      newLastSeqNum,
-					"timeToNextFetch": session.TimeToNextFetch,
-					"fetchBaseURL": fmt.Sprintf("%s/aim/fetchEvents?aimsid=%s&seqNum=%d",
-						baseURLFromRequest(r), aimsid, newLastSeqNum),
-				},
-				"statusCode":       200,
-				"statusText":       "OK",
-				"statusDetailCode": 0,
-			},
-		}
-		SendResponse(w, r, amfResp, h.Logger)
-	} else {
-		// Send response in requested format (JSON, JSONP, or XML)
-		SendResponse(w, r, resp, h.Logger)
-	}
-
-	if len(events) > 0 {
-		h.Logger.DebugContext(ctx, "events fetched",
-			"aimsid", aimsid,
-			"count", len(events),
-			"last_seq", newLastSeqNum,
-		)
-	}
-}
-
-// sendError is a convenience method that wraps the common SendError function.
-func (h *EventsHandler) sendError(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
-	SendError(w, r, statusCode, message)
-}

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

@@ -1,194 +0,0 @@
-package handlers
-
-import (
-	"encoding/base64"
-	"errors"
-	"html/template"
-	"net"
-	"net/http"
-	"net/url"
-	"strings"
-	"time"
-)
-
-// bosTokenCookie is the cookie the browser presents to getToken. The name is the
-// one AIM's own client knows, kept so a client running against the non-Web API
-// path finds what it expects.
-const bosTokenCookie = "oldAimToken"
-
-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
-		}
-
-		authCookie, err := h.authenticateCredentials(r.Context(), loginID, password, clientIDForDevID(data.DevID), shortTermTTL)
-		if err != nil {
-			if errors.Is(err, errInvalidCredentials) {
-				h.Logger.DebugContext(r.Context(), "login.psp failed", "loginId", loginID)
-				data.Error = "Invalid screen name or password."
-				h.renderLoginPSP(w, r, data)
-				return
-			}
-			h.Logger.ErrorContext(r.Context(), "login.psp could not authenticate", "loginId", loginID, "error", err)
-			http.Error(w, "internal server error", http.StatusInternalServerError)
-			return
-		}
-
-		setBOSTokenCookie(w, authCookie)
-
-		redirectURL := safeLoginRedirectURL(r, data.SuccURL)
-		h.Logger.InfoContext(r.Context(), "login.psp succeeded", "loginId", loginID, "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)
-	}
-}
-
-// setBOSTokenCookie hands the BOS token from the login response to the browser.
-func setBOSTokenCookie(w http.ResponseWriter, authCookie []byte) {
-	http.SetCookie(w, &http.Cookie{
-		Name:     bosTokenCookie,
-		Value:    base64.URLEncoding.EncodeToString(authCookie),
-		Path:     "/",
-		Expires:  time.Now().Add(shortTermTTL),
-		MaxAge:   int(shortTermTTL.Seconds()),
-		HttpOnly: true,
-		SameSite: http.SameSiteLaxMode,
-	})
-}
-
-// clearBOSTokenCookie expires the token cookie. getToken calls it on every
-// request, spending the token whether or not it was any good, so a reload finds
-// nothing to sign in with.
-func clearBOSTokenCookie(w http.ResponseWriter) {
-	http.SetCookie(w, &http.Cookie{
-		Name:     bosTokenCookie,
-		Value:    "",
-		Path:     "/",
-		Expires:  time.Unix(0, 0),
-		MaxAge:   -1,
-		HttpOnly: true,
-		SameSite: http.SameSiteLaxMode,
-	})
-}
-
-func defaultLoginSuccURL(r *http.Request) string {
-	return requestScheme(r) + "://" + 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
-}

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

@@ -1,197 +0,0 @@
-package handlers
-
-import (
-	"context"
-	"encoding/base64"
-	"errors"
-	"log/slog"
-	"net/http"
-	"net/http/httptest"
-	"net/url"
-	"strings"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-
-	"github.com/mk6i/open-oscar-server/config"
-	"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_Logout(t *testing.T) {
-	handler := &AuthHandler{Logger: slog.Default()}
-
-	req := httptest.NewRequest(http.MethodGet, "/auth/logout?f=json&a=sometoken&devId=dev1&succUrl=http%3A%2F%2Flocalhost%3A8000%2F.client%2F", nil)
-	rr := httptest.NewRecorder()
-
-	handler.Logout(rr, req)
-
-	assert.Equal(t, http.StatusFound, rr.Code)
-
-	loc, err := url.Parse(rr.Header().Get("Location"))
-	assert.NoError(t, err)
-	assert.Equal(t, "/_cqr/login/login.psp", loc.Path)
-	assert.Equal(t, "dev1", loc.Query().Get("devId"))
-	assert.Equal(t, "http://localhost:8000/.client/", loc.Query().Get("succUrl"))
-
-	// Signing out spends the token cookie, whether or not getToken already did.
-	// A 24h token left behind would sign the next person in as this account.
-	cleared := rr.Result().Cookies()
-	if assert.Len(t, cleared, 1) {
-		assert.Equal(t, bosTokenCookie, cleared[0].Name)
-		assert.Empty(t, cleared[0].Value)
-		assert.Less(t, cleared[0].MaxAge, 1)
-	}
-}
-
-func TestAuthHandler_LoginPSP_POST_Success(t *testing.T) {
-	var got wire.FLAPSignonFrame
-	handler := &AuthHandler{
-		AuthService: &testAuthService{
-			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
-				got = inFrame
-				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"))
-
-	set := make(map[string]*http.Cookie)
-	for _, c := range rr.Result().Cookies() {
-		set[c.Name] = c
-	}
-
-	// The cookie carries the BOS token from the login response, unchanged.
-	tokenCookie := set[bosTokenCookie]
-	if assert.NotNil(t, tokenCookie) {
-		assert.True(t, tokenCookie.HttpOnly)
-		raw, err := base64.URLEncoding.DecodeString(tokenCookie.Value)
-		assert.NoError(t, err)
-		assert.Equal(t, loginBlockCookie, raw)
-		// The browser drops it on the same schedule the server stops honouring it.
-		assert.Equal(t, 86400, tokenCookie.MaxAge)
-	}
-
-	for _, name := range []string{"RSP_USER", "RSP_LOCAL", "localAuthUser"} {
-		assert.NotContains(t, set, name)
-	}
-
-	// The Web API asks login for a token that outlives the browser round trip.
-	ttl, ok := got.Uint32BE(wire.LoginTLVTagsTokenTTL)
-	assert.True(t, ok)
-	assert.Equal(t, uint32(86400), ttl)
-
-	// The devId names the client on the resulting session.
-	clientID, ok := got.String(wire.LoginTLVTagsClientIdentity)
-	assert.True(t, ok, "signon frame should carry a client identity")
-	assert.Equal(t, "dev1", clientID)
-}
-
-func TestAuthHandler_LoginPSP_POST_ServiceErrors(t *testing.T) {
-	tests := []struct {
-		name      string
-		flapLogin func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error)
-	}{
-		{
-			name: "LoginResponseHasNoCookie",
-			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
-				return blockWithoutCookie(), nil
-			},
-		},
-		{
-			name: "AuthServiceUnreachable",
-			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error) {
-				return wire.TLVRestBlock{}, errors.New("boom")
-			},
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			handler := &AuthHandler{
-				AuthService: &testAuthService{flapLogin: tt.flapLogin},
-				Logger:      slog.Default(),
-			}
-
-			form := url.Values{}
-			form.Set("loginId", "testuser")
-			form.Set("password", "secret")
-			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)
-
-			// A broken auth service must not read as a mistyped password.
-			assert.Equal(t, http.StatusInternalServerError, rr.Code)
-			assert.NotContains(t, rr.Body.String(), "Invalid screen name or password")
-			assert.Empty(t, rr.Result().Cookies())
-		})
-	}
-}
-
-func TestAuthHandler_LoginPSP_POST_InvalidCredentials(t *testing.T) {
-	handler := &AuthHandler{
-		AuthService: &testAuthService{
-			flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (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 TestDefaultLoginSuccURL(t *testing.T) {
-	req := httptest.NewRequest(http.MethodGet, "http://ras.dev/_cqr/login/login.psp", nil)
-	assert.Equal(t, "http://ras.dev/", defaultLoginSuccURL(req))
-
-	// TLS terminated upstream, so the scheme only survives in the header.
-	req.Header.Set("X-Forwarded-Proto", "https")
-	assert.Equal(t, "https://ras.dev/", defaultLoginSuccURL(req))
-}
-
-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/"))
-}

+ 0 - 59
server/webapi/handlers/mocks_test.go

@@ -1,59 +0,0 @@
-package handlers
-
-import (
-	"context"
-	"log/slog"
-
-	"github.com/stretchr/testify/mock"
-
-	"github.com/mk6i/open-oscar-server/state"
-	"github.com/mk6i/open-oscar-server/wire"
-)
-
-// MockLocateService is a mock implementation of LocateService
-type MockLocateService struct {
-	mock.Mock
-}
-
-func (m *MockLocateService) SetInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x02_0x04_LocateSetInfo) error {
-	args := m.Called(ctx, instance, inBody)
-	return args.Error(0)
-}
-
-func (m *MockLocateService) UserInfoQuery(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
-	args := m.Called(ctx, instance, inFrame, inBody)
-	return args.Get(0).(wire.SNACMessage), args.Error(1)
-}
-
-// MockBuddyIconRetriever is a mock implementation of BuddyIconRetriever
-type MockBuddyIconRetriever struct {
-	mock.Mock
-}
-
-func (m *MockBuddyIconRetriever) BuddyIconMetadata(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error) {
-	args := m.Called(ctx, screenName)
-	id, _ := args.Get(0).(*wire.BARTID)
-	return id, args.Error(1)
-}
-
-// MockBARTService is a mock implementation of BARTService
-type MockBARTService struct {
-	mock.Mock
-}
-
-func (m *MockBARTService) RetrieveItem(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error) {
-	args := m.Called(ctx, inFrame, inBody)
-	return args.Get(0).(wire.SNACMessage), args.Error(1)
-}
-
-// newTestIconSource returns a BuddyIconSource whose users have no buddy icon,
-// for tests that are not exercising icons.
-func newTestIconSource() BuddyIconSource {
-	iconRetriever := &MockBuddyIconRetriever{}
-	iconRetriever.On("BuddyIconMetadata", mock.Anything, mock.Anything).Return(nil, nil).Maybe()
-	return BuddyIconSource{
-		IconRetriever: iconRetriever,
-		BARTService:   &MockBARTService{},
-		Logger:        slog.Default(),
-	}
-}

+ 0 - 174
server/webapi/handlers/oscar_bridge.go

@@ -1,174 +0,0 @@
-package handlers
-
-import (
-	"encoding/base64"
-	"encoding/xml"
-	"log/slog"
-	"net"
-	"net/http"
-	"strconv"
-	"strings"
-	"time"
-
-	"github.com/mk6i/open-oscar-server/config"
-	"github.com/mk6i/open-oscar-server/server/webapi/middleware"
-	"github.com/mk6i/open-oscar-server/state"
-)
-
-// OSCARBridgeHandler handles the handoff from the Web API's HTTP login to the
-// native OSCAR protocol, telling a client where to connect and what credential
-// to present.
-type OSCARBridgeHandler struct {
-	OSCARAuthService OSCARAuthService
-	Listener         config.ListenerGroup
-	Logger           *slog.Logger
-}
-
-// OSCARAuthService verifies the credential a client presents to the bridge.
-type OSCARAuthService interface {
-	CrackCookie(authCookie []byte) (state.ServerCookie, time.Time, error)
-}
-
-// StartOSCARSessionResponse represents the response for startOSCARSession endpoint.
-type StartOSCARSessionResponse struct {
-	Response struct {
-		StatusCode int    `json:"statusCode" xml:"statusCode"`
-		StatusText string `json:"statusText" xml:"statusText"`
-		Data       struct {
-			Host   string `json:"host" xml:"host"`
-			Port   int    `json:"port" xml:"port"`
-			Cookie string `json:"cookie" xml:"cookie"`
-			// TLSCertName is the certificate name the client verifies BOS against.
-			// Omitted rather than sent empty: its absence means connect in the clear.
-			TLSCertName string `json:"tlsCertName,omitempty" xml:"tlsCertName,omitempty"`
-		} `json:"data" xml:"data"`
-	} `json:"response"`
-}
-
-// MarshalXML renders the envelope with the same flat root as BaseResponse.
-func (s StartOSCARSessionResponse) MarshalXML(e *xml.Encoder, _ xml.StartElement) error {
-	return e.EncodeElement(s.Response, xml.StartElement{Name: xml.Name{Local: "response"}})
-}
-
-// StartOSCARSession handles GET /aim/startOSCARSession requests, which hand a
-// client that authenticated over HTTP the address of a BOS server and the
-// cookie to sign on with. The token in "a" is the auth cookie clientLogin
-// minted, already what BOS expects, so it is handed straight back.
-//
-// The sig_sha256 the client computes over the query string is not checked: that
-// signature is keyed by HMAC(password, sessionSecret), and clientLogin keeps
-// neither past the response.
-func (h *OSCARBridgeHandler) StartOSCARSession(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-
-	h.Logger.InfoContext(ctx, "startOSCARSession requested",
-		"method", r.Method,
-		"remote_addr", r.RemoteAddr,
-		"user_agent", r.UserAgent())
-
-	// Get API key info from context (set by auth middleware)
-	apiKey, ok := ctx.Value(middleware.ContextKeyAPIKey).(*state.WebAPIKey)
-	if !ok {
-		h.Logger.Error("API key not found in context")
-		SendError(w, r, http.StatusInternalServerError, "internal server error")
-		return
-	}
-
-	// Verify that this API key has permission to create OSCAR sessions
-	if !h.hasOSCARBridgeCapability(apiKey) {
-		h.Logger.Warn("API key lacks OSCAR bridge capability",
-			"dev_id", apiKey.DevID)
-		SendError(w, r, http.StatusForbidden, "OSCAR bridge not enabled for this application")
-		return
-	}
-
-	params := r.URL.Query()
-
-	token := params.Get("a")
-	if token == "" {
-		h.Logger.Warn("missing authentication token")
-		SendError(w, r, http.StatusUnauthorized, "authentication token required")
-		return
-	}
-
-	rawCookie, err := base64.URLEncoding.DecodeString(strings.TrimSpace(token))
-	if err != nil {
-		h.Logger.WarnContext(ctx, "invalid authentication token (base64)", "err", err.Error())
-		SendError(w, r, http.StatusUnauthorized, "invalid or expired token")
-		return
-	}
-
-	cookie, _, err := h.OSCARAuthService.CrackCookie(rawCookie)
-	if err != nil {
-		h.Logger.WarnContext(ctx, "invalid authentication token", "err", err.Error())
-		SendError(w, r, http.StatusUnauthorized, "invalid or expired token")
-		return
-	}
-
-	// Encryption the server cannot provide degrades to a plaintext host, which a
-	// client doing opportunistic encryption expects when no certificate is named.
-	// The sign-on cookie then crosses the wire in the clear, so the downgrade is
-	// logged rather than left to be inferred from the absent tlsCertName.
-	useTLS := h.parseBoolParam(params.Get("useTLS"))
-	endpoint := h.Listener.PlainEndpoint()
-	if useTLS {
-		ssl, ok := h.Listener.SSLEndpoint()
-		if !ok {
-			h.Logger.WarnContext(ctx, "TLS requested but no SSL listener is configured, advertising a plaintext BOS host",
-				"screen_name", cookie.ScreenName)
-			useTLS = false
-		} else {
-			endpoint = ssl
-		}
-	}
-
-	host, portStr, err := net.SplitHostPort(endpoint.AdvertisedHost())
-	if err != nil {
-		h.Logger.ErrorContext(ctx, "unable to split advertised BOS host", "err", err.Error())
-		SendError(w, r, http.StatusInternalServerError, "internal server error")
-		return
-	}
-	port, _ := strconv.Atoi(portStr)
-
-	resp := &StartOSCARSessionResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data.Host = host
-	resp.Response.Data.Port = port
-	// Base64, the encoding the client decodes the cookie with.
-	resp.Response.Data.Cookie = base64.StdEncoding.EncodeToString(rawCookie)
-	if useTLS {
-		// The advertised SSL host is the name the certificate is issued to.
-		resp.Response.Data.TLSCertName = host
-	}
-
-	SendResponse(w, r, resp, h.Logger)
-
-	h.Logger.InfoContext(ctx, "OSCAR session bridge created",
-		"screen_name", cookie.ScreenName,
-		"bos_host", host,
-		"bos_port", port,
-		"use_tls", useTLS)
-}
-
-// hasOSCARBridgeCapability checks if the API key has permission to create OSCAR bridges.
-func (h *OSCARBridgeHandler) hasOSCARBridgeCapability(apiKey *state.WebAPIKey) bool {
-	if len(apiKey.Capabilities) == 0 {
-		return true // No restrictions if capabilities not specified
-	}
-
-	// Check if OSCAR bridge is explicitly enabled
-	for _, cap := range apiKey.Capabilities {
-		if cap == "oscar_bridge" || cap == "*" {
-			return true
-		}
-	}
-
-	return false
-}
-
-// parseBoolParam parses a boolean parameter from query string.
-func (h *OSCARBridgeHandler) parseBoolParam(value string) bool {
-	value = strings.ToLower(value)
-	return value == "true" || value == "1" || value == "yes"
-}

+ 0 - 221
server/webapi/handlers/oscar_bridge_test.go

@@ -1,221 +0,0 @@
-package handlers
-
-import (
-	"context"
-	"encoding/base64"
-	"encoding/json"
-	"log/slog"
-	"net/http"
-	"net/http/httptest"
-	"testing"
-	"time"
-
-	"github.com/stretchr/testify/assert"
-
-	"github.com/mk6i/open-oscar-server/config"
-	"github.com/mk6i/open-oscar-server/server/webapi/middleware"
-	"github.com/mk6i/open-oscar-server/state"
-)
-
-// testListener is a listener group whose SSL half is present only when the
-// test asks for it.
-func testListener(sslAvailable bool) config.ListenerGroup {
-	g := config.ListenerGroup{
-		Name:                   "local",
-		BOSListenAddress:       "0.0.0.0:5190",
-		BOSAdvertisedHostPlain: "bos.example.com:5190",
-	}
-	if sslAvailable {
-		g.BOSListenAddressSSL = "0.0.0.0:5191"
-		g.BOSAdvertisedHostSSL = "ssl.example.com:5193"
-	}
-	return g
-}
-
-// bridgeRequest builds a startOSCARSession request carrying the API key the
-// middleware would have put on the context.
-func bridgeRequest(query string, apiKey *state.WebAPIKey) *http.Request {
-	req := httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?"+query, nil)
-	if apiKey != nil {
-		req = req.WithContext(context.WithValue(req.Context(), middleware.ContextKeyAPIKey, apiKey))
-	}
-	return req
-}
-
-// bridgeData is the data object of a successful startOSCARSession response.
-type bridgeData struct {
-	Response struct {
-		StatusCode int `json:"statusCode"`
-		Data       struct {
-			Host        string `json:"host"`
-			Port        int    `json:"port"`
-			Cookie      string `json:"cookie"`
-			TLSCertName string `json:"tlsCertName"`
-		} `json:"data"`
-	} `json:"response"`
-}
-
-func TestOSCARBridgeHandler_StartOSCARSession(t *testing.T) {
-	validToken := base64.URLEncoding.EncodeToString(signedCookieFor("testuser"))
-	unrestrictedKey := &state.WebAPIKey{DevID: "dev123"}
-
-	tests := []struct {
-		name         string
-		query        string
-		apiKey       *state.WebAPIKey
-		sslAvailable bool
-		expectedCode int
-		checkBody    func(t *testing.T, body string)
-	}{
-		{
-			// No tlsCertName, which is how the client reads "connect in the clear".
-			name:         "Success_Plaintext",
-			query:        "a=" + validToken,
-			apiKey:       unrestrictedKey,
-			expectedCode: http.StatusOK,
-			checkBody: func(t *testing.T, body string) {
-				got := decodeBridgeData(t, body)
-				assert.Equal(t, 200, got.Response.StatusCode)
-				assert.Equal(t, "bos.example.com", got.Response.Data.Host)
-				assert.Equal(t, 5190, got.Response.Data.Port)
-				assert.Empty(t, got.Response.Data.TLSCertName)
-			},
-		},
-		{
-			name:         "Success_TLS",
-			query:        "a=" + validToken + "&useTLS=1",
-			apiKey:       unrestrictedKey,
-			sslAvailable: true,
-			expectedCode: http.StatusOK,
-			checkBody: func(t *testing.T, body string) {
-				got := decodeBridgeData(t, body)
-				assert.Equal(t, "ssl.example.com", got.Response.Data.Host)
-				assert.Equal(t, 5193, got.Response.Data.Port)
-				// The certificate is issued to the host the client is sent to.
-				assert.Equal(t, "ssl.example.com", got.Response.Data.TLSCertName)
-			},
-		},
-		{
-			// Encryption the server cannot provide degrades to a plaintext host
-			// rather than failing the handoff.
-			name:         "TLSRequestedButUnavailable_DegradesToPlaintext",
-			query:        "a=" + validToken + "&useTLS=true",
-			apiKey:       unrestrictedKey,
-			sslAvailable: false,
-			expectedCode: http.StatusOK,
-			checkBody: func(t *testing.T, body string) {
-				got := decodeBridgeData(t, body)
-				assert.Equal(t, "bos.example.com", got.Response.Data.Host)
-				assert.Empty(t, got.Response.Data.TLSCertName)
-			},
-		},
-		{
-			name:         "Error_MissingToken",
-			query:        "",
-			apiKey:       unrestrictedKey,
-			expectedCode: http.StatusUnauthorized,
-			checkBody: func(t *testing.T, body string) {
-				assert.Contains(t, body, "authentication token required")
-			},
-		},
-		{
-			name:         "Error_TokenNotBase64",
-			query:        "a=not!valid!base64",
-			apiKey:       unrestrictedKey,
-			expectedCode: http.StatusUnauthorized,
-			checkBody: func(t *testing.T, body string) {
-				assert.Contains(t, body, "invalid or expired token")
-			},
-		},
-		{
-			// A well-formed token the baker refuses to crack: wrong signature or
-			// past its expiry.
-			name:         "Error_TokenFailsSignatureCheck",
-			query:        "a=" + base64.URLEncoding.EncodeToString([]byte("forged")),
-			apiKey:       unrestrictedKey,
-			expectedCode: http.StatusUnauthorized,
-			checkBody: func(t *testing.T, body string) {
-				assert.Contains(t, body, "invalid or expired token")
-			},
-		},
-		{
-			name:         "Error_NoAPIKeyOnContext",
-			query:        "a=" + validToken,
-			apiKey:       nil,
-			expectedCode: http.StatusInternalServerError,
-			checkBody: func(t *testing.T, body string) {
-				assert.Contains(t, body, "internal server error")
-			},
-		},
-		{
-			name:         "Error_APIKeyLacksBridgeCapability",
-			query:        "a=" + validToken,
-			apiKey:       &state.WebAPIKey{DevID: "dev123", Capabilities: []string{"presence"}},
-			expectedCode: http.StatusForbidden,
-			checkBody: func(t *testing.T, body string) {
-				assert.Contains(t, body, "OSCAR bridge not enabled")
-			},
-		},
-		{
-			name:         "Success_APIKeyGrantsBridgeCapability",
-			query:        "a=" + validToken,
-			apiKey:       &state.WebAPIKey{DevID: "dev123", Capabilities: []string{"presence", "oscar_bridge"}},
-			expectedCode: http.StatusOK,
-			checkBody: func(t *testing.T, body string) {
-				assert.Equal(t, 200, decodeBridgeData(t, body).Response.StatusCode)
-			},
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			handler := &OSCARBridgeHandler{
-				OSCARAuthService: &testAuthService{crackCookie: crackSignedCookie},
-				Listener:         testListener(tt.sslAvailable),
-				Logger:           slog.Default(),
-			}
-
-			rr := httptest.NewRecorder()
-			handler.StartOSCARSession(rr, bridgeRequest(tt.query, tt.apiKey))
-
-			assert.Equal(t, tt.expectedCode, rr.Code)
-			tt.checkBody(t, rr.Body.String())
-		})
-	}
-}
-
-// The token arrives URL-safe, the way clientLogin minted it, and goes back out in
-// standard base64, the alphabet the client decodes the sign-on cookie with. The
-// cookie bytes here encode differently under each.
-func TestOSCARBridgeHandler_StartOSCARSession_ReencodesCookie(t *testing.T) {
-	rawCookie := []byte{0xff, 0xef, 0xbe}
-	urlSafe := base64.URLEncoding.EncodeToString(rawCookie)
-	standard := base64.StdEncoding.EncodeToString(rawCookie)
-	assert.NotEqual(t, urlSafe, standard, "test cookie must distinguish the two alphabets")
-
-	var cracked []byte
-	handler := &OSCARBridgeHandler{
-		OSCARAuthService: &testAuthService{
-			crackCookie: func(authCookie []byte) (state.ServerCookie, time.Time, error) {
-				cracked = authCookie
-				return state.ServerCookie{ScreenName: "testuser"}, time.Now().Add(shortTermTTL), nil
-			},
-		},
-		Listener: testListener(false),
-		Logger:   slog.Default(),
-	}
-
-	rr := httptest.NewRecorder()
-	handler.StartOSCARSession(rr, bridgeRequest("a="+urlSafe, &state.WebAPIKey{DevID: "dev123"}))
-
-	assert.Equal(t, http.StatusOK, rr.Code)
-	assert.Equal(t, rawCookie, cracked, "the baker sees the decoded cookie")
-	assert.Equal(t, standard, decodeBridgeData(t, rr.Body.String()).Response.Data.Cookie)
-}
-
-func decodeBridgeData(t *testing.T, body string) bridgeData {
-	t.Helper()
-	got := bridgeData{}
-	assert.NoError(t, json.Unmarshal([]byte(body), &got))
-	return got
-}

+ 0 - 208
server/webapi/handlers/ratelimit.go

@@ -1,208 +0,0 @@
-package handlers
-
-import (
-	"fmt"
-	"log/slog"
-	"net/http"
-	"time"
-
-	"github.com/mk6i/open-oscar-server/server/webapi/types"
-	"github.com/mk6i/open-oscar-server/state"
-	"github.com/mk6i/open-oscar-server/wire"
-)
-
-// rateLimitStatusCode is the Web AIM API envelope code for a rate-limited
-// request. The AIM web client swallows 430 on the IM path so that the rateLimit
-// event owns the user-facing message instead of a generic send failure alert.
-const rateLimitStatusCode = 430
-
-// minRetryAfter floors the Retry-After hint sent with a rate-limited response.
-// The computed wait can round down to nothing when a class is barely over its
-// limit, and a hint of zero invites an immediate retry.
-const minRetryAfter = 1 * time.Second
-
-// SessionHandlerFunc is the session-aware handler shape that
-// AuthMiddleware.RequireSession invokes once it has resolved an aimsid.
-type SessionHandlerFunc = func(http.ResponseWriter, *http.Request, *state.WebAPISession)
-
-// RateLimitMiddleware enforces OSCAR rate limits on Web API routes that reach a
-// food group.
-//
-// Such routes are limited by OSCAR itself: OSCAR charges the session's shared
-// per-rate-class budget, the same budget a native OSCAR or TOC client spends, so
-// a user cannot dodge a limit by switching transports. Routes that reach no food
-// group are not limited here; edge rate limiting (a reverse proxy keyed by client
-// IP) is expected to cover the unauthenticated login/asset endpoints and the
-// authenticated bookkeeping ones.
-//
-// It lives alongside the handlers (rather than in the middleware package) so that
-// its rejection can be encoded through the same SendResponse path the handlers
-// use, honoring the request's JSON/JSONP/XML/AMF format.
-//
-// It only enforces the limit (the 430 rejection). Telling the client its status
-// changed is the job of OServiceService.MonitorRateLimits.
-type RateLimitMiddleware struct {
-	snacRateLimits wire.SNACRateLimits
-	logger         *slog.Logger
-}
-
-// NewRateLimitMiddleware creates a RateLimitMiddleware. snacRateLimits is the
-// same SNAC-to-rate-class mapping the OSCAR and TOC servers use.
-func NewRateLimitMiddleware(snacRateLimits wire.SNACRateLimits, logger *slog.Logger) *RateLimitMiddleware {
-	return &RateLimitMiddleware{
-		snacRateLimits: snacRateLimits,
-		logger:         logger,
-	}
-}
-
-// OSCAR returns middleware that charges one unit against the OSCAR rate class
-// mapped to (foodGroup, subGroup) before invoking the wrapped handler. It is the
-// HTTP counterpart of the TOC server's per-command rate check.
-//
-// A SNAC with no rate class mapping is allowed through, since refusing traffic
-// because the server's own table is incomplete would be worse than not limiting
-// it.
-func (l *RateLimitMiddleware) OSCAR(foodGroup uint16, subGroup uint16) func(SessionHandlerFunc) SessionHandlerFunc {
-	return func(next SessionHandlerFunc) SessionHandlerFunc {
-		return func(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
-			ctx := r.Context()
-
-			rateClassID, ok := l.snacRateLimits.RateClassLookup(foodGroup, subGroup)
-			if !ok {
-				l.logger.ErrorContext(ctx, "rate limit not found, allowing request through",
-					"foodgroup", wire.FoodGroupName(foodGroup),
-					"subgroup", wire.SubGroupName(foodGroup, subGroup))
-				next(w, r, session)
-				return
-			}
-
-			sess := session.OSCARSession.Session()
-			status := sess.EvaluateRateLimit(time.Now(), rateClassID)
-
-			// Disconnect is rejected alongside Limited: EvaluateRateLimit has
-			// already closed the account's OSCAR session by the time it returns,
-			// so there is nothing left for the handler to act on. That close also
-			// invalidates the aimsid (GetSession stops resolving a session whose
-			// OSCAR instance is closed), so every subsequent request is turned
-			// away at RequireSession rather than reaching here again.
-			if status == wire.RateLimitStatusLimited || status == wire.RateLimitStatusDisconnect {
-				l.logger.DebugContext(ctx, "(webapi) rate limit exceeded, dropping request",
-					"foodgroup", wire.FoodGroupName(foodGroup),
-					"subgroup", wire.SubGroupName(foodGroup, subGroup),
-					"status", rateLimitStatusName(status))
-
-				// A disconnected session has no aimsid left to retry with, so
-				// there is no wait to advertise.
-				var retryAfter time.Duration
-				if status == wire.RateLimitStatusLimited {
-					retryAfter = retryAfterFor(sess.RateLimitStates()[rateClassID-1])
-				}
-				l.sendRateLimited(w, r, retryAfter)
-				return
-			}
-
-			next(w, r, session)
-		}
-	}
-}
-
-// retryAfterFor returns how long the client must wait for its next request on
-// this class to clear the limit.
-//
-// OSCAR's limiter has no fixed window: it tracks a moving average of the gap
-// between requests, and a request lifts the limit only once that average climbs
-// back to ClearLevel. Inverting CheckRateLimit's update for the elapsed time that
-// lands the new average exactly on ClearLevel gives
-//
-//	elapsed = ClearLevel*WindowSize - CurrentLevel*(WindowSize-1)
-//
-// A flat hint cannot work here, because a rejected request is still charged: a
-// client retrying on a fixed interval drives the average toward that interval, so
-// any hint below the class's ClearLevel holds the average just under the bar and
-// the client stays limited forever. The production ICBM class clears at 5100ms,
-// which a 5s hint would do exactly.
-func retryAfterFor(rcs state.RateClassState) time.Duration {
-	neededMs := int64(rcs.ClearLevel)*int64(rcs.WindowSize) - int64(rcs.CurrentLevel)*int64(rcs.WindowSize-1)
-
-	// Retry-After carries whole seconds, so round up: a hint that is short by a
-	// fraction of a second reproduces the same never-clears loop. A class barely
-	// over its limit can compute to no wait at all, hence the floor.
-	return max(time.Duration((neededMs+999)/1000)*time.Second, minRetryAfter)
-}
-
-// seedRateLimitAlert raises the client's rate limit alert when a session starts
-// on an account that is already rate limited.
-//
-// The monitor broadcasts transitions, not current state, so a session signing on
-// mid-limit missed the one that raised the alert — and the client's alert is
-// sticky, so the eventual "clear" would arrive with nothing to dismiss. An OSCAR
-// client learns the current state from the rate params it gets at handshake; this
-// is the Web API's equivalent.
-//
-// Only the limited state is seeded: alert is a warning the user cannot act on,
-// and seeding clear would render nothing.
-func seedRateLimitAlert(session *state.WebAPISession, classID wire.RateLimitClassID) {
-	if classID == 0 {
-		return
-	}
-
-	status := session.OSCARSession.Session().RateLimitStates()[classID-1].CurrentStatus
-	if status != wire.RateLimitStatusLimited {
-		return
-	}
-
-	session.EventQueue.Push(types.EventTypeRateLimit, types.RateLimitEvent{
-		Classes: []types.RateLimitClass{
-			{
-				ID:     int(classID),
-				Status: rateLimitStatusName(status),
-			},
-		},
-	})
-}
-
-// rateLimitStatusName maps an OSCAR rate limit status onto the status string the
-// web client switches on. It returns "" for a status the client does not know.
-func rateLimitStatusName(status wire.RateLimitStatus) string {
-	switch status {
-	case wire.RateLimitStatusClear:
-		return "clear"
-	case wire.RateLimitStatusAlert:
-		return "warn"
-	case wire.RateLimitStatusLimited:
-		return "limit"
-	case wire.RateLimitStatusDisconnect:
-		return "disconnect"
-	default:
-		return ""
-	}
-}
-
-// sendRateLimited writes a rate limit rejection. The transport status is 200 and
-// the rejection lives entirely in the Web AIM API envelope's own rate limit code.
-//
-// The transport status is deliberately not 429: the AIM client's WIM request layer
-// (XhrManager) and its Fetcher only parse the response body on a 2xx. A non-2xx is
-// routed to their error handlers, which synthesize a generic "request failed"
-// result and never look at the body, so the envelope's 430 — which the client
-// swallows on the IM path in favor of the rateLimit event — would go unread and the
-// user would see a generic send failure instead.
-//
-// The body is encoded via SendResponse, so it honors the request's format
-// (JSON/JSONP/XML/AMF) and echoes the request id into response.requestId — which
-// the JSONP fallback needs to correlate the reply, or its UI hangs — exactly as a
-// normal handler response would.
-//
-// A retryAfter of zero sends no Retry-After header, for the rejections that have
-// nothing to retry.
-func (l *RateLimitMiddleware) sendRateLimited(w http.ResponseWriter, r *http.Request, retryAfter time.Duration) {
-	if retryAfter > 0 {
-		w.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
-	}
-
-	resp := BaseResponse{}
-	resp.Response.StatusCode = rateLimitStatusCode
-	resp.Response.StatusText = "rate limit exceeded"
-
-	SendResponse(w, r, resp, l.logger)
-}

+ 0 - 28
server/webapi/handlers/service_stub.go

@@ -1,28 +0,0 @@
-package handlers
-
-import (
-	"log/slog"
-	"net/http"
-)
-
-// ServiceStubHandler serves the /service/* endpoints that manage third-party
-// service linking (Google Talk, Facebook), none of which this server federates.
-type ServiceStubHandler struct {
-	Logger *slog.Logger
-}
-
-// statusNoSuchService is the envelope status the Web AIM client accepts as
-// "this account has no such linked service". Its getAttributes callback treats
-// 601 as an expected outcome and returns early; any other status sends it into
-// the success branch, where it dereferences response.data.serviceName and marks
-// the service associated. A 404 therefore both crashes the callback and, if it
-// did not, would advertise a Google Talk link that does not exist.
-const statusNoSuchService = 601
-
-// GetAttributes reports that the requested third-party service is not linked.
-func (h *ServiceStubHandler) GetAttributes(w http.ResponseWriter, r *http.Request) {
-	resp := BaseResponse{}
-	resp.Response.StatusCode = statusNoSuchService
-	resp.Response.StatusText = "Service not available"
-	SendResponse(w, r, resp, h.Logger)
-}

+ 0 - 46
server/webapi/handlers/session_test.go

@@ -1,46 +0,0 @@
-package handlers
-
-import (
-	"encoding/json"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-
-	"github.com/mk6i/open-oscar-server/state"
-)
-
-func TestBuildMyInfo_UserTypeAndService(t *testing.T) {
-	tests := []struct {
-		name       string
-		screenName string
-		wantType   string
-		wantSvc    string
-	}{
-		{"aim screen name", "mikekelly", "aim", "AIM"},
-		{"icq uin", "123456789", "icq", "ICQ"},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			mi := buildMyInfo(state.DisplayScreenName(tt.screenName), "online", "")
-			assert.Equal(t, tt.wantType, mi.UserType)
-			assert.Equal(t, tt.wantSvc, mi.Service)
-		})
-	}
-}
-
-func TestBuildMyInfo_BuddyIcon(t *testing.T) {
-	t.Run("included when set", func(t *testing.T) {
-		mi := buildMyInfo(state.DisplayScreenName("mikekelly"), "away", "http://x/icon")
-		assert.Equal(t, "http://x/icon", mi.BuddyIcon)
-	})
-	t.Run("omitted when empty so the client merge preserves the current icon", func(t *testing.T) {
-		mi := buildMyInfo(state.DisplayScreenName("mikekelly"), "away", "")
-		assert.Empty(t, mi.BuddyIcon)
-
-		// omitempty is what actually keeps it out of the payload.
-		body, err := json.Marshal(mi)
-		assert.NoError(t, err)
-		assert.NotContains(t, string(body), "buddyIcon")
-	})
-}

+ 0 - 226
server/webapi/handlers/webapi_event_converter.go

@@ -1,226 +0,0 @@
-package handlers
-
-import "github.com/mk6i/open-oscar-server/server/webapi/types"
-
-// ConvertEventForAMF3 converts a WebAPIEvent to a map suitable for AMF3 encoding,
-// ensuring all timestamps are float64 to avoid uint29 overflow issues.
-func ConvertEventForAMF3(event types.Event) map[string]interface{} {
-	result := map[string]interface{}{
-		"type":      string(event.Type),
-		"seqNum":    event.SeqNum,
-		"timestamp": float64(event.Timestamp), // Convert to float64
-	}
-
-	// Convert event data based on type
-	switch event.Type {
-	case types.EventTypeIM:
-		if imEvent, ok := event.Data.(types.IMEvent); ok {
-			// Gromit expects 'source' as a user object and 'autoresponse' (lowercase)
-			eventData := map[string]interface{}{
-				"source": map[string]interface{}{
-					"aimId":     imEvent.Source.AimID,
-					"displayId": imEvent.Source.DisplayID,
-					"userType":  imEvent.Source.UserType,
-					"state":     imEvent.Source.State,
-				},
-				"message":      imEvent.Message,
-				"timestamp":    imEvent.Timestamp, // Already float64
-				"autoresponse": imEvent.AutoResp,
-			}
-			if imEvent.MsgID != "" {
-				eventData["msgId"] = imEvent.MsgID
-			}
-			result["eventData"] = eventData
-		} else if dataMap, ok := event.Data.(map[string]interface{}); ok {
-			// Already a map, ensure timestamps are float64
-			if ts, exists := dataMap["timestamp"]; exists {
-				if tsInt, ok := ts.(int64); ok {
-					dataMap["timestamp"] = float64(tsInt)
-				}
-			}
-			result["eventData"] = dataMap
-		} else {
-			result["eventData"] = event.Data
-		}
-
-	case types.EventTypeOfflineIM:
-		if imEvent, ok := event.Data.(types.OfflineIMEvent); ok {
-			eventData := map[string]interface{}{
-				"aimId":        imEvent.AimID,
-				"message":      imEvent.Message,
-				"timestamp":    imEvent.Timestamp, // Already float64
-				"autoresponse": imEvent.AutoResp,
-			}
-			// The client keys its conversation list and chat-log cache by msgId, so
-			// an event without one collides with every other offline message.
-			if imEvent.MsgID != "" {
-				eventData["msgId"] = imEvent.MsgID
-			}
-			result["eventData"] = eventData
-		} else if dataMap, ok := event.Data.(map[string]interface{}); ok {
-			// Already a map, ensure timestamps are float64
-			if ts, exists := dataMap["timestamp"]; exists {
-				if tsInt, ok := ts.(int64); ok {
-					dataMap["timestamp"] = float64(tsInt)
-				}
-			}
-			result["eventData"] = dataMap
-		} else {
-			result["eventData"] = event.Data
-		}
-
-	case types.EventTypePresence:
-		if presenceEvent, ok := event.Data.(types.PresenceEvent); ok {
-			eventData := map[string]interface{}{
-				"aimId":    presenceEvent.AimID,
-				"state":    presenceEvent.State,
-				"userType": presenceEvent.UserType,
-			}
-			// Convert timestamp fields to float64
-			if presenceEvent.OnlineTime > 0 {
-				eventData["onlineTime"] = float64(presenceEvent.OnlineTime)
-			}
-			// This branch flattens PresenceEvent through an explicit allowlist, so
-			// buddyIcon must be added here or it never reaches an AMF3 client.
-			if presenceEvent.BuddyIcon != "" {
-				eventData["buddyIcon"] = presenceEvent.BuddyIcon
-			}
-			result["eventData"] = eventData
-		} else if dataMap, ok := event.Data.(map[string]interface{}); ok {
-			// Already a map, ensure timestamps are float64
-			if ot, exists := dataMap["onlineTime"]; exists {
-				if otInt, ok := ot.(int64); ok {
-					dataMap["onlineTime"] = float64(otInt)
-				}
-			}
-			result["eventData"] = dataMap
-		} else {
-			result["eventData"] = event.Data
-		}
-
-	case types.EventType("myInfo"):
-		// MyInfo events often contain timestamps
-		if dataMap, ok := event.Data.(map[string]interface{}); ok {
-			// Convert any int64 timestamps to float64
-			for key, val := range dataMap {
-				if key == "onlineTime" || key == "memberSince" || key == "awayTime" || key == "statusTime" {
-					if intVal, ok := val.(int64); ok {
-						dataMap[key] = float64(intVal)
-					}
-				}
-			}
-			result["eventData"] = dataMap
-		} else {
-			result["eventData"] = event.Data
-		}
-
-	case types.EventTypeBuddyList:
-		// Just pass through
-		result["eventData"] = event.Data
-
-	case types.EventTypeTyping:
-		result["eventData"] = event.Data
-
-	case types.EventTypeSentIM:
-		if sentIMEvent, ok := event.Data.(types.SentIMEvent); ok {
-			// Gromit expects both 'source' (sender) and 'dest' (recipient) for sentIM
-			// The parseIM function needs source even for outgoing messages
-			eventData := map[string]interface{}{
-				"source": map[string]interface{}{
-					"aimId":     sentIMEvent.Sender.AimID,
-					"displayId": sentIMEvent.Sender.DisplayID,
-					"userType":  sentIMEvent.Sender.UserType,
-					"state":     "online",
-				},
-				"dest": map[string]interface{}{
-					"aimId":     sentIMEvent.Dest.AimID,
-					"displayId": sentIMEvent.Dest.DisplayID,
-					"userType":  sentIMEvent.Dest.UserType,
-					"state":     "online",
-				},
-				"message":      sentIMEvent.Message,
-				"timestamp":    sentIMEvent.Timestamp, // Already float64
-				"autoresponse": sentIMEvent.AutoResp,
-			}
-			if sentIMEvent.MsgID != "" {
-				eventData["msgId"] = sentIMEvent.MsgID
-			}
-			result["eventData"] = eventData
-		} else {
-			result["eventData"] = event.Data
-		}
-
-	default:
-		// For unknown types, check if data is a map and convert any int64 values
-		if dataMap, ok := event.Data.(map[string]interface{}); ok {
-			result["eventData"] = convertTimestampsInMap(dataMap)
-		} else {
-			result["eventData"] = event.Data
-		}
-	}
-
-	return result
-}
-
-// convertTimestampsInMap recursively converts int64 values that look like timestamps to float64
-func convertTimestampsInMap(data map[string]interface{}) map[string]interface{} {
-	result := make(map[string]interface{})
-	for key, val := range data {
-		// Check if key suggests it's a timestamp
-		if isTimestampField(key) {
-			if intVal, ok := val.(int64); ok {
-				result[key] = float64(intVal)
-				continue
-			}
-		}
-
-		// Recursively process nested maps
-		if nestedMap, ok := val.(map[string]interface{}); ok {
-			result[key] = convertTimestampsInMap(nestedMap)
-		} else if nestedSlice, ok := val.([]interface{}); ok {
-			convertedSlice := make([]interface{}, len(nestedSlice))
-			for i, item := range nestedSlice {
-				if itemMap, ok := item.(map[string]interface{}); ok {
-					convertedSlice[i] = convertTimestampsInMap(itemMap)
-				} else {
-					convertedSlice[i] = item
-				}
-			}
-			result[key] = convertedSlice
-		} else {
-			result[key] = val
-		}
-	}
-	return result
-}
-
-// isTimestampField checks if a field name suggests it contains a timestamp
-func isTimestampField(fieldName string) bool {
-	timestampFields := []string{
-		"timestamp", "Timestamp",
-		"onlineTime", "OnlineTime",
-		"memberSince", "MemberSince",
-		"awayTime", "AwayTime",
-		"statusTime", "StatusTime",
-		"idleTime", "IdleTime",
-		"loginTime", "LoginTime",
-		"createdAt", "CreatedAt",
-		"updatedAt", "UpdatedAt",
-	}
-
-	for _, tf := range timestampFields {
-		if fieldName == tf {
-			return true
-		}
-	}
-	return false
-}
-
-// ConvertEventsForAMF3 converts a slice of WebAPIEvents for AMF3 encoding
-func ConvertEventsForAMF3(events []types.Event) []interface{} {
-	result := make([]interface{}, len(events))
-	for i, event := range events {
-		result[i] = ConvertEventForAMF3(event)
-	}
-	return result
-}

+ 0 - 64
server/webapi/handlers/webapi_event_converter_test.go

@@ -1,64 +0,0 @@
-package handlers
-
-import (
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-
-	"github.com/mk6i/open-oscar-server/server/webapi/types"
-)
-
-// The AMF3 converter re-flattens PresenceEvent through an explicit allowlist, so a
-// field absent from that allowlist never reaches an AMF3 client. buddyIcon must be
-// on it.
-func TestConvertEventForAMF3_PresenceCarriesBuddyIcon(t *testing.T) {
-	t.Run("buddyIcon is included when set", func(t *testing.T) {
-		out := ConvertEventForAMF3(types.Event{
-			Type: types.EventTypePresence,
-			Data: types.PresenceEvent{
-				AimID:     "mikekelly",
-				State:     "online",
-				UserType:  "aim",
-				BuddyIcon: "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=dead",
-			},
-		})
-
-		eventData := out["eventData"].(map[string]interface{})
-		assert.Equal(t,
-			"http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=dead",
-			eventData["buddyIcon"])
-	})
-
-	t.Run("buddyIcon is omitted when empty", func(t *testing.T) {
-		out := ConvertEventForAMF3(types.Event{
-			Type: types.EventTypePresence,
-			Data: types.PresenceEvent{AimID: "mikekelly", State: "offline", UserType: "aim"},
-		})
-
-		eventData := out["eventData"].(map[string]interface{})
-		_, ok := eventData["buddyIcon"]
-		assert.False(t, ok)
-	})
-}
-
-// The AMF3 converter flattens OfflineIMEvent through an explicit allowlist. The
-// client keys its conversation list and chat-log cache by msgId, so an event that
-// loses it collides with every other offline message.
-func TestConvertEventForAMF3_OfflineIM(t *testing.T) {
-	out := ConvertEventForAMF3(types.Event{
-		Type: types.EventTypeOfflineIM,
-		Data: types.OfflineIMEvent{
-			AimID:     "mikekelly",
-			Message:   "sent while you were out",
-			MsgID:     "beefcafe",
-			Timestamp: 1700000000,
-		},
-	})
-
-	eventData := out["eventData"].(map[string]interface{})
-	assert.Equal(t, "mikekelly", eventData["aimId"])
-	assert.Equal(t, "sent while you were out", eventData["message"])
-	assert.Equal(t, "beefcafe", eventData["msgId"])
-	assert.Equal(t, float64(1700000000), eventData["timestamp"])
-	assert.Equal(t, false, eventData["autoresponse"])
-}

+ 104 - 0
server/webapi/helpers_test.go

@@ -0,0 +1,104 @@
+package webapi
+
+import (
+	"log/slog"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+)
+
+// newTestIconSource returns a BuddyIconSource whose users have no buddy icon,
+// for tests that are not exercising icons.
+func newTestIconSource(t *testing.T) BuddyIconSource {
+	iconRetriever := newMockBuddyIconRetriever(t)
+	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).Return(nil, nil).Maybe()
+	return BuddyIconSource{
+		IconRetriever: iconRetriever,
+		BARTService:   newMockBARTService(t),
+		Logger:        slog.Default(),
+	}
+}
+
+// tightRateLimitClasses returns rate classes scaled down so tests run fast.
+//
+// OSCAR's moving average tracks the interval between requests in milliseconds,
+// seeded at MaxLevel, and each back-to-back request halves it at WindowSize 2.
+// So from 200 the sequence is 100 (clear), 50 (limited), 25, 12, 6 — the second
+// request trips the limit, and none of the first five fall below the disconnect
+// threshold. Recovering past ClearLevel takes a ~150ms pause rather than the
+// several seconds the production classes would need.
+func tightRateLimitClasses() wire.RateLimitClasses {
+	var classes [5]wire.RateClass
+	for i := range classes {
+		classes[i] = wire.RateClass{
+			ID:              wire.RateLimitClassID(i + 1),
+			WindowSize:      2,
+			ClearLevel:      100,
+			AlertLevel:      80,
+			LimitLevel:      70,
+			DisconnectLevel: 2,
+			MaxLevel:        200,
+		}
+	}
+	return wire.NewRateLimitClasses(classes)
+}
+
+// newTestOSCARInstance builds an OSCAR session with rate limit state
+// initialized, mirroring what RegisterBOSSession does at startSession time.
+func newTestOSCARInstance(t *testing.T, classes wire.RateLimitClasses) *state.SessionInstance {
+	t.Helper()
+
+	instance := state.NewSession().AddInstance()
+	instance.Session().SetIdentScreenName(state.NewIdentScreenName("me"))
+	instance.Session().SetDisplayScreenName("me")
+	instance.Session().SetRateClasses(time.Now(), classes)
+
+	return instance
+}
+
+// newTestWebAPISessionOn builds a WebAPI session over an existing OSCAR
+// instance. Two of them model two browser tabs signed in as the same account:
+// each tab holds its own aimsid and its own Session, but the account has
+// one OSCAR session and therefore one set of rate limit states.
+func newTestWebAPISessionOn(aimsid string, instance *state.SessionInstance) *Session {
+	return &Session{
+		AimSID:       aimsid,
+		ScreenName:   "me",
+		OSCARSession: instance,
+		EventQueue:   NewEventQueue(10),
+	}
+}
+
+// newTestWebAPISession builds a WebAPI session backed by a real OSCAR session
+// with rate limit state initialized.
+func newTestWebAPISession(t *testing.T, classes wire.RateLimitClasses) *Session {
+	t.Helper()
+
+	return newTestWebAPISessionOn("aimsid-1", newTestOSCARInstance(t, classes))
+}
+
+// rateLimitEventStatuses returns the status string of every rateLimit event
+// queued on the session, in order.
+func rateLimitEventStatuses(t *testing.T, session *Session) []string {
+	t.Helper()
+
+	var statuses []string
+	for _, event := range session.EventQueue.GetAllEvents() {
+		if event.Type != EventTypeRateLimit {
+			continue
+		}
+		payload, ok := event.Data.(RateLimitEvent)
+		if !assert.True(t, ok, "rateLimit event carried %T", event.Data) {
+			continue
+		}
+		if assert.Len(t, payload.Classes, 1) {
+			statuses = append(statuses, payload.Classes[0].Status)
+		}
+	}
+	return statuses
+}

+ 81 - 54
server/webapi/handlers/messaging.go → server/webapi/im_handler.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"context"
@@ -7,23 +7,15 @@ import (
 	"fmt"
 	"log/slog"
 	"net/http"
+	"strconv"
 	"time"
 
-	"github.com/mk6i/open-oscar-server/server/webapi/types"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-// ICBMService defines methods for ICBM operations
-type ICBMService interface {
-	ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
-	ClientEvent(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error
-	OfflineRetrieve(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error)
-}
-
 // MessagingHandler handles Web AIM API messaging endpoints
 type MessagingHandler struct {
-	SessionManager *state.WebAPISessionManager
 	ICBMService    ICBMService
 	LocateService  LocateService
 	FeedbagService FeedbagService
@@ -46,19 +38,19 @@ func queryOrFormParam(r *http.Request, key string) string {
 }
 
 // SendIM handles the /im/sendIM endpoint for sending instant messages
-func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
+func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *Session) {
 	ctx := r.Context()
 
 	// Parse parameters
 	recipient := queryOrFormParam(r, "t")
 	if recipient == "" {
-		h.sendErrorResponse(w, r, http.StatusBadRequest, "missing required parameter: t (recipient)")
+		SendError(w, r, http.StatusBadRequest, "missing required parameter: t (recipient)")
 		return
 	}
 
 	message := queryOrFormParam(r, "message")
 	if message == "" {
-		h.sendErrorResponse(w, r, http.StatusBadRequest, "missing required parameter: message")
+		SendError(w, r, http.StatusBadRequest, "missing required parameter: message")
 		return
 	}
 
@@ -72,7 +64,7 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 	var cookie [8]byte
 	if _, err := rand.Read(cookie[:]); err != nil {
 		h.Logger.ErrorContext(ctx, "failed to generate message cookie", "error", err)
-		h.sendErrorResponse(w, r, http.StatusInternalServerError, "internal server error")
+		SendError(w, r, http.StatusInternalServerError, "internal server error")
 		return
 	}
 	cookieUint64 := binary.BigEndian.Uint64(cookie[:])
@@ -102,7 +94,7 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 	// Add message data
 	frags, err := wire.ICBMFragmentList(message)
 	if err != nil {
-		h.sendErrorResponse(w, r, http.StatusInternalServerError, "failed to send message")
+		SendError(w, r, http.StatusInternalServerError, "failed to send message")
 		return
 	}
 
@@ -127,7 +119,7 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 	resp, err := h.ICBMService.ChannelMsgToHost(r.Context(), sess.OSCARSession, frame, clientIM)
 
 	if err != nil {
-		h.sendErrorResponse(w, r, http.StatusInternalServerError, "failed to send message")
+		SendError(w, r, http.StatusInternalServerError, "failed to send message")
 		return
 	}
 
@@ -171,29 +163,17 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 	h.Logger.DebugContext(ctx, "queued sentIM event for sender",
 		"from", sess.ScreenName.String(),
 		"to", recipient,
-		"eventType", types.EventTypeSentIM,
+		"eventType", EventTypeSentIM,
 	)
 
 	// Send success response
 	responseData := &SendIMData{MsgID: messageID, State: "delivered"}
-	response := BaseResponse{}
-	response.Response.StatusCode = 200
-	response.Response.StatusText = "OK"
-	response.Response.Data = responseData
-	SendResponse(w, r, response, h.Logger)
+	SendOK(w, r, responseData, h.Logger)
 }
 
 // sendUndeliverable reports an IM the server accepted but could not deliver.
-//
-// The status travels in the envelope with HTTP 200, because the web client reads
-// response.statusCode and only recognizes 602/603 as "recipient offline or
-// blocked". Any other code, and an empty body most of all, falls through to its
-// generic "Bummer. Your message failed." alert.
 func (h *MessagingHandler) sendUndeliverable(w http.ResponseWriter, r *http.Request, statusText string) {
-	response := BaseResponse{}
-	response.Response.StatusCode = 602
-	response.Response.StatusText = statusText
-	SendResponse(w, r, response, h.Logger)
+	SendEnvelopeStatus(w, r, statusSendFailed, statusText, h.Logger)
 }
 
 // resolveDisplayName returns the recipient's screen name as they formatted it,
@@ -227,17 +207,17 @@ func (h *MessagingHandler) resolveDisplayName(ctx context.Context, instance *sta
 // "Mike Lee" to "mikelee" the moment you message him. Omitting displayId leaves the
 // client's existing name untouched. The merge also deletes any alias it holds, so
 // friendly has to be repeated here even though the buddy list already sent it.
-func (h *MessagingHandler) pushSenderWebAPIEvents(sess *state.WebAPISession, recipient state.IdentScreenName, recipientDisplay, recipientAlias, message, messageID string, now float64, autoResponse bool) {
+func (h *MessagingHandler) pushSenderWebAPIEvents(sess *Session, recipient state.IdentScreenName, recipientDisplay, recipientAlias, message, messageID string, now float64, autoResponse bool) {
 	senderAimID := sess.ScreenName.IdentScreenName().String()
 	recipientAimID := recipient.String()
 
-	senderEventData := types.SentIMEvent{
-		Sender: types.UserInfo{
+	senderEventData := SentIMEvent{
+		Sender: UserInfo{
 			AimID:     senderAimID,
 			DisplayID: sess.ScreenName.String(),
 			UserType:  "aim",
 		},
-		Dest: types.UserInfo{
+		Dest: UserInfo{
 			AimID:     recipientAimID,
 			DisplayID: recipientDisplay,
 			Friendly:  recipientAlias,
@@ -248,27 +228,22 @@ func (h *MessagingHandler) pushSenderWebAPIEvents(sess *state.WebAPISession, rec
 		Timestamp: now,
 		AutoResp:  autoResponse,
 	}
-	sess.EventQueue.Push(types.EventTypeSentIM, senderEventData)
+	sess.EventQueue.Push(EventTypeSentIM, senderEventData)
 	if sess.IsSubscribedTo("conversation") {
-		sess.EventQueue.Push(types.EventTypeConversation, types.ConversationEventData("update", []types.ConversationEntryData{
-			types.ConversationEntry(recipientAimID, recipientDisplay, message, messageID, senderAimID, true, 0),
+		sess.EventQueue.Push(EventTypeConversation, ConversationEventData("update", []ConversationEntryData{
+			ConversationEntry(recipientAimID, recipientDisplay, message, messageID, senderAimID, true, 0),
 		}))
 	}
 }
 
-// sendErrorResponse sends an error response in Web AIM API format
-func (h *MessagingHandler) sendErrorResponse(w http.ResponseWriter, r *http.Request, statusCode int, errorText string) {
-	SendError(w, r, statusCode, errorText)
-}
-
 // SetTyping handles the /im/setTyping endpoint for typing indicators
-func (h *MessagingHandler) SetTyping(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
+func (h *MessagingHandler) SetTyping(w http.ResponseWriter, r *http.Request, sess *Session) {
 	ctx := r.Context()
 
 	// Parse parameters
 	recipient := r.URL.Query().Get("t")
 	if recipient == "" {
-		h.sendErrorResponse(w, r, http.StatusBadRequest, "missing required parameter: t (recipient)")
+		SendError(w, r, http.StatusBadRequest, "missing required parameter: t (recipient)")
 		return
 	}
 
@@ -294,11 +269,11 @@ func (h *MessagingHandler) SetTyping(w http.ResponseWriter, r *http.Request, ses
 	}
 	if err := h.ICBMService.ClientEvent(ctx, sess.OSCARSession, wire.SNACFrame{}, inBody); err != nil {
 		h.Logger.ErrorContext(ctx, "failed to send typing notification", "error", err)
-		h.sendErrorResponse(w, r, http.StatusInternalServerError, "internal server error")
+		SendError(w, r, http.StatusInternalServerError, "internal server error")
 		return
 	}
 
-	h.sendSuccessResponse(w, r, nil)
+	SendOK(w, r, nil, h.Logger)
 }
 
 // SendIMData reports the fate of an accepted IM.
@@ -307,11 +282,63 @@ type SendIMData struct {
 	State string `json:"state" xml:"state"`
 }
 
-// sendSuccessResponse sends a success response in Web AIM API format
-func (h *MessagingHandler) sendSuccessResponse(w http.ResponseWriter, r *http.Request, data interface{}) {
-	response := BaseResponse{}
-	response.Response.StatusCode = 200
-	response.Response.StatusText = "OK"
-	response.Response.Data = data
-	SendResponse(w, r, response, h.Logger)
+// StoredIMsData is the fetchStoredIMs payload.
+type StoredIMsData struct {
+	Msgs []StoredIM `json:"msgs" xml:"msgs>msg"`
+}
+
+// ConversationStubHandler serves Web AIM conversation/imlog endpoints the
+// client calls when syncing chat focus and read state.
+type ConversationStubHandler struct {
+	Logger *slog.Logger
+}
+
+// Update records active/focus time for a conversation (fire-and-forget).
+func (h *ConversationStubHandler) Update(w http.ResponseWriter, r *http.Request) {
+	SendOK(w, r, nil, h.Logger)
+}
+
+// Close acknowledges a conversation was closed in the client.
+func (h *ConversationStubHandler) Close(w http.ResponseWriter, r *http.Request) {
+	SendOK(w, r, nil, h.Logger)
+}
+
+// MarkRead acknowledges IM log read state for a buddy.
+func (h *ConversationStubHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
+	SendOK(w, r, nil, h.Logger)
+}
+
+// FetchStoredIMs returns stored IM history for a conversation partner.
+func (h *ConversationStubHandler) FetchStoredIMs(w http.ResponseWriter, r *http.Request, sess *Session) {
+	partner := r.URL.Query().Get("to")
+	if partner == "" {
+		SendError(w, r, http.StatusBadRequest, "missing required parameter: to")
+		return
+	}
+
+	q := 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)
+
+	SendOK(w, r, &StoredIMsData{Msgs: msgs}, h.Logger)
 }

+ 84 - 122
server/webapi/handlers/messaging_test.go → server/webapi/im_handler_test.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"context"
@@ -15,97 +15,71 @@ import (
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/require"
 
-	"github.com/mk6i/open-oscar-server/server/webapi/middleware"
-	"github.com/mk6i/open-oscar-server/server/webapi/types"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
 // requireSession wraps next with the session-resolving auth middleware for tests.
-func requireSession(sm middleware.WebAPISessionResolver, next func(http.ResponseWriter, *http.Request, *state.WebAPISession)) http.Handler {
-	return middleware.NewAuthMiddleware(nil, slog.Default()).RequireSession(sm, next)
+func requireSession(sm SessionResolver, next func(http.ResponseWriter, *http.Request, *Session)) http.Handler {
+	return NewAuthMiddleware(nil, slog.Default()).RequireSession(sm, next)
 }
 
-// MockICBMService is a mock implementation of ICBMService
-type MockICBMService struct {
-	mock.Mock
-}
-
-func (m *MockICBMService) ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*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 *MockICBMService) ClientEvent(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error {
-	args := m.Called(ctx, instance, inFrame, inBody)
-	return args.Error(0)
-}
-
-func (m *MockICBMService) OfflineRetrieve(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)
-}
-
-// createTestSessionManager creates a WebAPISessionManager with a pre-populated session.
-// createTestSessionManagerWithOSCAR creates a WebAPISessionManager with an OSCAR session instance set.
-func createTestSessionManagerWithOSCAR(screenName string, oscarSession *state.SessionInstance) (*state.WebAPISessionManager, string) {
-	mgr := state.NewWebAPISessionManager()
+// createTestSessionManager creates a SessionManager with a pre-populated session.
+// createTestSessionManagerWithOSCAR creates a SessionManager with an OSCAR session instance set.
+func createTestSessionManagerWithOSCAR(screenName string, oscarSession *state.SessionInstance) (*SessionManager, string) {
+	mgr := NewSessionManager()
 	session, _ := mgr.CreateSession(state.DisplayScreenName(screenName), "test-dev", []string{"im", "presence", "buddylist", "sentIM", "typing"}, oscarSession, "", slog.Default())
 	return mgr, session.AimSID
 }
 
 // stubLocateService answers UserInfoQuery with a reply carrying screenName, or
 // with an error when screenName is empty (i.e. the target is offline or blocked).
-func stubLocateService(screenName string) *MockLocateService {
-	ls := &MockLocateService{}
-	call := ls.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
+func stubLocateService(t *testing.T, screenName string) *mockLocateService {
+	ls := newMockLocateService(t)
+	call := ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything)
 	if screenName == "" {
-		call.Return(wire.SNACMessage{}, io.EOF)
+		call.Return(wire.SNACMessage{}, io.EOF).Maybe()
 	} else {
 		call.Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
 			TLVUserInfo: wire.TLVUserInfo{ScreenName: screenName},
-		}}, nil)
+		}}, nil).Maybe()
 	}
 	return ls
 }
 
 // stubFeedbagService answers Query with a single buddy item for buddy, carrying
 // alias when one is given.
-func stubFeedbagService(buddy, alias string) *MockFeedbagService {
+func stubFeedbagService(t *testing.T, buddy, alias string) *mockFeedbagService {
 	item := wire.FeedbagItem{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: buddy}
 	if alias != "" {
 		item.TLVLBlock = wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesAlias, alias)}}
 	}
-	fs := &MockFeedbagService{}
-	fs.On("Query", mock.Anything, mock.Anything, mock.Anything).Return(
+	fs := newMockFeedbagService(t)
+	fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
 		wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: []wire.FeedbagItem{item}}}, nil,
-	)
+	).Maybe()
 	return fs
 }
 
 // sendIMForDest drives SendIM addressed to t, with the recipient's display name
 // resolving to locateName and the sender's alias for them set to alias, and returns
 // the events queued for the sender.
-func sendIMForDest(t *testing.T, dest, locateName, alias string) []types.Event {
+func sendIMForDest(t *testing.T, dest, locateName, alias string) []Event {
 	t.Helper()
 
 	oscarInstance := state.NewSession().AddInstance()
-	icbmService := &MockICBMService{}
-	icbmService.On("ChannelMsgToHost", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+	icbmService := newMockICBMService(t)
+	icbmService.EXPECT().ChannelMsgToHost(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
 		Return(nil, nil)
 
-	mgr := state.NewWebAPISessionManager()
+	mgr := NewSessionManager()
 	session, err := mgr.CreateSession(state.DisplayScreenName("Ann Dupree"), "test-dev", []string{"im", "sentIM", "conversation"}, oscarInstance, "", slog.Default())
 	require.NoError(t, err)
 
 	handler := &MessagingHandler{
-		SessionManager: mgr,
 		ICBMService:    icbmService,
-		LocateService:  stubLocateService(locateName),
-		FeedbagService: stubFeedbagService(dest, alias),
+		LocateService:  stubLocateService(t, locateName),
+		FeedbagService: stubFeedbagService(t, dest, alias),
 		Logger:         slog.Default(),
 	}
 
@@ -127,14 +101,14 @@ func sendIMForDest(t *testing.T, dest, locateName, alias string) []types.Event {
 // to come from the locate reply. Echoing t back as a displayId would overwrite the
 // properly formatted name the client already holds for that aimId.
 func TestMessagingHandler_SendIM_DestDisplayIDFromLocateReply(t *testing.T) {
-	var sentIM types.SentIMEvent
-	var conv types.ConversationEntryData
+	var sentIM SentIMEvent
+	var conv ConversationEntryData
 	for _, event := range sendIMForDest(t, "mikelee", "Mike Lee", "") {
 		switch event.Type {
-		case types.EventTypeSentIM:
-			sentIM, _ = event.Data.(types.SentIMEvent)
-		case types.EventTypeConversation:
-			data, _ := event.Data.(*types.ConversationData)
+		case EventTypeSentIM:
+			sentIM, _ = event.Data.(SentIMEvent)
+		case EventTypeConversation:
+			data, _ := event.Data.(*ConversationData)
 			require.NotNil(t, data)
 			require.Len(t, data.Conversations, 1)
 			conv = data.Conversations[0]
@@ -154,10 +128,10 @@ func TestMessagingHandler_SendIM_DestDisplayIDFromLocateReply(t *testing.T) {
 // deletes the alias it holds every time it merges a user map. So the sentIM echo has
 // to repeat it, or messaging an aliased buddy renames him back to his screen name.
 func TestMessagingHandler_SendIM_DestCarriesAlias(t *testing.T) {
-	var sentIM types.SentIMEvent
+	var sentIM SentIMEvent
 	for _, event := range sendIMForDest(t, "mikelee", "Mike Lee", "MICHAELLEE") {
-		if event.Type == types.EventTypeSentIM {
-			sentIM, _ = event.Data.(types.SentIMEvent)
+		if event.Type == EventTypeSentIM {
+			sentIM, _ = event.Data.(SentIMEvent)
 		}
 	}
 
@@ -169,14 +143,14 @@ func TestMessagingHandler_SendIM_DestCarriesAlias(t *testing.T) {
 // When the recipient's display name cannot be resolved, displayId is omitted
 // rather than filled in with the aimId, leaving the client's existing name intact.
 func TestMessagingHandler_SendIM_OmitsDestDisplayIDWhenUnresolved(t *testing.T) {
-	var sentIM types.SentIMEvent
-	var conv types.ConversationEntryData
+	var sentIM SentIMEvent
+	var conv ConversationEntryData
 	for _, event := range sendIMForDest(t, "mikelee", "", "") {
 		switch event.Type {
-		case types.EventTypeSentIM:
-			sentIM, _ = event.Data.(types.SentIMEvent)
-		case types.EventTypeConversation:
-			data, _ := event.Data.(*types.ConversationData)
+		case EventTypeSentIM:
+			sentIM, _ = event.Data.(SentIMEvent)
+		case EventTypeConversation:
+			data, _ := event.Data.(*ConversationData)
 			require.NotNil(t, data)
 			require.Len(t, data.Conversations, 1)
 			conv = data.Conversations[0]
@@ -203,15 +177,15 @@ func TestMessagingHandler_SendIM(t *testing.T) {
 	tests := []struct {
 		name               string
 		queryParams        string
-		setupMocks         func(*MockICBMService)
+		setupMocks         func(*mockICBMService)
 		expectedStatusCode int
 		checkResponse      func(*testing.T, string)
 	}{
 		{
 			name:        "Success",
 			queryParams: "t=recipient&message=hello+world",
-			setupMocks: func(is *MockICBMService) {
-				is.On("ChannelMsgToHost", mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNACFrame"), mock.AnythingOfType("wire.SNAC_0x04_0x06_ICBMChannelMsgToHost")).
+			setupMocks: func(is *mockICBMService) {
+				is.EXPECT().ChannelMsgToHost(mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNACFrame"), mock.AnythingOfType("wire.SNAC_0x04_0x06_ICBMChannelMsgToHost")).
 					Return(nil, nil)
 			},
 			expectedStatusCode: http.StatusOK,
@@ -224,7 +198,7 @@ func TestMessagingHandler_SendIM(t *testing.T) {
 		{
 			name:               "Error_MissingRecipient",
 			queryParams:        "message=hello",
-			setupMocks:         func(is *MockICBMService) {},
+			setupMocks:         func(is *mockICBMService) {},
 			expectedStatusCode: http.StatusBadRequest,
 			checkResponse: func(t *testing.T, body string) {
 				assert.Contains(t, body, "missing required parameter: t")
@@ -233,7 +207,7 @@ func TestMessagingHandler_SendIM(t *testing.T) {
 		{
 			name:               "Error_MissingMessage",
 			queryParams:        "t=recipient",
-			setupMocks:         func(is *MockICBMService) {},
+			setupMocks:         func(is *mockICBMService) {},
 			expectedStatusCode: http.StatusBadRequest,
 			checkResponse: func(t *testing.T, body string) {
 				assert.Contains(t, body, "missing required parameter: message")
@@ -243,15 +217,14 @@ func TestMessagingHandler_SendIM(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			icbmService := &MockICBMService{}
+			icbmService := newMockICBMService(t)
 
 			sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
 			handler := &MessagingHandler{
-				SessionManager: sessionMgr,
 				ICBMService:    icbmService,
-				LocateService:  stubLocateService(""),
-				FeedbagService: stubFeedbagService("someone", ""),
+				LocateService:  stubLocateService(t, ""),
+				FeedbagService: stubFeedbagService(t, "someone", ""),
 				Logger:         slog.Default(),
 			}
 
@@ -263,7 +236,7 @@ func TestMessagingHandler_SendIM(t *testing.T) {
 
 			rr := httptest.NewRecorder()
 
-			requireSession(handler.SessionManager, handler.SendIM).ServeHTTP(rr, req)
+			requireSession(sessionMgr, handler.SendIM).ServeHTTP(rr, req)
 
 			assert.Equal(t, tt.expectedStatusCode, rr.Code)
 
@@ -271,8 +244,6 @@ func TestMessagingHandler_SendIM(t *testing.T) {
 			if tt.checkResponse != nil {
 				tt.checkResponse(t, responseBody)
 			}
-
-			icbmService.AssertExpectations(t)
 		})
 	}
 }
@@ -281,33 +252,31 @@ func TestMessagingHandler_SendIM(t *testing.T) {
 // store directive is present, so the client's offlineIM flag has to become one.
 func TestMessagingHandler_SendIM_OfflineIMSetsStoreTLV(t *testing.T) {
 	oscarInstance := state.NewSession().AddInstance()
-	icbmService := &MockICBMService{}
+	icbmService := newMockICBMService(t)
 
 	var sent wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
-	icbmService.On("ChannelMsgToHost", mock.Anything, oscarInstance, mock.Anything, mock.Anything).
-		Run(func(args mock.Arguments) {
-			sent = args.Get(3).(wire.SNAC_0x04_0x06_ICBMChannelMsgToHost)
+	icbmService.EXPECT().ChannelMsgToHost(mock.Anything, oscarInstance, mock.Anything, mock.Anything).
+		Run(func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) {
+			sent = inBody
 		}).
 		Return(nil, nil)
 
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 	handler := &MessagingHandler{
-		SessionManager: sessionMgr,
 		ICBMService:    icbmService,
-		LocateService:  stubLocateService(""),
-		FeedbagService: stubFeedbagService("recipient", ""),
+		LocateService:  stubLocateService(t, ""),
+		FeedbagService: stubFeedbagService(t, "recipient", ""),
 		Logger:         slog.Default(),
 	}
 
 	req, err := http.NewRequest("GET", "/im/sendIM?aimsid="+aimsid+"&t=recipient&message=hi&offlineIM=true", nil)
 	require.NoError(t, err)
 	rr := httptest.NewRecorder()
-	requireSession(handler.SessionManager, handler.SendIM).ServeHTTP(rr, req)
+	requireSession(sessionMgr, handler.SendIM).ServeHTTP(rr, req)
 
 	require.Equal(t, http.StatusOK, rr.Code)
 	_, hasStore := sent.Bytes(wire.ICBMTLVStore)
 	assert.True(t, hasStore)
-	icbmService.AssertExpectations(t)
 }
 
 // An undeliverable IM must still produce an envelope: the web client reads
@@ -339,8 +308,8 @@ func TestMessagingHandler_SendIM_UndeliverableReportsStatus(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			oscarInstance := state.NewSession().AddInstance()
-			icbmService := &MockICBMService{}
-			icbmService.On("ChannelMsgToHost", mock.Anything, oscarInstance, mock.Anything, mock.Anything).
+			icbmService := newMockICBMService(t)
+			icbmService.EXPECT().ChannelMsgToHost(mock.Anything, oscarInstance, mock.Anything, mock.Anything).
 				Return(&wire.SNACMessage{
 					Frame: wire.SNACFrame{FoodGroup: wire.ICBM, SubGroup: wire.ICBMErr},
 					Body:  tt.errs,
@@ -348,40 +317,37 @@ func TestMessagingHandler_SendIM_UndeliverableReportsStatus(t *testing.T) {
 
 			sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 			handler := &MessagingHandler{
-				SessionManager: sessionMgr,
 				ICBMService:    icbmService,
-				LocateService:  stubLocateService(""),
-				FeedbagService: stubFeedbagService("recipient", ""),
+				LocateService:  stubLocateService(t, ""),
+				FeedbagService: stubFeedbagService(t, "recipient", ""),
 				Logger:         slog.Default(),
 			}
 
 			req, err := http.NewRequest("GET", "/im/sendIM?aimsid="+aimsid+"&f=json&t=recipient&message=hi", nil)
 			require.NoError(t, err)
 			rr := httptest.NewRecorder()
-			requireSession(handler.SessionManager, handler.SendIM).ServeHTTP(rr, req)
+			requireSession(sessionMgr, handler.SendIM).ServeHTTP(rr, req)
 
 			require.Equal(t, http.StatusOK, rr.Code)
 			assert.Contains(t, rr.Body.String(), `"statusCode":602`)
 			assert.NotContains(t, rr.Body.String(), `"msgId"`)
-			icbmService.AssertExpectations(t)
 		})
 	}
 }
 
 func TestMessagingHandler_SendIM_POST(t *testing.T) {
 	oscarInstance := state.NewSession().AddInstance()
-	icbmService := &MockICBMService{}
+	icbmService := newMockICBMService(t)
 
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
-	icbmService.On("ChannelMsgToHost", mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNACFrame"), mock.AnythingOfType("wire.SNAC_0x04_0x06_ICBMChannelMsgToHost")).
+	icbmService.EXPECT().ChannelMsgToHost(mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNACFrame"), mock.AnythingOfType("wire.SNAC_0x04_0x06_ICBMChannelMsgToHost")).
 		Return(nil, nil)
 
 	handler := &MessagingHandler{
-		SessionManager: sessionMgr,
 		ICBMService:    icbmService,
-		LocateService:  stubLocateService(""),
-		FeedbagService: stubFeedbagService("someone", ""),
+		LocateService:  stubLocateService(t, ""),
+		FeedbagService: stubFeedbagService(t, "someone", ""),
 		Logger:         slog.Default(),
 	}
 
@@ -391,40 +357,39 @@ func TestMessagingHandler_SendIM_POST(t *testing.T) {
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 
 	rr := httptest.NewRecorder()
-	requireSession(handler.SessionManager, handler.SendIM).ServeHTTP(rr, req)
+	requireSession(sessionMgr, handler.SendIM).ServeHTTP(rr, req)
 
 	assert.Equal(t, http.StatusOK, rr.Code)
 	assert.Contains(t, rr.Body.String(), `"msgId"`)
-	icbmService.AssertExpectations(t)
 }
 
 func TestMessagingHandler_SendIM_MissingAimsid(t *testing.T) {
+	sessionMgr := NewSessionManager()
 	handler := &MessagingHandler{
-		SessionManager: state.NewWebAPISessionManager(),
-		Logger:         slog.Default(),
+		Logger: slog.Default(),
 	}
 
 	req, err := http.NewRequest("GET", "/im/sendIM", nil)
 	assert.NoError(t, err)
 
 	rr := httptest.NewRecorder()
-	requireSession(handler.SessionManager, handler.SendIM).ServeHTTP(rr, req)
+	requireSession(sessionMgr, handler.SendIM).ServeHTTP(rr, req)
 
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
 }
 
 func TestMessagingHandler_SendIM_InvalidSession(t *testing.T) {
+	sessionMgr := NewSessionManager()
 	handler := &MessagingHandler{
-		SessionManager: state.NewWebAPISessionManager(),
-		Logger:         slog.Default(),
+		Logger: slog.Default(),
 	}
 
 	req, err := http.NewRequest("GET", "/im/sendIM?aimsid=nonexistent&t=someone&message=hi", nil)
 	assert.NoError(t, err)
 
 	rr := httptest.NewRecorder()
-	requireSession(handler.SessionManager, handler.SendIM).ServeHTTP(rr, req)
+	requireSession(sessionMgr, handler.SendIM).ServeHTTP(rr, req)
 
 	assert.Equal(t, http.StatusUnauthorized, rr.Code)
 	assert.Contains(t, rr.Body.String(), "invalid or expired session")
@@ -436,15 +401,15 @@ func TestMessagingHandler_SetTyping(t *testing.T) {
 	tests := []struct {
 		name               string
 		queryParams        string
-		setupMocks         func(*MockICBMService)
+		setupMocks         func(*mockICBMService)
 		expectedStatusCode int
 		checkResponse      func(*testing.T, string)
 	}{
 		{
 			name:        "Success_TypingStarted",
 			queryParams: "t=recipient&typingStatus=typing",
-			setupMocks: func(is *MockICBMService) {
-				is.On("ClientEvent", mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x04_0x14_ICBMClientEvent{
+			setupMocks: func(is *mockICBMService) {
+				is.EXPECT().ClientEvent(mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x04_0x14_ICBMClientEvent{
 					ChannelID:  wire.ICBMChannelIM,
 					ScreenName: "recipient",
 					Event:      0x0002,
@@ -458,8 +423,8 @@ func TestMessagingHandler_SetTyping(t *testing.T) {
 		{
 			name:        "Success_TypingPaused",
 			queryParams: "t=recipient&typingStatus=typed",
-			setupMocks: func(is *MockICBMService) {
-				is.On("ClientEvent", mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x04_0x14_ICBMClientEvent{
+			setupMocks: func(is *mockICBMService) {
+				is.EXPECT().ClientEvent(mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x04_0x14_ICBMClientEvent{
 					ChannelID:  wire.ICBMChannelIM,
 					ScreenName: "recipient",
 					Event:      0x0001,
@@ -470,8 +435,8 @@ func TestMessagingHandler_SetTyping(t *testing.T) {
 		{
 			name:        "Success_TypingStopped",
 			queryParams: "t=recipient&typingStatus=none",
-			setupMocks: func(is *MockICBMService) {
-				is.On("ClientEvent", mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x04_0x14_ICBMClientEvent{
+			setupMocks: func(is *mockICBMService) {
+				is.EXPECT().ClientEvent(mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x04_0x14_ICBMClientEvent{
 					ChannelID:  wire.ICBMChannelIM,
 					ScreenName: "recipient",
 					Event:      0x0000,
@@ -482,7 +447,7 @@ func TestMessagingHandler_SetTyping(t *testing.T) {
 		{
 			name:               "Error_MissingRecipient",
 			queryParams:        "typingStatus=typing",
-			setupMocks:         func(is *MockICBMService) {},
+			setupMocks:         func(is *mockICBMService) {},
 			expectedStatusCode: http.StatusBadRequest,
 			checkResponse: func(t *testing.T, body string) {
 				assert.Contains(t, body, "missing required parameter: t")
@@ -492,15 +457,14 @@ func TestMessagingHandler_SetTyping(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			icbmService := &MockICBMService{}
+			icbmService := newMockICBMService(t)
 
 			sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
 			handler := &MessagingHandler{
-				SessionManager: sessionMgr,
 				ICBMService:    icbmService,
-				LocateService:  stubLocateService(""),
-				FeedbagService: stubFeedbagService("someone", ""),
+				LocateService:  stubLocateService(t, ""),
+				FeedbagService: stubFeedbagService(t, "someone", ""),
 				Logger:         slog.Default(),
 			}
 
@@ -512,7 +476,7 @@ func TestMessagingHandler_SetTyping(t *testing.T) {
 
 			rr := httptest.NewRecorder()
 
-			requireSession(handler.SessionManager, handler.SetTyping).ServeHTTP(rr, req)
+			requireSession(sessionMgr, handler.SetTyping).ServeHTTP(rr, req)
 
 			assert.Equal(t, tt.expectedStatusCode, rr.Code)
 
@@ -520,23 +484,21 @@ func TestMessagingHandler_SetTyping(t *testing.T) {
 				responseBody := strings.TrimSpace(rr.Body.String())
 				tt.checkResponse(t, responseBody)
 			}
-
-			icbmService.AssertExpectations(t)
 		})
 	}
 }
 
 func TestMessagingHandler_SetTyping_MissingAimsid(t *testing.T) {
+	sessionMgr := NewSessionManager()
 	handler := &MessagingHandler{
-		SessionManager: state.NewWebAPISessionManager(),
-		Logger:         slog.Default(),
+		Logger: slog.Default(),
 	}
 
 	req, err := http.NewRequest("GET", "/im/setTyping", nil)
 	assert.NoError(t, err)
 
 	rr := httptest.NewRecorder()
-	requireSession(handler.SessionManager, handler.SetTyping).ServeHTTP(rr, req)
+	requireSession(sessionMgr, handler.SetTyping).ServeHTTP(rr, req)
 
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Contains(t, rr.Body.String(), "missing aimsid parameter")

+ 13 - 49
server/webapi/handlers/memberdir.go → server/webapi/memberdir_handler.go

@@ -1,7 +1,6 @@
-package handlers
+package webapi
 
 import (
-	"context"
 	"fmt"
 	"log/slog"
 	"net/http"
@@ -21,21 +20,6 @@ const defaultMemberDirLimit = 100
 // ever asks for one, so this bounds the fan-out an arbitrary "t" list can force.
 const maxMemberDirTargets = 20
 
-// DirSearchService issues OSCAR ODir directory searches. A single InfoQuery
-// dispatches to name/address, email, or interest-keyword search based on which
-// TLVs the query carries.
-type DirSearchService interface {
-	InfoQuery(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)
-}
-
-// MemberDirLocateService reads and writes stored directory info. memberDir/get
-// reads the requested screen names' profiles via DirInfo; memberDir/update
-// writes the caller's own via SetDirInfo.
-type MemberDirLocateService interface {
-	DirInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error)
-	SetDirInfo(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error)
-}
-
 // dirInfoTags are the directory fields carried in both the ODir get reply and
 // the locate set request. SetDirectoryInfo replaces every column, so
 // memberDir/update re-sends all of them to preserve fields the web form (which
@@ -57,7 +41,7 @@ var dirInfoTags = []uint16{
 // (memberDir/search, memberDir/get, and memberDir/update).
 type MemberDirHandler struct {
 	DirSearchService DirSearchService
-	LocateService    MemberDirLocateService
+	LocateService    LocateService
 	Logger           *slog.Logger
 }
 
@@ -95,7 +79,7 @@ type MemberDirInfoArray struct {
 // input as a "match" parameter shaped like "keyword=<x>" or
 // "firstName=<x>,lastName=<y>". We translate that into an OSCAR ODir InfoQuery
 // and let the ODir service pick the search mode.
-func (h *MemberDirHandler) Search(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *MemberDirHandler) Search(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
 	fields := parseMatch(r.URL.Query().Get("match"))
@@ -104,7 +88,7 @@ func (h *MemberDirHandler) Search(w http.ResponseWriter, r *http.Request, sessio
 	reply, err := h.DirSearchService.InfoQuery(ctx, wire.SNACFrame{}, inBody)
 	if err != nil {
 		h.Logger.ErrorContext(ctx, "memberDir search failed", "err", err.Error())
-		h.sendData(w, r, &MemberDirResults{Results: MemberDirInfoArray{InfoArray: []MemberDirInfo{}}})
+		SendOK(w, r, &MemberDirResults{Results: MemberDirInfoArray{InfoArray: []MemberDirInfo{}}}, h.Logger)
 		return
 	}
 
@@ -112,7 +96,7 @@ func (h *MemberDirHandler) Search(w http.ResponseWriter, r *http.Request, sessio
 	if !ok || body.Status != wire.ODirSearchResponseOK {
 		// Missing/insufficient params or an empty directory: return no results
 		// rather than an error so the client simply shows an empty result set.
-		h.sendData(w, r, &MemberDirResults{Results: MemberDirInfoArray{InfoArray: []MemberDirInfo{}}})
+		SendOK(w, r, &MemberDirResults{Results: MemberDirInfoArray{InfoArray: []MemberDirInfo{}}}, h.Logger)
 		return
 	}
 
@@ -152,13 +136,13 @@ func (h *MemberDirHandler) Search(w http.ResponseWriter, r *http.Request, sessio
 		"results", len(infoArray),
 	)
 
-	h.sendData(w, r, &MemberDirResults{Results: MemberDirInfoArray{InfoArray: infoArray}})
+	SendOK(w, r, &MemberDirResults{Results: MemberDirInfoArray{InfoArray: infoArray}}, h.Logger)
 }
 
 // Get handles GET /memberDir/get. The "t" param names the screen names to look
 // up, defaulting to the caller when absent. Each returned profile carries the
 // identity of the target it describes.
-func (h *MemberDirHandler) Get(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *MemberDirHandler) Get(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
 	targets := parseTargets(r.URL.Query().Get("t"))
@@ -201,7 +185,7 @@ func (h *MemberDirHandler) Get(w http.ResponseWriter, r *http.Request, session *
 		"targets", len(targets),
 	)
 
-	h.sendData(w, r, &MemberDirInfoArray{InfoArray: infoArray})
+	SendOK(w, r, &MemberDirInfoArray{InfoArray: infoArray}, h.Logger)
 }
 
 // Update handles GET /memberDir/update. The "Edit Your Name" form sends repeated
@@ -211,7 +195,7 @@ func (h *MemberDirHandler) Get(w http.ResponseWriter, r *http.Request, session *
 //
 // SetDirectoryInfo replaces the whole directory record, so we read the current
 // info first and re-send every field, overlaying only what the form changed.
-func (h *MemberDirHandler) Update(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *MemberDirHandler) Update(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
 	sets := parseSet(r.URL.Query()["set"])
@@ -222,13 +206,13 @@ func (h *MemberDirHandler) Update(w http.ResponseWriter, r *http.Request, sessio
 	reply, err := h.LocateService.DirInfo(ctx, wire.SNACFrame{}, wire.SNAC_0x02_0x0B_LocateGetDirInfo{ScreenName: session.ScreenName.String()})
 	if err != nil {
 		h.Logger.ErrorContext(ctx, "memberDir update: failed to read current dir info", "err", err.Error())
-		h.sendError(w, r, http.StatusInternalServerError, "failed to update directory info")
+		SendEnvelopeStatus(w, r, http.StatusInternalServerError, "failed to update directory info", h.Logger)
 		return
 	}
 	body, ok := reply.Body.(wire.SNAC_0x02_0x0C_LocateGetDirReply)
 	if !ok {
 		h.Logger.ErrorContext(ctx, "memberDir update: unexpected dir info reply", "body", fmt.Sprintf("%T", reply.Body))
-		h.sendError(w, r, http.StatusInternalServerError, "failed to update directory info")
+		SendEnvelopeStatus(w, r, http.StatusInternalServerError, "failed to update directory info", h.Logger)
 		return
 	}
 
@@ -255,7 +239,7 @@ func (h *MemberDirHandler) Update(w http.ResponseWriter, r *http.Request, sessio
 
 	if _, err := h.LocateService.SetDirInfo(ctx, session.OSCARSession, wire.SNACFrame{}, inBody); err != nil {
 		h.Logger.ErrorContext(ctx, "memberDir update failed", "err", err.Error())
-		h.sendError(w, r, http.StatusInternalServerError, "failed to update directory info")
+		SendEnvelopeStatus(w, r, http.StatusInternalServerError, "failed to update directory info", h.Logger)
 		return
 	}
 
@@ -265,27 +249,7 @@ func (h *MemberDirHandler) Update(w http.ResponseWriter, r *http.Request, sessio
 		"lastName", values[wire.ODirTLVLastName],
 	)
 
-	h.sendData(w, r, struct{}{})
-}
-
-// sendData wraps data in the standard response envelope and sends it via
-// SendResponse, which honors the JSONP callback the web client uses.
-func (h *MemberDirHandler) sendData(w http.ResponseWriter, r *http.Request, data any) {
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = data
-	SendResponse(w, r, resp, h.Logger)
-}
-
-// sendError returns an error status in the response envelope, routed through
-// SendResponse so the JSONP callback is still honored (a raw JSON error would be
-// CORB-blocked by the client's <script> transport).
-func (h *MemberDirHandler) sendError(w http.ResponseWriter, r *http.Request, code int, msg string) {
-	resp := BaseResponse{}
-	resp.Response.StatusCode = code
-	resp.Response.StatusText = msg
-	SendResponse(w, r, resp, h.Logger)
+	SendOK(w, r, struct{}{}, h.Logger)
 }
 
 // buildDirInfoQuery maps the web client's parsed "match" fields onto ODir search

+ 35 - 58
server/webapi/handlers/memberdir_test.go → server/webapi/memberdir_handler_test.go

@@ -1,7 +1,6 @@
-package handlers
+package webapi
 
 import (
-	"context"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -19,25 +18,6 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-type mockDirSearchService struct{ mock.Mock }
-
-func (m *mockDirSearchService) InfoQuery(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error) {
-	args := m.Called(ctx, inFrame, inBody)
-	return args.Get(0).(wire.SNACMessage), args.Error(1)
-}
-
-type mockMemberDirLocateService struct{ mock.Mock }
-
-func (m *mockMemberDirLocateService) DirInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error) {
-	args := m.Called(ctx, inFrame, inBody)
-	return args.Get(0).(wire.SNACMessage), args.Error(1)
-}
-
-func (m *mockMemberDirLocateService) SetDirInfo(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error) {
-	args := m.Called(ctx, instance, inFrame, inBody)
-	return args.Get(0).(wire.SNACMessage), args.Error(1)
-}
-
 func searchReply(status uint16, results ...wire.TLVBlock) wire.SNACMessage {
 	body := wire.SNAC_0x0F_0x03_InfoReply{Status: status}
 	body.Results.List = results
@@ -76,15 +56,15 @@ func decodeInfoArray(t *testing.T, body []byte, nested bool) []MemberDirInfo {
 }
 
 func TestMemberDirHandler_Search_Keyword(t *testing.T) {
-	dirSvc := &mockDirSearchService{}
+	dirSvc := newMockDirSearchService(t)
 	// keyword=haha must map to the ODir interest TLV.
-	dirSvc.On("InfoQuery", mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x0F_0x02_InfoQuery) bool {
+	dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x0F_0x02_InfoQuery) bool {
 		v, ok := q.String(wire.ODirTLVInterest)
 		return ok && v == "haha"
 	})).Return(searchReply(wire.ODirSearchResponseOK, result("FoundUser", "Found", "User")), nil)
 
 	h := &MemberDirHandler{DirSearchService: dirSvc, Logger: slog.Default()}
-	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
 
 	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dhaha&nToGet=200", nil)
 	rr := httptest.NewRecorder()
@@ -96,13 +76,12 @@ func TestMemberDirHandler_Search_Keyword(t *testing.T) {
 	assert.Equal(t, "founduser", infoArray[0].Profile.AimID)
 	assert.Equal(t, "FoundUser", infoArray[0].Profile.DisplayID)
 	assert.Equal(t, "Found", infoArray[0].Profile.FirstName)
-	dirSvc.AssertExpectations(t)
 }
 
 func TestMemberDirHandler_Search_FirstLastName(t *testing.T) {
-	dirSvc := &mockDirSearchService{}
+	dirSvc := newMockDirSearchService(t)
 	// firstName/lastName must map to the ODir name TLVs, not interest.
-	dirSvc.On("InfoQuery", mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x0F_0x02_InfoQuery) bool {
+	dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x0F_0x02_InfoQuery) bool {
 		first, hasFirst := q.String(wire.ODirTLVFirstName)
 		last, hasLast := q.String(wire.ODirTLVLastName)
 		_, hasInterest := q.String(wire.ODirTLVInterest)
@@ -110,7 +89,7 @@ func TestMemberDirHandler_Search_FirstLastName(t *testing.T) {
 	})).Return(searchReply(wire.ODirSearchResponseOK, result("Bob", "Bob", "Smith")), nil)
 
 	h := &MemberDirHandler{DirSearchService: dirSvc, Logger: slog.Default()}
-	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
 
 	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=firstName%3DBob%2ClastName%3DSmith", nil)
 	rr := httptest.NewRecorder()
@@ -119,19 +98,18 @@ func TestMemberDirHandler_Search_FirstLastName(t *testing.T) {
 	infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
 	require.Len(t, infoArray, 1)
 	assert.Equal(t, "bob", infoArray[0].Profile.AimID)
-	dirSvc.AssertExpectations(t)
 }
 
 func TestMemberDirHandler_Search_ExcludesSelf(t *testing.T) {
-	dirSvc := &mockDirSearchService{}
-	dirSvc.On("InfoQuery", mock.Anything, mock.Anything, mock.Anything).Return(
+	dirSvc := newMockDirSearchService(t)
+	dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.Anything).Return(
 		searchReply(wire.ODirSearchResponseOK,
 			result("Me", "", ""),    // caller — must be filtered out
 			result("Other", "", ""), // kept
 		), nil)
 
 	h := &MemberDirHandler{DirSearchService: dirSvc, Logger: slog.Default()}
-	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("M E")}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("M E")}
 
 	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dx", nil)
 	rr := httptest.NewRecorder()
@@ -143,12 +121,12 @@ func TestMemberDirHandler_Search_ExcludesSelf(t *testing.T) {
 }
 
 func TestMemberDirHandler_Search_RespectsJSONPCallback(t *testing.T) {
-	dirSvc := &mockDirSearchService{}
-	dirSvc.On("InfoQuery", mock.Anything, mock.Anything, mock.Anything).Return(
+	dirSvc := newMockDirSearchService(t)
+	dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.Anything).Return(
 		searchReply(wire.ODirSearchResponseOK), nil)
 
 	h := &MemberDirHandler{DirSearchService: dirSvc, Logger: slog.Default()}
-	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
 
 	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dx&c=_callbacks_._abc", nil)
 	rr := httptest.NewRecorder()
@@ -156,7 +134,9 @@ func TestMemberDirHandler_Search_RespectsJSONPCallback(t *testing.T) {
 
 	// The web client loads this via a <script> tag, so the response must be
 	// JavaScript (JSONP), not application/json — otherwise the browser CORB-blocks it.
-	assert.Equal(t, "application/javascript", rr.Header().Get("Content-Type"))
+	// The charset is explicit because a script tag otherwise decodes using the host
+	// page's encoding, which mangles non-ASCII screen names.
+	assert.Equal(t, "application/javascript; charset=utf-8", rr.Header().Get("Content-Type"))
 	assert.Contains(t, rr.Body.String(), "_callbacks_._abc(")
 }
 
@@ -165,13 +145,13 @@ func TestMemberDirHandler_Get_Self(t *testing.T) {
 	reply.Append(wire.NewTLVBE(wire.ODirTLVFirstName, "Me"))
 	reply.Append(wire.NewTLVBE(wire.ODirTLVLastName, "Myself"))
 
-	locSvc := &mockMemberDirLocateService{}
-	locSvc.On("DirInfo", mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x02_0x0B_LocateGetDirInfo) bool {
+	locSvc := newMockLocateService(t)
+	locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x02_0x0B_LocateGetDirInfo) bool {
 		return q.ScreenName == "me"
 	})).Return(wire.SNACMessage{Body: reply}, nil)
 
 	h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
-	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
 
 	// No "t" param: defaults to self.
 	req := httptest.NewRequest("GET", "/memberDir/get?aimsid=sid", nil)
@@ -183,7 +163,6 @@ func TestMemberDirHandler_Get_Self(t *testing.T) {
 	assert.Equal(t, "me", infoArray[0].Profile.AimID)
 	assert.Equal(t, "Me", infoArray[0].Profile.FirstName)
 	assert.Equal(t, "Myself", infoArray[0].Profile.LastName)
-	locSvc.AssertExpectations(t)
 }
 
 func TestMemberDirHandler_Get_LabelsEachTargetWithOwnIdentity(t *testing.T) {
@@ -193,16 +172,16 @@ func TestMemberDirHandler_Get_LabelsEachTargetWithOwnIdentity(t *testing.T) {
 		return wire.SNACMessage{Body: reply}
 	}
 
-	locSvc := &mockMemberDirLocateService{}
-	locSvc.On("DirInfo", mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x02_0x0B_LocateGetDirInfo) bool {
+	locSvc := newMockLocateService(t)
+	locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x02_0x0B_LocateGetDirInfo) bool {
 		return q.ScreenName == "Bob Smith"
 	})).Return(dirReply("Bob"), nil)
-	locSvc.On("DirInfo", mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x02_0x0B_LocateGetDirInfo) bool {
+	locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x02_0x0B_LocateGetDirInfo) bool {
 		return q.ScreenName == "alice"
 	})).Return(dirReply("Alice"), nil)
 
 	h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
-	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("Bob Smith")}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("Bob Smith")}
 
 	req := httptest.NewRequest("GET", "/memberDir/get?aimsid=sid&t=Bob+Smith,alice", nil)
 	rr := httptest.NewRecorder()
@@ -218,16 +197,16 @@ func TestMemberDirHandler_Get_LabelsEachTargetWithOwnIdentity(t *testing.T) {
 	assert.Equal(t, "alice", infoArray[1].Profile.AimID)
 	assert.Equal(t, "alice", infoArray[1].Profile.DisplayID)
 	assert.Equal(t, "Alice", infoArray[1].Profile.FirstName)
-	locSvc.AssertExpectations(t)
 }
 
 func TestMemberDirHandler_Get_CapsTargetFanOut(t *testing.T) {
-	locSvc := &mockMemberDirLocateService{}
-	locSvc.On("DirInfo", mock.Anything, mock.Anything, mock.Anything).
-		Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}}, nil)
+	locSvc := newMockLocateService(t)
+	locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}}, nil).
+		Times(maxMemberDirTargets)
 
 	h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
-	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
 
 	// Every target costs a directory lookup, so an arbitrarily long "t" list
 	// must not translate into an unbounded number of them.
@@ -241,7 +220,6 @@ func TestMemberDirHandler_Get_CapsTargetFanOut(t *testing.T) {
 
 	infoArray := decodeInfoArray(t, rr.Body.Bytes(), false)
 	assert.Len(t, infoArray, maxMemberDirTargets)
-	locSvc.AssertNumberOfCalls(t, "DirInfo", maxMemberDirTargets)
 }
 
 func TestMemberDirHandler_Update_PersistsNameAndPreservesOtherFields(t *testing.T) {
@@ -251,11 +229,11 @@ func TestMemberDirHandler_Update_PersistsNameAndPreservesOtherFields(t *testing.
 	current.Append(wire.NewTLVBE(wire.ODirTLVLastName, "Name"))
 	current.Append(wire.NewTLVBE(wire.ODirTLVCity, "Reno"))
 
-	locSvc := &mockMemberDirLocateService{}
-	locSvc.On("DirInfo", mock.Anything, mock.Anything, mock.Anything).
+	locSvc := newMockLocateService(t)
+	locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
 		Return(wire.SNACMessage{Body: current}, nil)
 	// The set request must carry the new name AND the preserved city.
-	locSvc.On("SetDirInfo", mock.Anything, mock.Anything, mock.Anything,
+	locSvc.EXPECT().SetDirInfo(mock.Anything, mock.Anything, mock.Anything,
 		mock.MatchedBy(func(b wire.SNAC_0x02_0x09_LocateSetDirInfo) bool {
 			first, _ := b.String(wire.ODirTLVFirstName)
 			last, _ := b.String(wire.ODirTLVLastName)
@@ -264,7 +242,7 @@ func TestMemberDirHandler_Update_PersistsNameAndPreservesOtherFields(t *testing.
 		})).Return(wire.SNACMessage{}, nil)
 
 	h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
-	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("mike")}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("mike")}
 
 	req := httptest.NewRequest("GET",
 		"/memberDir/update?aimsid=sid&set=firstName%3DMike&set=lastName%3DK&set=hideLevel%3DemailsAndCellular", nil)
@@ -272,16 +250,15 @@ func TestMemberDirHandler_Update_PersistsNameAndPreservesOtherFields(t *testing.
 	h.Update(rr, req, session)
 
 	assert.Equal(t, http.StatusOK, rr.Code)
-	locSvc.AssertExpectations(t)
 }
 
 func TestMemberDirHandler_Update_AbortsWhenCurrentInfoUnreadable(t *testing.T) {
-	locSvc := &mockMemberDirLocateService{}
-	locSvc.On("DirInfo", mock.Anything, mock.Anything, mock.Anything).
+	locSvc := newMockLocateService(t)
+	locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
 		Return(wire.SNACMessage{}, io.ErrUnexpectedEOF)
 
 	h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
-	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("mike")}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("mike")}
 
 	req := httptest.NewRequest("GET", "/memberDir/update?aimsid=sid&set=firstName%3DMike&set=lastName%3DK", nil)
 	rr := httptest.NewRecorder()

+ 191 - 166
server/webapi/middleware/auth.go → server/webapi/middleware.go

@@ -1,9 +1,7 @@
-package middleware
+package webapi
 
 import (
 	"context"
-	"encoding/json"
-	"encoding/xml"
 	"fmt"
 	"log/slog"
 	"net/http"
@@ -15,6 +13,7 @@ import (
 	"golang.org/x/time/rate"
 
 	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
 )
 
 // contextKey is a custom type for context keys to avoid collisions.
@@ -23,22 +22,12 @@ type contextKey string
 const (
 	// ContextKeyAPIKey is the context key for storing the validated API key.
 	ContextKeyAPIKey contextKey = "api_key"
-	// ContextKeyDevID is the context key for storing the developer ID.
-	ContextKeyDevID contextKey = "dev_id"
 	// contextKeyResolvedAPIKey caches an API key lookup across middlewares
 	// handling the same request. Unexported: it is an internal memo, not
 	// something handlers should read.
 	contextKeyResolvedAPIKey contextKey = "resolved_api_key"
 )
 
-// APIKeyValidator defines methods for validating Web API keys.
-type APIKeyValidator interface {
-	// GetAPIKeyByDevKey retrieves and validates an API key by its dev_key value.
-	GetAPIKeyByDevKey(ctx context.Context, devKey string) (*state.WebAPIKey, error)
-	// UpdateLastUsed updates the last_used timestamp for an API key.
-	UpdateLastUsed(ctx context.Context, devKey string) error
-}
-
 // RateLimitInfo contains rate limit metadata for a request.
 type RateLimitInfo struct {
 	Limit     int   // Total requests allowed per window
@@ -74,6 +63,13 @@ func NewRateLimiter() *RateLimiter {
 
 // CheckRateLimit checks if a request from the given devID is allowed and returns rate limit info.
 func (r *RateLimiter) CheckRateLimit(devID string, limit int) RateLimitInfo {
+	if limit <= 0 {
+		return RateLimitInfo{
+			Reset:   time.Now().Add(r.windowSize).Unix(),
+			Allowed: true,
+		}
+	}
+
 	r.mu.Lock()
 	defer r.mu.Unlock()
 
@@ -137,32 +133,26 @@ func NewAuthMiddleware(validator APIKeyValidator, logger *slog.Logger) *AuthMidd
 	}
 }
 
-// WebAPISessionResolver resolves and refreshes Web API sessions by aimsid.
-type WebAPISessionResolver interface {
-	GetSession(ctx context.Context, aimsid string) (*state.WebAPISession, error)
-	TouchSession(ctx context.Context, aimsid string) error
-}
-
 // RequireSession resolves the aimsid session and passes it to next. It rejects
 // requests whose session is missing or expired with an auth error. On success it
 // touches the session, sliding its expiry forward; this is the keepalive that
 // holds a long-polling client's session open (see the session lifecycle timeline
-// on state's WebAPISession manager).
+// on state's Session manager).
 //
 // A session with a nil OSCARSession is rejected as a 500: startSession no longer
 // creates such sessions (anonymous guests are unsupported), so a nil is a broken
 // server invariant, not a client error. This lets downstream handlers treat
 // session.OSCARSession as non-nil.
-func (m *AuthMiddleware) RequireSession(sm WebAPISessionResolver, next func(http.ResponseWriter, *http.Request, *state.WebAPISession)) http.Handler {
+func (m *AuthMiddleware) RequireSession(sm SessionResolver, next func(http.ResponseWriter, *http.Request, *Session)) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		aimsid := r.URL.Query().Get("aimsid")
 		if aimsid == "" {
-			m.sendSessionError(w, r, http.StatusBadRequest, "missing aimsid parameter")
+			SendError(w, r, http.StatusBadRequest, "missing aimsid parameter")
 			return
 		}
 		session, err := sm.GetSession(r.Context(), aimsid)
 		if err != nil {
-			m.sendSessionError(w, r, http.StatusUnauthorized, "invalid or expired session")
+			SendError(w, r, http.StatusUnauthorized, "invalid or expired session")
 			return
 		}
 		_ = sm.TouchSession(r.Context(), aimsid)
@@ -170,12 +160,6 @@ func (m *AuthMiddleware) RequireSession(sm WebAPISessionResolver, next func(http
 	})
 }
 
-// sendSessionError writes a Web AIM API error envelope with the given HTTP
-// status, or as a JSONP callback when the client requested one.
-func (m *AuthMiddleware) sendSessionError(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
-	m.writeErrorEnvelope(w, r, statusCode, message, true)
-}
-
 // Authenticate is an HTTP middleware that validates API keys and enforces rate limits.
 func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -187,7 +171,7 @@ func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler {
 		}
 
 		if apiKey == "" {
-			m.sendErrorResponse(w, r, http.StatusBadRequest, "required parameter 'k' is missing")
+			SendEnvelopeStatus(w, r, http.StatusBadRequest, "required parameter 'k' is missing", m.Logger)
 			return
 		}
 
@@ -196,7 +180,7 @@ func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler {
 		ctx := r.Context()
 		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")
+			SendEnvelopeStatus(w, r, http.StatusForbidden, "invalid API key", m.Logger)
 			return
 		}
 
@@ -216,7 +200,7 @@ func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler {
 				retryAfter = 1
 			}
 			w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
-			m.sendErrorResponse(w, r, http.StatusTooManyRequests, "rate limit exceeded")
+			SendEnvelopeStatus(w, r, http.StatusTooManyRequests, "rate limit exceeded", m.Logger)
 			return
 		}
 
@@ -229,7 +213,6 @@ func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler {
 
 		// Add API key info to context for use in handlers
 		ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
-		ctx = context.WithValue(ctx, ContextKeyDevID, key.DevID)
 
 		// Log the API request
 		m.Logger.InfoContext(ctx, "API request authenticated",
@@ -343,134 +326,6 @@ func (m *AuthMiddleware) isOriginAllowed(origin string, allowedOrigins []string)
 	return false
 }
 
-// sendErrorResponse sends a Web AIM API error envelope, with JSONP support when
-// requested. The HTTP status stays 200 and the real status travels in the
-// envelope, which is where the Web AIM client reads it from.
-func (m *AuthMiddleware) sendErrorResponse(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
-	m.writeErrorEnvelope(w, r, statusCode, message, false)
-}
-
-// writeErrorEnvelope marshals a Web AIM API error envelope and writes it as JSON
-// or, when the client asked for a callback, as JSONP.
-//
-// A JSONP error is always sent with HTTP 200 regardless of httpStatus: browsers
-// do not execute the body of a <script> tag that came back with a 4xx or 5xx, so
-// a status-carrying JSONP error never reaches the callback and surfaces in the
-// client as the generic "Failed to load script tag, probably malformed JS at
-// that url" instead of the real statusText.
-func (m *AuthMiddleware) writeErrorEnvelope(w http.ResponseWriter, r *http.Request, statusCode int, message string, httpStatus bool) {
-	envelope := map[string]any{
-		"statusCode": statusCode,
-		"statusText": message,
-		// Callbacks that reach response.data on a failure throw a TypeError when
-		// it is absent, so the envelope carries an empty one even here.
-		"data": map[string]any{},
-	}
-	// The client indexes JSONP replies by response.requestId and discards any
-	// reply that lacks one, leaving the request pending until it times out.
-	if id := r.URL.Query().Get("r"); id != "" {
-		envelope["requestId"] = id
-	}
-
-	body, err := json.Marshal(map[string]any{"response": envelope})
-	if err != nil {
-		m.Logger.Error("failed to encode error response", "err", err.Error())
-		http.Error(w, "internal server error", http.StatusInternalServerError)
-		return
-	}
-
-	// The callback outranks the format: a client on the <script> transport needs
-	// executable JS back whatever "f" says, and gets a script load failure
-	// otherwise.
-	if callback := jsonpCallback(r); 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
-	}
-
-	// An XML client cannot parse a JSON error; it reports an unreadable response
-	// rather than this statusText.
-	if requestFormat(r) == "xml" {
-		m.writeXMLErrorEnvelope(w, statusCode, message, httpStatus)
-		return
-	}
-
-	w.Header().Set("Content-Type", "application/json")
-	if httpStatus {
-		w.WriteHeader(statusCode)
-	}
-	_, _ = w.Write(body)
-}
-
-// xmlErrorEnvelope is the error envelope XML clients read, rooted at the
-// response itself where JSON nests it under a "response" key.
-type xmlErrorEnvelope struct {
-	XMLName    xml.Name `xml:"response"`
-	StatusCode int      `xml:"statusCode"`
-	StatusText string   `xml:"statusText"`
-	Data       struct{} `xml:"data"`
-}
-
-func (m *AuthMiddleware) writeXMLErrorEnvelope(w http.ResponseWriter, statusCode int, message string, httpStatus bool) {
-	body, err := xml.Marshal(xmlErrorEnvelope{StatusCode: statusCode, StatusText: message})
-	if err != nil {
-		m.Logger.Error("failed to encode XML error response", "err", err.Error())
-		http.Error(w, "internal server error", http.StatusInternalServerError)
-		return
-	}
-
-	w.Header().Set("Content-Type", "text/xml; charset=utf-8")
-	if httpStatus {
-		w.WriteHeader(statusCode)
-	}
-	_, _ = w.Write([]byte(xml.Header))
-	_, _ = w.Write(body)
-}
-
-// requestFormat returns the format the client asked for. A POST sends "f" in its
-// body, as clientLogin does, so the query string alone does not answer it.
-func requestFormat(r *http.Request) string {
-	format := strings.ToLower(r.URL.Query().Get("f"))
-	if format == "" && r.Method == http.MethodPost {
-		_ = r.ParseForm()
-		format = strings.ToLower(r.FormValue("f"))
-	}
-	return format
-}
-
-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
-}
-
-// min returns the minimum of two integers.
-func min(a, b int) int {
-	if a < b {
-		return a
-	}
-	return b
-}
-
 // AuthenticateFlexible is an HTTP middleware that supports multiple authentication methods:
 // 1. aimsid (session ID) - no k required
 // 2. a (AOL token) - no k required
@@ -506,7 +361,6 @@ func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
 				}
 			}
 			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
@@ -531,7 +385,7 @@ func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
 		}
 
 		if apiKey == "" {
-			m.sendErrorResponse(w, r, http.StatusBadRequest, "authentication required: provide aimsid or k parameter")
+			SendEnvelopeStatus(w, r, http.StatusBadRequest, "authentication required: provide aimsid or k parameter", m.Logger)
 			return
 		}
 
@@ -539,7 +393,7 @@ func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
 		ctx = r.Context()
 		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")
+			SendEnvelopeStatus(w, r, http.StatusForbidden, "invalid API key", m.Logger)
 			return
 		}
 
@@ -559,7 +413,7 @@ func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
 				retryAfter = 1
 			}
 			w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
-			m.sendErrorResponse(w, r, http.StatusTooManyRequests, "rate limit exceeded")
+			SendEnvelopeStatus(w, r, http.StatusTooManyRequests, "rate limit exceeded", m.Logger)
 			return
 		}
 
@@ -572,7 +426,6 @@ func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
 
 		// Add API key info to context for use in handlers
 		ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
-		ctx = context.WithValue(ctx, ContextKeyDevID, key.DevID)
 
 		// Log the API request
 		m.Logger.InfoContext(ctx, "API request authenticated via key",
@@ -621,3 +474,175 @@ func (m *AuthMiddleware) resolveAPIKey(ctx context.Context, devKey string) *stat
 	}
 	return key
 }
+
+// minRetryAfter floors the Retry-After hint sent with a rate-limited response.
+// The computed wait can round down to nothing when a class is barely over its
+// limit, and a hint of zero invites an immediate retry.
+const minRetryAfter = 1 * time.Second
+
+// SessionHandlerFunc is the session-aware handler shape that
+// AuthMiddleware.RequireSession invokes once it has resolved an aimsid.
+type SessionHandlerFunc = func(http.ResponseWriter, *http.Request, *Session)
+
+// RateLimitMiddleware enforces OSCAR rate limits on Web API routes that reach a
+// food group.
+//
+// Such routes are limited by OSCAR itself: OSCAR charges the session's shared
+// per-rate-class budget, the same budget a native OSCAR or TOC client spends, so
+// a user cannot dodge a limit by switching transports. Routes that reach no food
+// group are not limited here; edge rate limiting (a reverse proxy keyed by client
+// IP) is expected to cover the unauthenticated login/asset endpoints and the
+// authenticated bookkeeping ones.
+//
+// It lives in package webapi (rather than in server/oscar/middleware) so that its
+// rejection can be encoded through the same SendResponse path the handlers use,
+// honoring the request's JSON/JSONP/XML/AMF format.
+//
+// It only enforces the limit (the 430 rejection). Telling the client its status
+// changed is the job of OServiceService.MonitorRateLimits.
+type RateLimitMiddleware struct {
+	snacRateLimits wire.SNACRateLimits
+	logger         *slog.Logger
+}
+
+// NewRateLimitMiddleware creates a RateLimitMiddleware. snacRateLimits is the
+// same SNAC-to-rate-class mapping the OSCAR and TOC servers use.
+func NewRateLimitMiddleware(snacRateLimits wire.SNACRateLimits, logger *slog.Logger) *RateLimitMiddleware {
+	return &RateLimitMiddleware{
+		snacRateLimits: snacRateLimits,
+		logger:         logger,
+	}
+}
+
+// OSCAR returns middleware that charges one unit against the OSCAR rate class
+// mapped to (foodGroup, subGroup) before invoking the wrapped handler. It is the
+// HTTP counterpart of the TOC server's per-command rate check.
+//
+// A SNAC with no rate class mapping is allowed through, since refusing traffic
+// because the server's own table is incomplete would be worse than not limiting
+// it.
+func (l *RateLimitMiddleware) OSCAR(foodGroup uint16, subGroup uint16) func(SessionHandlerFunc) SessionHandlerFunc {
+	return func(next SessionHandlerFunc) SessionHandlerFunc {
+		return func(w http.ResponseWriter, r *http.Request, session *Session) {
+			ctx := r.Context()
+
+			rateClassID, ok := l.snacRateLimits.RateClassLookup(foodGroup, subGroup)
+			if !ok {
+				l.logger.ErrorContext(ctx, "rate limit not found, allowing request through",
+					"foodgroup", wire.FoodGroupName(foodGroup),
+					"subgroup", wire.SubGroupName(foodGroup, subGroup))
+				next(w, r, session)
+				return
+			}
+
+			sess := session.OSCARSession.Session()
+			status := sess.EvaluateRateLimit(time.Now(), rateClassID)
+
+			// Disconnect is rejected alongside Limited: EvaluateRateLimit has
+			// already closed the account's OSCAR session by the time it returns,
+			// so there is nothing left for the handler to act on. That close also
+			// invalidates the aimsid (GetSession stops resolving a session whose
+			// OSCAR instance is closed), so every subsequent request is turned
+			// away at RequireSession rather than reaching here again.
+			if status == wire.RateLimitStatusLimited || status == wire.RateLimitStatusDisconnect {
+				l.logger.DebugContext(ctx, "(webapi) rate limit exceeded, dropping request",
+					"foodgroup", wire.FoodGroupName(foodGroup),
+					"subgroup", wire.SubGroupName(foodGroup, subGroup),
+					"status", rateLimitStatusName(status))
+
+				// A disconnected session has no aimsid left to retry with, so
+				// there is no wait to advertise.
+				var retryAfter time.Duration
+				if status == wire.RateLimitStatusLimited {
+					retryAfter = retryAfterFor(sess.RateLimitStates()[rateClassID-1])
+				}
+				l.sendRateLimited(w, r, retryAfter)
+				return
+			}
+
+			next(w, r, session)
+		}
+	}
+}
+
+// retryAfterFor returns how long the client must wait for its next request on
+// this class to clear the limit.
+//
+// OSCAR's limiter has no fixed window: it tracks a moving average of the gap
+// between requests, and a request lifts the limit only once that average climbs
+// back to ClearLevel. Inverting CheckRateLimit's update for the elapsed time that
+// lands the new average exactly on ClearLevel gives
+//
+//	elapsed = ClearLevel*WindowSize - CurrentLevel*(WindowSize-1)
+//
+// A flat hint cannot work here, because a rejected request is still charged: a
+// client retrying on a fixed interval drives the average toward that interval, so
+// any hint below the class's ClearLevel holds the average just under the bar and
+// the client stays limited forever. The production ICBM class clears at 5100ms,
+// which a 5s hint would do exactly.
+func retryAfterFor(rcs state.RateClassState) time.Duration {
+	neededMs := int64(rcs.ClearLevel)*int64(rcs.WindowSize) - int64(rcs.CurrentLevel)*int64(rcs.WindowSize-1)
+
+	// Retry-After carries whole seconds, so round up: a hint that is short by a
+	// fraction of a second reproduces the same never-clears loop. A class barely
+	// over its limit can compute to no wait at all, hence the floor.
+	return max(time.Duration((neededMs+999)/1000)*time.Second, minRetryAfter)
+}
+
+// rateLimitStatusName maps an OSCAR rate limit status onto the status string the
+// web client switches on. It returns "" for a status the client does not know.
+func rateLimitStatusName(status wire.RateLimitStatus) string {
+	switch status {
+	case wire.RateLimitStatusClear:
+		return "clear"
+	case wire.RateLimitStatusAlert:
+		return "warn"
+	case wire.RateLimitStatusLimited:
+		return "limit"
+	case wire.RateLimitStatusDisconnect:
+		return "disconnect"
+	default:
+		return ""
+	}
+}
+
+// sendRateLimited writes a rate limit rejection. The transport status is 200 and
+// the rejection lives entirely in the Web AIM API envelope's own rate limit code.
+//
+// The transport status is deliberately not 429: the AIM client's WIM request layer
+// (XhrManager) and its Fetcher only parse the response body on a 2xx. A non-2xx is
+// routed to their error handlers, which synthesize a generic "request failed"
+// result and never look at the body, so the envelope's 430 — which the client
+// swallows on the IM path in favor of the rateLimit event — would go unread and the
+// user would see a generic send failure instead.
+//
+// The body is encoded via SendResponse, so it honors the request's format
+// (JSON/JSONP/XML/AMF) and echoes the request id into response.requestId — which
+// the JSONP fallback needs to correlate the reply, or its UI hangs — exactly as a
+// normal handler response would.
+//
+// A retryAfter of zero sends no Retry-After header, for the rejections that have
+// nothing to retry.
+func (l *RateLimitMiddleware) sendRateLimited(w http.ResponseWriter, r *http.Request, retryAfter time.Duration) {
+	if retryAfter > 0 {
+		w.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = statusRateLimited
+	resp.Response.StatusText = "rate limit exceeded"
+
+	SendResponse(w, r, resp, l.logger)
+}
+
+// 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)
+	})
+}

+ 0 - 311
server/webapi/middleware/cors_test.go

@@ -1,311 +0,0 @@
-package middleware
-
-import (
-	"context"
-	"io"
-	"log/slog"
-	"net/http"
-	"net/http/httptest"
-	"strings"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-
-	"github.com/mk6i/open-oscar-server/state"
-)
-
-// stubValidator records how many times a key was looked up so the tests can
-// assert that CORSMiddleware and the auth layer share a single lookup.
-type stubValidator struct {
-	key    *state.WebAPIKey
-	lookup int
-}
-
-func (s *stubValidator) GetAPIKeyByDevKey(_ context.Context, devKey string) (*state.WebAPIKey, error) {
-	s.lookup++
-	if s.key == nil || s.key.DevKey != devKey {
-		return nil, nil
-	}
-	return s.key, nil
-}
-
-func (s *stubValidator) UpdateLastUsed(context.Context, string) error { return nil }
-
-func newTestMiddleware(v APIKeyValidator) *AuthMiddleware {
-	return NewAuthMiddleware(v, slog.New(slog.NewTextHandler(io.Discard, nil)))
-}
-
-// The Web AIM client permanently downgrades to JSONP when a cross-origin
-// response arrives without Access-Control-Allow-Origin, so every response the
-// auth layer rejects must still carry CORS headers. That only holds while
-// CORSMiddleware wraps the auth middleware.
-func TestCORSMiddleware_HeadersOnAuthRejection(t *testing.T) {
-	// The auth layer reports failures in the response envelope rather than the
-	// HTTP status, so the envelope's statusCode is what identifies a rejection.
-	tests := []struct {
-		name         string
-		query        string
-		validator    *stubValidator
-		wantEnvelope string
-	}{
-		{
-			name:         "missing credentials",
-			query:        "?f=json",
-			validator:    &stubValidator{},
-			wantEnvelope: `"statusCode":400`,
-		},
-		{
-			name:         "unknown api key",
-			query:        "?k=nosuchkey",
-			validator:    &stubValidator{},
-			wantEnvelope: `"statusCode":403`,
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			m := newTestMiddleware(tt.validator)
-			var reachedHandler bool
-			h := m.CORSMiddleware(m.AuthenticateFlexible(
-				http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
-					reachedHandler = true
-					w.WriteHeader(http.StatusOK)
-				})))
-
-			r := httptest.NewRequest(http.MethodGet, "/im/sendIM"+tt.query, nil)
-			r.Header.Set("Origin", "http://localhost:8000")
-			w := httptest.NewRecorder()
-			h.ServeHTTP(w, r)
-
-			assert.False(t, reachedHandler, "auth layer should have rejected the request")
-			assert.Contains(t, w.Body.String(), tt.wantEnvelope)
-			assert.Equal(t, "http://localhost:8000", w.Header().Get("Access-Control-Allow-Origin"))
-			assert.Equal(t, "Origin", w.Header().Get("Vary"))
-		})
-	}
-}
-
-// A 404 for an endpoint this server does not implement (/service/getAttributes,
-// /metrics/sendIM) must reach the client as a 404 rather than as a blocked
-// response.
-func TestCORSMiddleware_HeadersOn404(t *testing.T) {
-	m := newTestMiddleware(&stubValidator{})
-	h := m.CORSMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
-		w.WriteHeader(http.StatusNotFound)
-	}))
-
-	r := httptest.NewRequest(http.MethodGet, "/service/getAttributes?f=json&aimsid=abc", nil)
-	r.Header.Set("Origin", "http://localhost:8000")
-	w := httptest.NewRecorder()
-	h.ServeHTTP(w, r)
-
-	assert.Equal(t, http.StatusNotFound, w.Code)
-	assert.Equal(t, "http://localhost:8000", w.Header().Get("Access-Control-Allow-Origin"))
-}
-
-func TestCORSMiddleware_PreflightShortCircuits(t *testing.T) {
-	m := newTestMiddleware(&stubValidator{})
-	var reachedNext bool
-	h := m.CORSMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
-		reachedNext = true
-	}))
-
-	r := httptest.NewRequest(http.MethodOptions, "/im/sendIM", nil)
-	r.Header.Set("Origin", "http://localhost:8000")
-	w := httptest.NewRecorder()
-	h.ServeHTTP(w, r)
-
-	assert.Equal(t, http.StatusNoContent, w.Code)
-	assert.False(t, reachedNext, "preflight must not reach the wrapped handler")
-	assert.Equal(t, "http://localhost:8000", w.Header().Get("Access-Control-Allow-Origin"))
-	assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), "POST")
-}
-
-// CORSMiddleware runs ahead of authentication and so resolves the API key
-// itself; the auth layer behind it must reuse that lookup rather than repeat it.
-func TestCORSMiddleware_SharesKeyLookupWithAuth(t *testing.T) {
-	v := &stubValidator{key: &state.WebAPIKey{
-		DevID:     "dev1",
-		DevKey:    "goodkey",
-		IsActive:  true,
-		RateLimit: 100,
-	}}
-	m := newTestMiddleware(v)
-
-	var served bool
-	h := m.CORSMiddleware(m.Authenticate(
-		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			served = true
-			key, ok := r.Context().Value(ContextKeyAPIKey).(*state.WebAPIKey)
-			require.True(t, ok, "handler should see the validated key")
-			assert.Equal(t, "dev1", key.DevID)
-			w.WriteHeader(http.StatusOK)
-		})))
-
-	r := httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?k=goodkey", nil)
-	r.Header.Set("Origin", "http://localhost:8000")
-	w := httptest.NewRecorder()
-	h.ServeHTTP(w, r)
-
-	assert.True(t, served)
-	assert.Equal(t, http.StatusOK, w.Code)
-	assert.Equal(t, 1, v.lookup, "key should be resolved once per request, not once per middleware")
-}
-
-// Per-key origin allowlists must keep working now that the origin decision is
-// made before authentication.
-func TestCORSMiddleware_PerKeyOriginAllowlist(t *testing.T) {
-	v := &stubValidator{key: &state.WebAPIKey{
-		DevID:          "dev1",
-		DevKey:         "goodkey",
-		IsActive:       true,
-		RateLimit:      100,
-		AllowedOrigins: []string{"http://allowed.example"},
-	}}
-	m := newTestMiddleware(v)
-	h := m.CORSMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
-		w.WriteHeader(http.StatusOK)
-	}))
-
-	t.Run("allowed origin", func(t *testing.T) {
-		r := httptest.NewRequest(http.MethodGet, "/im/sendIM?k=goodkey", nil)
-		r.Header.Set("Origin", "http://allowed.example")
-		w := httptest.NewRecorder()
-		h.ServeHTTP(w, r)
-		assert.Equal(t, "http://allowed.example", w.Header().Get("Access-Control-Allow-Origin"))
-	})
-
-	t.Run("disallowed origin", func(t *testing.T) {
-		r := httptest.NewRequest(http.MethodGet, "/im/sendIM?k=goodkey", nil)
-		r.Header.Set("Origin", "http://evil.example")
-		w := httptest.NewRecorder()
-		h.ServeHTTP(w, r)
-		assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin"))
-	})
-}
-
-// A POST body must survive the middleware chain: CORSMiddleware reads the API
-// key from the query string only, so it never parses the form.
-func TestCORSMiddleware_DoesNotConsumePOSTBody(t *testing.T) {
-	m := newTestMiddleware(&stubValidator{})
-	h := m.CORSMiddleware(m.AuthenticateFlexible(
-		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			body, err := io.ReadAll(r.Body)
-			require.NoError(t, err)
-			assert.Equal(t, "message=hello", string(body))
-			w.WriteHeader(http.StatusOK)
-		})))
-
-	r := httptest.NewRequest(http.MethodPost, "/im/sendIM?aimsid=abc", strings.NewReader("message=hello"))
-	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-	r.Header.Set("Origin", "http://localhost:8000")
-	w := httptest.NewRecorder()
-	h.ServeHTTP(w, r)
-
-	assert.Equal(t, http.StatusOK, w.Code)
-}
-
-// The auth layer's own rejections must be JSONP-wrapped too, otherwise a client
-// already in JSONP mode gets a script-tag syntax error instead of the reason.
-func TestAuthErrorsHonorJSONP(t *testing.T) {
-	m := newTestMiddleware(&stubValidator{})
-
-	t.Run("session error", func(t *testing.T) {
-		h := m.RequireSession(&stubSessionResolver{}, func(http.ResponseWriter, *http.Request, *state.WebAPISession) {
-			t.Fatal("handler should not run")
-		})
-
-		r := httptest.NewRequest(http.MethodGet, "/im/sendIM?c=_callbacks_._x&r=7", nil)
-		w := httptest.NewRecorder()
-		h.ServeHTTP(w, r)
-
-		body := w.Body.String()
-		assert.True(t, strings.HasPrefix(body, "_callbacks_._x("), "got %s", body)
-		assert.True(t, strings.HasSuffix(body, ");"), "got %s", body)
-		assert.Contains(t, body, `"statusCode":400`)
-		assert.Contains(t, body, `"requestId":"7"`)
-		// A 4xx would stop the browser executing the script tag.
-		assert.Equal(t, http.StatusOK, w.Code)
-	})
-
-	t.Run("missing credentials", func(t *testing.T) {
-		h := m.AuthenticateFlexible(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
-			t.Fatal("handler should not run")
-		}))
-
-		r := httptest.NewRequest(http.MethodGet, "/im/sendIM?c=cb&r=9", nil)
-		w := httptest.NewRecorder()
-		h.ServeHTTP(w, r)
-
-		body := w.Body.String()
-		assert.True(t, strings.HasPrefix(body, "cb("), "got %s", body)
-		assert.Contains(t, body, `"statusCode":400`)
-		assert.Contains(t, body, `"requestId":"9"`)
-		assert.Equal(t, http.StatusOK, w.Code)
-	})
-
-	t.Run("without a callback the session error keeps its HTTP status", func(t *testing.T) {
-		h := m.RequireSession(&stubSessionResolver{}, func(http.ResponseWriter, *http.Request, *state.WebAPISession) {
-			t.Fatal("handler should not run")
-		})
-
-		r := httptest.NewRequest(http.MethodGet, "/im/sendIM", nil)
-		w := httptest.NewRecorder()
-		h.ServeHTTP(w, r)
-
-		assert.Equal(t, http.StatusBadRequest, w.Code)
-		assert.Contains(t, w.Header().Get("Content-Type"), "json")
-	})
-}
-
-// An XML client cannot parse a JSON error, so it reports an unreadable response
-// instead of the reason the auth layer rejected it.
-func TestAuthErrorsHonorXML(t *testing.T) {
-	m := newTestMiddleware(&stubValidator{})
-	h := m.Authenticate(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
-		t.Fatal("handler should not run")
-	}))
-
-	t.Run("format in the query string", func(t *testing.T) {
-		r := httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?f=xml", nil)
-		w := httptest.NewRecorder()
-		h.ServeHTTP(w, r)
-
-		assert.Contains(t, w.Header().Get("Content-Type"), "xml")
-		assert.Contains(t, w.Body.String(), "<statusCode>400</statusCode>")
-	})
-
-	// A POST states the format in its body, the only place clientLogin sends it.
-	t.Run("format in the POST body", func(t *testing.T) {
-		r := httptest.NewRequest(http.MethodPost, "/auth/clientLogin", strings.NewReader("s=testuser&f=xml"))
-		r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-		w := httptest.NewRecorder()
-		h.ServeHTTP(w, r)
-
-		assert.Contains(t, w.Header().Get("Content-Type"), "xml")
-		assert.Contains(t, w.Body.String(), "<statusCode>400</statusCode>")
-	})
-
-	// A client on the <script> transport needs executable JS back whatever "f"
-	// says; XML there is a script load failure with no reason attached.
-	t.Run("a callback outranks the format", func(t *testing.T) {
-		r := httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?f=xml&c=cb&r=3", nil)
-		w := httptest.NewRecorder()
-		h.ServeHTTP(w, r)
-
-		body := w.Body.String()
-		assert.True(t, strings.HasPrefix(body, "cb("), "got %s", body)
-		assert.Contains(t, body, `"statusCode":400`)
-		assert.Contains(t, body, `"requestId":"3"`)
-	})
-}
-
-// stubSessionResolver never resolves a session, so RequireSession always rejects.
-type stubSessionResolver struct{}
-
-func (stubSessionResolver) GetSession(context.Context, string) (*state.WebAPISession, error) {
-	return nil, assert.AnError
-}
-
-func (stubSessionResolver) TouchSession(context.Context, string) error { return nil }

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

@@ -1,18 +0,0 @@
-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)
-	})
-}

+ 288 - 116
server/webapi/handlers/ratelimit_test.go → server/webapi/middleware_test.go

@@ -1,104 +1,321 @@
-package handlers
+package webapi
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"log/slog"
 	"net/http"
 	"net/http/httptest"
+	"strings"
 	"testing"
 	"time"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
-	"github.com/mk6i/open-oscar-server/server/webapi/types"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-// tightRateLimitClasses returns rate classes scaled down so tests run fast.
-//
-// OSCAR's moving average tracks the interval between requests in milliseconds,
-// seeded at MaxLevel, and each back-to-back request halves it at WindowSize 2.
-// So from 200 the sequence is 100 (clear), 50 (limited), 25, 12, 6 — the second
-// request trips the limit, and none of the first five fall below the disconnect
-// threshold. Recovering past ClearLevel takes a ~150ms pause rather than the
-// several seconds the production classes would need.
-func tightRateLimitClasses() wire.RateLimitClasses {
-	var classes [5]wire.RateClass
-	for i := range classes {
-		classes[i] = wire.RateClass{
-			ID:              wire.RateLimitClassID(i + 1),
-			WindowSize:      2,
-			ClearLevel:      100,
-			AlertLevel:      80,
-			LimitLevel:      70,
-			DisconnectLevel: 2,
-			MaxLevel:        200,
-		}
-	}
-	return wire.NewRateLimitClasses(classes)
+// stubValidator records how many times a key was looked up so the tests can
+// assert that CORSMiddleware and the auth layer share a single lookup.
+type stubValidator struct {
+	key    *state.WebAPIKey
+	lookup int
 }
 
-// newTestOSCARInstance builds an OSCAR session with rate limit state
-// initialized, mirroring what RegisterBOSSession does at startSession time.
-func newTestOSCARInstance(t *testing.T, classes wire.RateLimitClasses) *state.SessionInstance {
-	t.Helper()
+func (s *stubValidator) GetAPIKeyByDevKey(_ context.Context, devKey string) (*state.WebAPIKey, error) {
+	s.lookup++
+	if s.key == nil || s.key.DevKey != devKey {
+		return nil, nil
+	}
+	return s.key, nil
+}
 
-	instance := state.NewSession().AddInstance()
-	instance.Session().SetIdentScreenName(state.NewIdentScreenName("me"))
-	instance.Session().SetDisplayScreenName("me")
-	instance.Session().SetRateClasses(time.Now(), classes)
+func (s *stubValidator) UpdateLastUsed(context.Context, string) error { return nil }
 
-	return instance
+func newTestMiddleware(v APIKeyValidator) *AuthMiddleware {
+	return NewAuthMiddleware(v, slog.New(slog.NewTextHandler(io.Discard, nil)))
 }
 
-// newTestWebAPISessionOn builds a WebAPI session over an existing OSCAR
-// instance. Two of them model two browser tabs signed in as the same account:
-// each tab holds its own aimsid and its own WebAPISession, but the account has
-// one OSCAR session and therefore one set of rate limit states.
-func newTestWebAPISessionOn(aimsid string, instance *state.SessionInstance) *state.WebAPISession {
-	return &state.WebAPISession{
-		AimSID:       aimsid,
-		ScreenName:   "me",
-		OSCARSession: instance,
-		EventQueue:   types.NewEventQueue(10),
+// The Web AIM client permanently downgrades to JSONP when a cross-origin
+// response arrives without Access-Control-Allow-Origin, so every response the
+// auth layer rejects must still carry CORS headers. That only holds while
+// CORSMiddleware wraps the auth middleware.
+func TestCORSMiddleware_HeadersOnAuthRejection(t *testing.T) {
+	// The auth layer reports failures in the response envelope rather than the
+	// HTTP status, so the envelope's statusCode is what identifies a rejection.
+	tests := []struct {
+		name         string
+		query        string
+		validator    *stubValidator
+		wantEnvelope string
+	}{
+		{
+			name:         "missing credentials",
+			query:        "?f=json",
+			validator:    &stubValidator{},
+			wantEnvelope: `"statusCode":400`,
+		},
+		{
+			name:         "unknown api key",
+			query:        "?k=nosuchkey",
+			validator:    &stubValidator{},
+			wantEnvelope: `"statusCode":403`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			m := newTestMiddleware(tt.validator)
+			var reachedHandler bool
+			h := m.CORSMiddleware(m.AuthenticateFlexible(
+				http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+					reachedHandler = true
+					w.WriteHeader(http.StatusOK)
+				})))
+
+			r := httptest.NewRequest(http.MethodGet, "/im/sendIM"+tt.query, nil)
+			r.Header.Set("Origin", "http://localhost:8000")
+			w := httptest.NewRecorder()
+			h.ServeHTTP(w, r)
+
+			assert.False(t, reachedHandler, "auth layer should have rejected the request")
+			assert.Contains(t, w.Body.String(), tt.wantEnvelope)
+			assert.Equal(t, "http://localhost:8000", w.Header().Get("Access-Control-Allow-Origin"))
+			assert.Equal(t, "Origin", w.Header().Get("Vary"))
+		})
 	}
 }
 
-// newTestWebAPISession builds a WebAPI session backed by a real OSCAR session
-// with rate limit state initialized.
-func newTestWebAPISession(t *testing.T, classes wire.RateLimitClasses) *state.WebAPISession {
-	t.Helper()
+// A 404 for an endpoint this server does not implement (/service/getAttributes,
+// /metrics/sendIM) must reach the client as a 404 rather than as a blocked
+// response.
+func TestCORSMiddleware_HeadersOn404(t *testing.T) {
+	m := newTestMiddleware(&stubValidator{})
+	h := m.CORSMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusNotFound)
+	}))
+
+	r := httptest.NewRequest(http.MethodGet, "/service/getAttributes?f=json&aimsid=abc", nil)
+	r.Header.Set("Origin", "http://localhost:8000")
+	w := httptest.NewRecorder()
+	h.ServeHTTP(w, r)
+
+	assert.Equal(t, http.StatusNotFound, w.Code)
+	assert.Equal(t, "http://localhost:8000", w.Header().Get("Access-Control-Allow-Origin"))
+}
 
-	return newTestWebAPISessionOn("aimsid-1", newTestOSCARInstance(t, classes))
+func TestCORSMiddleware_PreflightShortCircuits(t *testing.T) {
+	m := newTestMiddleware(&stubValidator{})
+	var reachedNext bool
+	h := m.CORSMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+		reachedNext = true
+	}))
+
+	r := httptest.NewRequest(http.MethodOptions, "/im/sendIM", nil)
+	r.Header.Set("Origin", "http://localhost:8000")
+	w := httptest.NewRecorder()
+	h.ServeHTTP(w, r)
+
+	assert.Equal(t, http.StatusNoContent, w.Code)
+	assert.False(t, reachedNext, "preflight must not reach the wrapped handler")
+	assert.Equal(t, "http://localhost:8000", w.Header().Get("Access-Control-Allow-Origin"))
+	assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), "POST")
 }
 
-func newTestRateLimitMiddleware() *RateLimitMiddleware {
-	return NewRateLimitMiddleware(wire.DefaultSNACRateLimits(), slog.New(slog.DiscardHandler))
+// CORSMiddleware runs ahead of authentication and so resolves the API key
+// itself; the auth layer behind it must reuse that lookup rather than repeat it.
+func TestCORSMiddleware_SharesKeyLookupWithAuth(t *testing.T) {
+	v := &stubValidator{key: &state.WebAPIKey{
+		DevID:     "dev1",
+		DevKey:    "goodkey",
+		IsActive:  true,
+		RateLimit: 100,
+	}}
+	m := newTestMiddleware(v)
+
+	var served bool
+	h := m.CORSMiddleware(m.Authenticate(
+		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			served = true
+			key, ok := r.Context().Value(ContextKeyAPIKey).(*state.WebAPIKey)
+			require.True(t, ok, "handler should see the validated key")
+			assert.Equal(t, "dev1", key.DevID)
+			w.WriteHeader(http.StatusOK)
+		})))
+
+	r := httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?k=goodkey", nil)
+	r.Header.Set("Origin", "http://localhost:8000")
+	w := httptest.NewRecorder()
+	h.ServeHTTP(w, r)
+
+	assert.True(t, served)
+	assert.Equal(t, http.StatusOK, w.Code)
+	assert.Equal(t, 1, v.lookup, "key should be resolved once per request, not once per middleware")
 }
 
-// rateLimitEventStatuses returns the status string of every rateLimit event
-// queued on the session, in order.
-func rateLimitEventStatuses(t *testing.T, session *state.WebAPISession) []string {
-	t.Helper()
+// Per-key origin allowlists must keep working now that the origin decision is
+// made before authentication.
+func TestCORSMiddleware_PerKeyOriginAllowlist(t *testing.T) {
+	v := &stubValidator{key: &state.WebAPIKey{
+		DevID:          "dev1",
+		DevKey:         "goodkey",
+		IsActive:       true,
+		RateLimit:      100,
+		AllowedOrigins: []string{"http://allowed.example"},
+	}}
+	m := newTestMiddleware(v)
+	h := m.CORSMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	t.Run("allowed origin", func(t *testing.T) {
+		r := httptest.NewRequest(http.MethodGet, "/im/sendIM?k=goodkey", nil)
+		r.Header.Set("Origin", "http://allowed.example")
+		w := httptest.NewRecorder()
+		h.ServeHTTP(w, r)
+		assert.Equal(t, "http://allowed.example", w.Header().Get("Access-Control-Allow-Origin"))
+	})
 
-	var statuses []string
-	for _, event := range session.EventQueue.GetAllEvents() {
-		if event.Type != types.EventTypeRateLimit {
-			continue
-		}
-		payload, ok := event.Data.(types.RateLimitEvent)
-		if !assert.True(t, ok, "rateLimit event carried %T", event.Data) {
-			continue
-		}
-		if assert.Len(t, payload.Classes, 1) {
-			statuses = append(statuses, payload.Classes[0].Status)
-		}
-	}
-	return statuses
+	t.Run("disallowed origin", func(t *testing.T) {
+		r := httptest.NewRequest(http.MethodGet, "/im/sendIM?k=goodkey", nil)
+		r.Header.Set("Origin", "http://evil.example")
+		w := httptest.NewRecorder()
+		h.ServeHTTP(w, r)
+		assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin"))
+	})
+}
+
+// A POST body must survive the middleware chain: CORSMiddleware reads the API
+// key from the query string only, so it never parses the form.
+func TestCORSMiddleware_DoesNotConsumePOSTBody(t *testing.T) {
+	m := newTestMiddleware(&stubValidator{})
+	h := m.CORSMiddleware(m.AuthenticateFlexible(
+		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			body, err := io.ReadAll(r.Body)
+			require.NoError(t, err)
+			assert.Equal(t, "message=hello", string(body))
+			w.WriteHeader(http.StatusOK)
+		})))
+
+	r := httptest.NewRequest(http.MethodPost, "/im/sendIM?aimsid=abc", strings.NewReader("message=hello"))
+	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	r.Header.Set("Origin", "http://localhost:8000")
+	w := httptest.NewRecorder()
+	h.ServeHTTP(w, r)
+
+	assert.Equal(t, http.StatusOK, w.Code)
+}
+
+// The auth layer's own rejections must be JSONP-wrapped too, otherwise a client
+// already in JSONP mode gets a script-tag syntax error instead of the reason.
+func TestAuthErrorsHonorJSONP(t *testing.T) {
+	m := newTestMiddleware(&stubValidator{})
+
+	t.Run("session error", func(t *testing.T) {
+		h := m.RequireSession(&stubSessionResolver{}, func(http.ResponseWriter, *http.Request, *Session) {
+			t.Fatal("handler should not run")
+		})
+
+		r := httptest.NewRequest(http.MethodGet, "/im/sendIM?c=_callbacks_._x&r=7", nil)
+		w := httptest.NewRecorder()
+		h.ServeHTTP(w, r)
+
+		body := w.Body.String()
+		assert.True(t, strings.HasPrefix(body, "_callbacks_._x("), "got %s", body)
+		assert.True(t, strings.HasSuffix(body, ");"), "got %s", body)
+		assert.Contains(t, body, `"statusCode":400`)
+		assert.Contains(t, body, `"requestId":"7"`)
+		// A 4xx would stop the browser executing the script tag.
+		assert.Equal(t, http.StatusOK, w.Code)
+	})
+
+	t.Run("missing credentials", func(t *testing.T) {
+		h := m.AuthenticateFlexible(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+			t.Fatal("handler should not run")
+		}))
+
+		r := httptest.NewRequest(http.MethodGet, "/im/sendIM?c=cb&r=9", nil)
+		w := httptest.NewRecorder()
+		h.ServeHTTP(w, r)
+
+		body := w.Body.String()
+		assert.True(t, strings.HasPrefix(body, "cb("), "got %s", body)
+		assert.Contains(t, body, `"statusCode":400`)
+		assert.Contains(t, body, `"requestId":"9"`)
+		assert.Equal(t, http.StatusOK, w.Code)
+	})
+
+	t.Run("without a callback the session error keeps its HTTP status", func(t *testing.T) {
+		h := m.RequireSession(&stubSessionResolver{}, func(http.ResponseWriter, *http.Request, *Session) {
+			t.Fatal("handler should not run")
+		})
+
+		r := httptest.NewRequest(http.MethodGet, "/im/sendIM", nil)
+		w := httptest.NewRecorder()
+		h.ServeHTTP(w, r)
+
+		assert.Equal(t, http.StatusBadRequest, w.Code)
+		assert.Contains(t, w.Header().Get("Content-Type"), "json")
+	})
+}
+
+// An XML client cannot parse a JSON error, so it reports an unreadable response
+// instead of the reason the auth layer rejected it.
+func TestAuthErrorsHonorXML(t *testing.T) {
+	m := newTestMiddleware(&stubValidator{})
+	h := m.Authenticate(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+		t.Fatal("handler should not run")
+	}))
+
+	t.Run("format in the query string", func(t *testing.T) {
+		r := httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?f=xml", nil)
+		w := httptest.NewRecorder()
+		h.ServeHTTP(w, r)
+
+		assert.Contains(t, w.Header().Get("Content-Type"), "xml")
+		assert.Contains(t, w.Body.String(), "<statusCode>400</statusCode>")
+	})
+
+	// A POST states the format in its body, the only place clientLogin sends it.
+	t.Run("format in the POST body", func(t *testing.T) {
+		r := httptest.NewRequest(http.MethodPost, "/auth/clientLogin", strings.NewReader("s=testuser&f=xml"))
+		r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+		w := httptest.NewRecorder()
+		h.ServeHTTP(w, r)
+
+		assert.Contains(t, w.Header().Get("Content-Type"), "xml")
+		assert.Contains(t, w.Body.String(), "<statusCode>400</statusCode>")
+	})
+
+	// A client on the <script> transport needs executable JS back whatever "f"
+	// says; XML there is a script load failure with no reason attached.
+	t.Run("a callback outranks the format", func(t *testing.T) {
+		r := httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?f=xml&c=cb&r=3", nil)
+		w := httptest.NewRecorder()
+		h.ServeHTTP(w, r)
+
+		body := w.Body.String()
+		assert.True(t, strings.HasPrefix(body, "cb("), "got %s", body)
+		assert.Contains(t, body, `"statusCode":400`)
+		assert.Contains(t, body, `"requestId":"3"`)
+	})
+}
+
+// stubSessionResolver never resolves a session, so RequireSession always rejects.
+type stubSessionResolver struct{}
+
+func (stubSessionResolver) GetSession(context.Context, string) (*Session, error) {
+	return nil, assert.AnError
+}
+
+func (stubSessionResolver) TouchSession(context.Context, string) error { return nil }
+
+func newTestRateLimitMiddleware() *RateLimitMiddleware {
+	return NewRateLimitMiddleware(wire.DefaultSNACRateLimits(), slog.New(slog.DiscardHandler))
 }
 
 // assertRateLimited checks that a response is the Web API's rate limit
@@ -118,7 +335,7 @@ func assertRateLimited(t *testing.T, rec *httptest.ResponseRecorder, wantRetryAf
 		} `json:"response"`
 	}
 	assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
-	assert.Equal(t, rateLimitStatusCode, envelope.Response.StatusCode)
+	assert.Equal(t, statusRateLimited, envelope.Response.StatusCode)
 	assert.Equal(t, "rate limit exceeded", envelope.Response.StatusText)
 }
 
@@ -191,7 +408,7 @@ func TestRateLimitMiddleware_OSCAR(t *testing.T) {
 
 			calls := 0
 			handler := middleware.OSCAR(tt.foodGroup, tt.subGroup)(
-				func(w http.ResponseWriter, r *http.Request, s *state.WebAPISession) {
+				func(w http.ResponseWriter, r *http.Request, s *Session) {
 					calls++
 					w.WriteHeader(http.StatusOK)
 				})
@@ -216,51 +433,6 @@ func TestRateLimitMiddleware_OSCAR(t *testing.T) {
 	}
 }
 
-// The monitor broadcasts transitions, not current state, so without a seed a
-// session signing on mid-limit shows no banner while its sends are rejected — and
-// the client's alert is sticky, so the eventual "clear" has nothing to dismiss.
-func TestSeedRateLimitAlert(t *testing.T) {
-	imClass, ok := wire.DefaultSNACRateLimits().RateClassLookup(wire.ICBM, wire.ICBMChannelMsgToHost)
-	require.True(t, ok)
-
-	// limitedSession returns a session on an account already in the limited state.
-	limitedSession := func(t *testing.T) *state.WebAPISession {
-		t.Helper()
-
-		session := newTestWebAPISession(t, tightRateLimitClasses())
-		sess := session.OSCARSession.Session()
-		for i := 0; sess.RateLimitStates()[imClass-1].CurrentStatus != wire.RateLimitStatusLimited; i++ {
-			require.Less(t, i, 100, "class never reached the limited state")
-			sess.EvaluateRateLimit(time.Now(), imClass)
-		}
-		return session
-	}
-
-	t.Run("a session starting on a limited account is told", func(t *testing.T) {
-		session := limitedSession(t)
-
-		seedRateLimitAlert(session, imClass)
-
-		assert.Equal(t, []string{"limit"}, rateLimitEventStatuses(t, session))
-	})
-
-	t.Run("a session starting on a clear account is told nothing", func(t *testing.T) {
-		session := newTestWebAPISession(t, tightRateLimitClasses())
-
-		seedRateLimitAlert(session, imClass)
-
-		assert.Empty(t, rateLimitEventStatuses(t, session))
-	})
-
-	t.Run("a zero class id disables the alert", func(t *testing.T) {
-		session := limitedSession(t)
-
-		seedRateLimitAlert(session, 0)
-
-		assert.Empty(t, rateLimitEventStatuses(t, session))
-	})
-}
-
 // Retry-After must name a wait that actually clears the limit. A rejected
 // request is still charged, so a client retrying on the advertised interval
 // drives the moving average toward that interval: a hint below the class's
@@ -302,7 +474,7 @@ func TestRateLimitMiddleware_OSCAR_retryAfterMatchesClass(t *testing.T) {
 	middleware := newTestRateLimitMiddleware()
 
 	handler := middleware.OSCAR(wire.ICBM, wire.ICBMChannelMsgToHost)(
-		func(w http.ResponseWriter, r *http.Request, s *state.WebAPISession) {})
+		func(w http.ResponseWriter, r *http.Request, s *Session) {})
 
 	classID, ok := wire.DefaultSNACRateLimits().RateClassLookup(wire.ICBM, wire.ICBMChannelMsgToHost)
 	require.True(t, ok)

+ 190 - 0
server/webapi/mock_bart_service_test.go

@@ -0,0 +1,190 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package webapi
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockBARTService creates a new instance of mockBARTService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockBARTService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockBARTService {
+	mock := &mockBARTService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockBARTService is an autogenerated mock type for the BARTService type
+type mockBARTService struct {
+	mock.Mock
+}
+
+type mockBARTService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockBARTService) EXPECT() *mockBARTService_Expecter {
+	return &mockBARTService_Expecter{mock: &_m.Mock}
+}
+
+// RetrieveItem provides a mock function for the type mockBARTService
+func (_mock *mockBARTService) RetrieveItem(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RetrieveItem")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x10_0x04_BARTDownloadQuery) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNACFrame, wire.SNAC_0x10_0x04_BARTDownloadQuery) error); ok {
+		r1 = returnFunc(ctx, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockBARTService_RetrieveItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveItem'
+type mockBARTService_RetrieveItem_Call struct {
+	*mock.Call
+}
+
+// RetrieveItem is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x10_0x04_BARTDownloadQuery
+func (_e *mockBARTService_Expecter) RetrieveItem(ctx interface{}, inFrame interface{}, inBody interface{}) *mockBARTService_RetrieveItem_Call {
+	return &mockBARTService_RetrieveItem_Call{Call: _e.mock.On("RetrieveItem", ctx, inFrame, inBody)}
+}
+
+func (_c *mockBARTService_RetrieveItem_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery)) *mockBARTService_RetrieveItem_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 wire.SNACFrame
+		if args[1] != nil {
+			arg1 = args[1].(wire.SNACFrame)
+		}
+		var arg2 wire.SNAC_0x10_0x04_BARTDownloadQuery
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNAC_0x10_0x04_BARTDownloadQuery)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockBARTService_RetrieveItem_Call) Return(sNACMessage wire.SNACMessage, err error) *mockBARTService_RetrieveItem_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockBARTService_RetrieveItem_Call) RunAndReturn(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error)) *mockBARTService_RetrieveItem_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UpsertItem provides a mock function for the type mockBARTService
+func (_mock *mockBARTService) UpsertItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x02_BARTUploadQuery) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UpsertItem")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x10_0x02_BARTUploadQuery) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x10_0x02_BARTUploadQuery) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x10_0x02_BARTUploadQuery) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockBARTService_UpsertItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpsertItem'
+type mockBARTService_UpsertItem_Call struct {
+	*mock.Call
+}
+
+// UpsertItem is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x10_0x02_BARTUploadQuery
+func (_e *mockBARTService_Expecter) UpsertItem(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockBARTService_UpsertItem_Call {
+	return &mockBARTService_UpsertItem_Call{Call: _e.mock.On("UpsertItem", ctx, instance, inFrame, inBody)}
+}
+
+func (_c *mockBARTService_UpsertItem_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x02_BARTUploadQuery)) *mockBARTService_UpsertItem_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x10_0x02_BARTUploadQuery
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x10_0x02_BARTUploadQuery)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockBARTService_UpsertItem_Call) Return(sNACMessage wire.SNACMessage, err error) *mockBARTService_UpsertItem_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockBARTService_UpsertItem_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x02_BARTUploadQuery) (wire.SNACMessage, error)) *mockBARTService_UpsertItem_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 160 - 0
server/webapi/mock_buddy_broadcaster_test.go

@@ -0,0 +1,160 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package webapi
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockBuddyBroadcaster creates a new instance of mockBuddyBroadcaster. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockBuddyBroadcaster(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockBuddyBroadcaster {
+	mock := &mockBuddyBroadcaster{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockBuddyBroadcaster is an autogenerated mock type for the BuddyBroadcaster type
+type mockBuddyBroadcaster struct {
+	mock.Mock
+}
+
+type mockBuddyBroadcaster_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockBuddyBroadcaster) EXPECT() *mockBuddyBroadcaster_Expecter {
+	return &mockBuddyBroadcaster_Expecter{mock: &_m.Mock}
+}
+
+// BroadcastBuddyArrived provides a mock function for the type mockBuddyBroadcaster
+func (_mock *mockBuddyBroadcaster) BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error {
+	ret := _mock.Called(ctx, screenName, userInfo)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BroadcastBuddyArrived")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, wire.TLVUserInfo) error); ok {
+		r0 = returnFunc(ctx, screenName, userInfo)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockBuddyBroadcaster_BroadcastBuddyArrived_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BroadcastBuddyArrived'
+type mockBuddyBroadcaster_BroadcastBuddyArrived_Call struct {
+	*mock.Call
+}
+
+// BroadcastBuddyArrived is a helper method to define mock.On call
+//   - ctx context.Context
+//   - screenName state.IdentScreenName
+//   - userInfo wire.TLVUserInfo
+func (_e *mockBuddyBroadcaster_Expecter) BroadcastBuddyArrived(ctx interface{}, screenName interface{}, userInfo interface{}) *mockBuddyBroadcaster_BroadcastBuddyArrived_Call {
+	return &mockBuddyBroadcaster_BroadcastBuddyArrived_Call{Call: _e.mock.On("BroadcastBuddyArrived", ctx, screenName, userInfo)}
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastBuddyArrived_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo)) *mockBuddyBroadcaster_BroadcastBuddyArrived_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 state.IdentScreenName
+		if args[1] != nil {
+			arg1 = args[1].(state.IdentScreenName)
+		}
+		var arg2 wire.TLVUserInfo
+		if args[2] != nil {
+			arg2 = args[2].(wire.TLVUserInfo)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastBuddyArrived_Call) Return(err error) *mockBuddyBroadcaster_BroadcastBuddyArrived_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastBuddyArrived_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error) *mockBuddyBroadcaster_BroadcastBuddyArrived_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// BroadcastBuddyDeparted provides a mock function for the type mockBuddyBroadcaster
+func (_mock *mockBuddyBroadcaster) BroadcastBuddyDeparted(ctx context.Context, screenName state.IdentScreenName) error {
+	ret := _mock.Called(ctx, screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BroadcastBuddyDeparted")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) error); ok {
+		r0 = returnFunc(ctx, screenName)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockBuddyBroadcaster_BroadcastBuddyDeparted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BroadcastBuddyDeparted'
+type mockBuddyBroadcaster_BroadcastBuddyDeparted_Call struct {
+	*mock.Call
+}
+
+// BroadcastBuddyDeparted is a helper method to define mock.On call
+//   - ctx context.Context
+//   - screenName state.IdentScreenName
+func (_e *mockBuddyBroadcaster_Expecter) BroadcastBuddyDeparted(ctx interface{}, screenName interface{}) *mockBuddyBroadcaster_BroadcastBuddyDeparted_Call {
+	return &mockBuddyBroadcaster_BroadcastBuddyDeparted_Call{Call: _e.mock.On("BroadcastBuddyDeparted", ctx, screenName)}
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastBuddyDeparted_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName)) *mockBuddyBroadcaster_BroadcastBuddyDeparted_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 state.IdentScreenName
+		if args[1] != nil {
+			arg1 = args[1].(state.IdentScreenName)
+		}
+		run(
+			arg0,
+			arg1,
+		)
+	})
+	return _c
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastBuddyDeparted_Call) Return(err error) *mockBuddyBroadcaster_BroadcastBuddyDeparted_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastBuddyDeparted_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName) error) *mockBuddyBroadcaster_BroadcastBuddyDeparted_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 108 - 0
server/webapi/mock_buddy_icon_retriever_test.go

@@ -0,0 +1,108 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package webapi
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockBuddyIconRetriever creates a new instance of mockBuddyIconRetriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockBuddyIconRetriever(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockBuddyIconRetriever {
+	mock := &mockBuddyIconRetriever{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockBuddyIconRetriever is an autogenerated mock type for the BuddyIconRetriever type
+type mockBuddyIconRetriever struct {
+	mock.Mock
+}
+
+type mockBuddyIconRetriever_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockBuddyIconRetriever) EXPECT() *mockBuddyIconRetriever_Expecter {
+	return &mockBuddyIconRetriever_Expecter{mock: &_m.Mock}
+}
+
+// BuddyIconMetadata provides a mock function for the type mockBuddyIconRetriever
+func (_mock *mockBuddyIconRetriever) BuddyIconMetadata(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error) {
+	ret := _mock.Called(ctx, screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BuddyIconMetadata")
+	}
+
+	var r0 *wire.BARTID
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) (*wire.BARTID, error)); ok {
+		return returnFunc(ctx, screenName)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) *wire.BARTID); ok {
+		r0 = returnFunc(ctx, screenName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.BARTID)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, state.IdentScreenName) error); ok {
+		r1 = returnFunc(ctx, screenName)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockBuddyIconRetriever_BuddyIconMetadata_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BuddyIconMetadata'
+type mockBuddyIconRetriever_BuddyIconMetadata_Call struct {
+	*mock.Call
+}
+
+// BuddyIconMetadata is a helper method to define mock.On call
+//   - ctx context.Context
+//   - screenName state.IdentScreenName
+func (_e *mockBuddyIconRetriever_Expecter) BuddyIconMetadata(ctx interface{}, screenName interface{}) *mockBuddyIconRetriever_BuddyIconMetadata_Call {
+	return &mockBuddyIconRetriever_BuddyIconMetadata_Call{Call: _e.mock.On("BuddyIconMetadata", ctx, screenName)}
+}
+
+func (_c *mockBuddyIconRetriever_BuddyIconMetadata_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName)) *mockBuddyIconRetriever_BuddyIconMetadata_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 state.IdentScreenName
+		if args[1] != nil {
+			arg1 = args[1].(state.IdentScreenName)
+		}
+		run(
+			arg0,
+			arg1,
+		)
+	})
+	return _c
+}
+
+func (_c *mockBuddyIconRetriever_BuddyIconMetadata_Call) Return(bARTID *wire.BARTID, err error) *mockBuddyIconRetriever_BuddyIconMetadata_Call {
+	_c.Call.Return(bARTID, err)
+	return _c
+}
+
+func (_c *mockBuddyIconRetriever_BuddyIconMetadata_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error)) *mockBuddyIconRetriever_BuddyIconMetadata_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 111 - 0
server/webapi/mock_dir_search_service_test.go

@@ -0,0 +1,111 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package webapi
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockDirSearchService creates a new instance of mockDirSearchService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockDirSearchService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockDirSearchService {
+	mock := &mockDirSearchService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockDirSearchService is an autogenerated mock type for the DirSearchService type
+type mockDirSearchService struct {
+	mock.Mock
+}
+
+type mockDirSearchService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockDirSearchService) EXPECT() *mockDirSearchService_Expecter {
+	return &mockDirSearchService_Expecter{mock: &_m.Mock}
+}
+
+// InfoQuery provides a mock function for the type mockDirSearchService
+func (_mock *mockDirSearchService) InfoQuery(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for InfoQuery")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) error); ok {
+		r1 = returnFunc(ctx, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockDirSearchService_InfoQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InfoQuery'
+type mockDirSearchService_InfoQuery_Call struct {
+	*mock.Call
+}
+
+// InfoQuery is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x0F_0x02_InfoQuery
+func (_e *mockDirSearchService_Expecter) InfoQuery(ctx interface{}, inFrame interface{}, inBody interface{}) *mockDirSearchService_InfoQuery_Call {
+	return &mockDirSearchService_InfoQuery_Call{Call: _e.mock.On("InfoQuery", ctx, inFrame, inBody)}
+}
+
+func (_c *mockDirSearchService_InfoQuery_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery)) *mockDirSearchService_InfoQuery_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 wire.SNACFrame
+		if args[1] != nil {
+			arg1 = args[1].(wire.SNACFrame)
+		}
+		var arg2 wire.SNAC_0x0F_0x02_InfoQuery
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNAC_0x0F_0x02_InfoQuery)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockDirSearchService_InfoQuery_Call) Return(sNACMessage wire.SNACMessage, err error) *mockDirSearchService_InfoQuery_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockDirSearchService_InfoQuery_Call) RunAndReturn(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)) *mockDirSearchService_InfoQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 329 - 0
server/webapi/mock_feedbag_service_test.go

@@ -0,0 +1,329 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package webapi
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockFeedbagService creates a new instance of mockFeedbagService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockFeedbagService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockFeedbagService {
+	mock := &mockFeedbagService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockFeedbagService is an autogenerated mock type for the FeedbagService type
+type mockFeedbagService struct {
+	mock.Mock
+}
+
+type mockFeedbagService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockFeedbagService) EXPECT() *mockFeedbagService_Expecter {
+	return &mockFeedbagService_Expecter{mock: &_m.Mock}
+}
+
+// DeleteItem provides a mock function for the type mockFeedbagService
+func (_mock *mockFeedbagService) DeleteItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (*wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for DeleteItem")
+	}
+
+	var r0 *wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (*wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x0A_FeedbagDeleteItem) *wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.SNACMessage)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x0A_FeedbagDeleteItem) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockFeedbagService_DeleteItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteItem'
+type mockFeedbagService_DeleteItem_Call struct {
+	*mock.Call
+}
+
+// DeleteItem is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem
+func (_e *mockFeedbagService_Expecter) DeleteItem(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagService_DeleteItem_Call {
+	return &mockFeedbagService_DeleteItem_Call{Call: _e.mock.On("DeleteItem", ctx, instance, inFrame, inBody)}
+}
+
+func (_c *mockFeedbagService_DeleteItem_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem)) *mockFeedbagService_DeleteItem_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x13_0x0A_FeedbagDeleteItem
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x13_0x0A_FeedbagDeleteItem)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_DeleteItem_Call) Return(sNACMessage *wire.SNACMessage, err error) *mockFeedbagService_DeleteItem_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockFeedbagService_DeleteItem_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (*wire.SNACMessage, error)) *mockFeedbagService_DeleteItem_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// Query provides a mock function for the type mockFeedbagService
+func (_mock *mockFeedbagService) Query(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Query")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockFeedbagService_Query_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Query'
+type mockFeedbagService_Query_Call struct {
+	*mock.Call
+}
+
+// Query is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+func (_e *mockFeedbagService_Expecter) Query(ctx interface{}, instance interface{}, inFrame interface{}) *mockFeedbagService_Query_Call {
+	return &mockFeedbagService_Query_Call{Call: _e.mock.On("Query", ctx, instance, inFrame)}
+}
+
+func (_c *mockFeedbagService_Query_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame)) *mockFeedbagService_Query_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_Query_Call) Return(sNACMessage wire.SNACMessage, err error) *mockFeedbagService_Query_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockFeedbagService_Query_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error)) *mockFeedbagService_Query_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UpsertItem provides a mock function for the type mockFeedbagService
+func (_mock *mockFeedbagService) UpsertItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) (*wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame, items)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UpsertItem")
+	}
+
+	var r0 *wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, []wire.FeedbagItem) (*wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame, items)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, []wire.FeedbagItem) *wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame, items)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.SNACMessage)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame, []wire.FeedbagItem) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame, items)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockFeedbagService_UpsertItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpsertItem'
+type mockFeedbagService_UpsertItem_Call struct {
+	*mock.Call
+}
+
+// UpsertItem is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - items []wire.FeedbagItem
+func (_e *mockFeedbagService_Expecter) UpsertItem(ctx interface{}, instance interface{}, inFrame interface{}, items interface{}) *mockFeedbagService_UpsertItem_Call {
+	return &mockFeedbagService_UpsertItem_Call{Call: _e.mock.On("UpsertItem", ctx, instance, inFrame, items)}
+}
+
+func (_c *mockFeedbagService_UpsertItem_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem)) *mockFeedbagService_UpsertItem_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 []wire.FeedbagItem
+		if args[3] != nil {
+			arg3 = args[3].([]wire.FeedbagItem)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_UpsertItem_Call) Return(sNACMessage *wire.SNACMessage, err error) *mockFeedbagService_UpsertItem_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockFeedbagService_UpsertItem_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) (*wire.SNACMessage, error)) *mockFeedbagService_UpsertItem_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// Use provides a mock function for the type mockFeedbagService
+func (_mock *mockFeedbagService) Use(ctx context.Context, instance *state.SessionInstance) error {
+	ret := _mock.Called(ctx, instance)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Use")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance) error); ok {
+		r0 = returnFunc(ctx, instance)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockFeedbagService_Use_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Use'
+type mockFeedbagService_Use_Call struct {
+	*mock.Call
+}
+
+// Use is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+func (_e *mockFeedbagService_Expecter) Use(ctx interface{}, instance interface{}) *mockFeedbagService_Use_Call {
+	return &mockFeedbagService_Use_Call{Call: _e.mock.On("Use", ctx, instance)}
+}
+
+func (_c *mockFeedbagService_Use_Call) Run(run func(ctx context.Context, instance *state.SessionInstance)) *mockFeedbagService_Use_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		run(
+			arg0,
+			arg1,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_Use_Call) Return(err error) *mockFeedbagService_Use_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockFeedbagService_Use_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance) error) *mockFeedbagService_Use_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 261 - 0
server/webapi/mock_icbm_service_test.go

@@ -0,0 +1,261 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package webapi
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockICBMService creates a new instance of mockICBMService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockICBMService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockICBMService {
+	mock := &mockICBMService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockICBMService is an autogenerated mock type for the ICBMService type
+type mockICBMService struct {
+	mock.Mock
+}
+
+type mockICBMService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockICBMService) EXPECT() *mockICBMService_Expecter {
+	return &mockICBMService_Expecter{mock: &_m.Mock}
+}
+
+// ChannelMsgToHost provides a mock function for the type mockICBMService
+func (_mock *mockICBMService) ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ChannelMsgToHost")
+	}
+
+	var r0 *wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) *wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.SNACMessage)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockICBMService_ChannelMsgToHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChannelMsgToHost'
+type mockICBMService_ChannelMsgToHost_Call struct {
+	*mock.Call
+}
+
+// ChannelMsgToHost is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
+func (_e *mockICBMService_Expecter) ChannelMsgToHost(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockICBMService_ChannelMsgToHost_Call {
+	return &mockICBMService_ChannelMsgToHost_Call{Call: _e.mock.On("ChannelMsgToHost", ctx, instance, inFrame, inBody)}
+}
+
+func (_c *mockICBMService_ChannelMsgToHost_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost)) *mockICBMService_ChannelMsgToHost_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x04_0x06_ICBMChannelMsgToHost)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockICBMService_ChannelMsgToHost_Call) Return(sNACMessage *wire.SNACMessage, err error) *mockICBMService_ChannelMsgToHost_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockICBMService_ChannelMsgToHost_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)) *mockICBMService_ChannelMsgToHost_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// ClientEvent provides a mock function for the type mockICBMService
+func (_mock *mockICBMService) ClientEvent(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error {
+	ret := _mock.Called(ctx, instance, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ClientEvent")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x04_0x14_ICBMClientEvent) error); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockICBMService_ClientEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClientEvent'
+type mockICBMService_ClientEvent_Call struct {
+	*mock.Call
+}
+
+// ClientEvent is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x04_0x14_ICBMClientEvent
+func (_e *mockICBMService_Expecter) ClientEvent(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockICBMService_ClientEvent_Call {
+	return &mockICBMService_ClientEvent_Call{Call: _e.mock.On("ClientEvent", ctx, instance, inFrame, inBody)}
+}
+
+func (_c *mockICBMService_ClientEvent_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent)) *mockICBMService_ClientEvent_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x04_0x14_ICBMClientEvent
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x04_0x14_ICBMClientEvent)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockICBMService_ClientEvent_Call) Return(err error) *mockICBMService_ClientEvent_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockICBMService_ClientEvent_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error) *mockICBMService_ClientEvent_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// OfflineRetrieve provides a mock function for the type mockICBMService
+func (_mock *mockICBMService) OfflineRetrieve(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for OfflineRetrieve")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockICBMService_OfflineRetrieve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OfflineRetrieve'
+type mockICBMService_OfflineRetrieve_Call struct {
+	*mock.Call
+}
+
+// OfflineRetrieve is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+func (_e *mockICBMService_Expecter) OfflineRetrieve(ctx interface{}, instance interface{}, inFrame interface{}) *mockICBMService_OfflineRetrieve_Call {
+	return &mockICBMService_OfflineRetrieve_Call{Call: _e.mock.On("OfflineRetrieve", ctx, instance, inFrame)}
+}
+
+func (_c *mockICBMService_OfflineRetrieve_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame)) *mockICBMService_OfflineRetrieve_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockICBMService_OfflineRetrieve_Call) Return(sNACMessage wire.SNACMessage, err error) *mockICBMService_OfflineRetrieve_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockICBMService_OfflineRetrieve_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error)) *mockICBMService_OfflineRetrieve_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 331 - 0
server/webapi/mock_locate_service_test.go

@@ -0,0 +1,331 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package webapi
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockLocateService creates a new instance of mockLocateService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockLocateService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockLocateService {
+	mock := &mockLocateService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockLocateService is an autogenerated mock type for the LocateService type
+type mockLocateService struct {
+	mock.Mock
+}
+
+type mockLocateService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockLocateService) EXPECT() *mockLocateService_Expecter {
+	return &mockLocateService_Expecter{mock: &_m.Mock}
+}
+
+// DirInfo provides a mock function for the type mockLocateService
+func (_mock *mockLocateService) DirInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for DirInfo")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x02_0x0B_LocateGetDirInfo) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNACFrame, wire.SNAC_0x02_0x0B_LocateGetDirInfo) error); ok {
+		r1 = returnFunc(ctx, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockLocateService_DirInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DirInfo'
+type mockLocateService_DirInfo_Call struct {
+	*mock.Call
+}
+
+// DirInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x02_0x0B_LocateGetDirInfo
+func (_e *mockLocateService_Expecter) DirInfo(ctx interface{}, inFrame interface{}, inBody interface{}) *mockLocateService_DirInfo_Call {
+	return &mockLocateService_DirInfo_Call{Call: _e.mock.On("DirInfo", ctx, inFrame, inBody)}
+}
+
+func (_c *mockLocateService_DirInfo_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x0B_LocateGetDirInfo)) *mockLocateService_DirInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 wire.SNACFrame
+		if args[1] != nil {
+			arg1 = args[1].(wire.SNACFrame)
+		}
+		var arg2 wire.SNAC_0x02_0x0B_LocateGetDirInfo
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNAC_0x02_0x0B_LocateGetDirInfo)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockLocateService_DirInfo_Call) Return(sNACMessage wire.SNACMessage, err error) *mockLocateService_DirInfo_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockLocateService_DirInfo_Call) RunAndReturn(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error)) *mockLocateService_DirInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetDirInfo provides a mock function for the type mockLocateService
+func (_mock *mockLocateService) SetDirInfo(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetDirInfo")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x02_0x09_LocateSetDirInfo) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x02_0x09_LocateSetDirInfo) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockLocateService_SetDirInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDirInfo'
+type mockLocateService_SetDirInfo_Call struct {
+	*mock.Call
+}
+
+// SetDirInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x02_0x09_LocateSetDirInfo
+func (_e *mockLocateService_Expecter) SetDirInfo(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockLocateService_SetDirInfo_Call {
+	return &mockLocateService_SetDirInfo_Call{Call: _e.mock.On("SetDirInfo", ctx, instance, inFrame, inBody)}
+}
+
+func (_c *mockLocateService_SetDirInfo_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x09_LocateSetDirInfo)) *mockLocateService_SetDirInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x02_0x09_LocateSetDirInfo
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x02_0x09_LocateSetDirInfo)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockLocateService_SetDirInfo_Call) Return(sNACMessage wire.SNACMessage, err error) *mockLocateService_SetDirInfo_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockLocateService_SetDirInfo_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error)) *mockLocateService_SetDirInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetInfo provides a mock function for the type mockLocateService
+func (_mock *mockLocateService) SetInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x02_0x04_LocateSetInfo) error {
+	ret := _mock.Called(ctx, instance, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetInfo")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNAC_0x02_0x04_LocateSetInfo) error); ok {
+		r0 = returnFunc(ctx, instance, inBody)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockLocateService_SetInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetInfo'
+type mockLocateService_SetInfo_Call struct {
+	*mock.Call
+}
+
+// SetInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inBody wire.SNAC_0x02_0x04_LocateSetInfo
+func (_e *mockLocateService_Expecter) SetInfo(ctx interface{}, instance interface{}, inBody interface{}) *mockLocateService_SetInfo_Call {
+	return &mockLocateService_SetInfo_Call{Call: _e.mock.On("SetInfo", ctx, instance, inBody)}
+}
+
+func (_c *mockLocateService_SetInfo_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x02_0x04_LocateSetInfo)) *mockLocateService_SetInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNAC_0x02_0x04_LocateSetInfo
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNAC_0x02_0x04_LocateSetInfo)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockLocateService_SetInfo_Call) Return(err error) *mockLocateService_SetInfo_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockLocateService_SetInfo_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x02_0x04_LocateSetInfo) error) *mockLocateService_SetInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UserInfoQuery provides a mock function for the type mockLocateService
+func (_mock *mockLocateService) UserInfoQuery(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UserInfoQuery")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x02_0x05_LocateUserInfoQuery) wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x02_0x05_LocateUserInfoQuery) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockLocateService_UserInfoQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UserInfoQuery'
+type mockLocateService_UserInfoQuery_Call struct {
+	*mock.Call
+}
+
+// UserInfoQuery is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery
+func (_e *mockLocateService_Expecter) UserInfoQuery(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockLocateService_UserInfoQuery_Call {
+	return &mockLocateService_UserInfoQuery_Call{Call: _e.mock.On("UserInfoQuery", ctx, instance, inFrame, inBody)}
+}
+
+func (_c *mockLocateService_UserInfoQuery_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery)) *mockLocateService_UserInfoQuery_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x02_0x05_LocateUserInfoQuery
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x02_0x05_LocateUserInfoQuery)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockLocateService_UserInfoQuery_Call) Return(sNACMessage wire.SNACMessage, err error) *mockLocateService_UserInfoQuery_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockLocateService_UserInfoQuery_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error)) *mockLocateService_UserInfoQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 163 - 0
server/webapi/mock_webapi_session_resolver_test.go

@@ -0,0 +1,163 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package webapi
+
+import (
+	"context"
+
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockSessionResolver creates a new instance of mockSessionResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockSessionResolver(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockSessionResolver {
+	mock := &mockSessionResolver{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockSessionResolver is an autogenerated mock type for the SessionResolver type
+type mockSessionResolver struct {
+	mock.Mock
+}
+
+type mockSessionResolver_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockSessionResolver) EXPECT() *mockSessionResolver_Expecter {
+	return &mockSessionResolver_Expecter{mock: &_m.Mock}
+}
+
+// GetSession provides a mock function for the type mockSessionResolver
+func (_mock *mockSessionResolver) GetSession(ctx context.Context, aimsid string) (*Session, error) {
+	ret := _mock.Called(ctx, aimsid)
+
+	if len(ret) == 0 {
+		panic("no return value specified for GetSession")
+	}
+
+	var r0 *Session
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*Session, error)); ok {
+		return returnFunc(ctx, aimsid)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, string) *Session); ok {
+		r0 = returnFunc(ctx, aimsid)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*Session)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
+		r1 = returnFunc(ctx, aimsid)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockSessionResolver_GetSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSession'
+type mockSessionResolver_GetSession_Call struct {
+	*mock.Call
+}
+
+// GetSession is a helper method to define mock.On call
+//   - ctx context.Context
+//   - aimsid string
+func (_e *mockSessionResolver_Expecter) GetSession(ctx interface{}, aimsid interface{}) *mockSessionResolver_GetSession_Call {
+	return &mockSessionResolver_GetSession_Call{Call: _e.mock.On("GetSession", ctx, aimsid)}
+}
+
+func (_c *mockSessionResolver_GetSession_Call) Run(run func(ctx context.Context, aimsid string)) *mockSessionResolver_GetSession_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 string
+		if args[1] != nil {
+			arg1 = args[1].(string)
+		}
+		run(
+			arg0,
+			arg1,
+		)
+	})
+	return _c
+}
+
+func (_c *mockSessionResolver_GetSession_Call) Return(session *Session, err error) *mockSessionResolver_GetSession_Call {
+	_c.Call.Return(session, err)
+	return _c
+}
+
+func (_c *mockSessionResolver_GetSession_Call) RunAndReturn(run func(ctx context.Context, aimsid string) (*Session, error)) *mockSessionResolver_GetSession_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// TouchSession provides a mock function for the type mockSessionResolver
+func (_mock *mockSessionResolver) TouchSession(ctx context.Context, aimsid string) error {
+	ret := _mock.Called(ctx, aimsid)
+
+	if len(ret) == 0 {
+		panic("no return value specified for TouchSession")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
+		r0 = returnFunc(ctx, aimsid)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockSessionResolver_TouchSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TouchSession'
+type mockSessionResolver_TouchSession_Call struct {
+	*mock.Call
+}
+
+// TouchSession is a helper method to define mock.On call
+//   - ctx context.Context
+//   - aimsid string
+func (_e *mockSessionResolver_Expecter) TouchSession(ctx interface{}, aimsid interface{}) *mockSessionResolver_TouchSession_Call {
+	return &mockSessionResolver_TouchSession_Call{Call: _e.mock.On("TouchSession", ctx, aimsid)}
+}
+
+func (_c *mockSessionResolver_TouchSession_Call) Run(run func(ctx context.Context, aimsid string)) *mockSessionResolver_TouchSession_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 string
+		if args[1] != nil {
+			arg1 = args[1].(string)
+		}
+		run(
+			arg0,
+			arg1,
+		)
+	})
+	return _c
+}
+
+func (_c *mockSessionResolver_TouchSession_Call) Return(err error) *mockSessionResolver_TouchSession_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockSessionResolver_TouchSession_Call) RunAndReturn(run func(ctx context.Context, aimsid string) error) *mockSessionResolver_TouchSession_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 21 - 43
server/webapi/handlers/preference.go → server/webapi/preference_handler.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"context"
@@ -9,7 +9,6 @@ import (
 	"reflect"
 	"strings"
 
-	"github.com/mk6i/open-oscar-server/server/webapi/types"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
@@ -17,7 +16,7 @@ import (
 // PreferenceHandler handles Web AIM API preference-related endpoints.
 type PreferenceHandler struct {
 	FeedbagService FeedbagService
-	SessionManager *state.WebAPISessionManager
+	SessionManager *SessionManager
 	Logger         *slog.Logger
 }
 
@@ -216,7 +215,7 @@ type PermitDenyData struct {
 }
 
 // SetPreferences handles GET /preference/set requests to update user preferences.
-func (h *PreferenceHandler) SetPreferences(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *PreferenceHandler) SetPreferences(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
 	// Preferences are stored as OSCAR buddy prefs in the feedbag, which requires
@@ -228,7 +227,7 @@ func (h *PreferenceHandler) SetPreferences(w http.ResponseWriter, r *http.Reques
 	item, err := buddyPrefsItem(ctx, h.FeedbagService, instance)
 	if err != nil {
 		h.Logger.ErrorContext(ctx, "failed to retrieve feedbag", "err", err.Error())
-		h.sendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
+		SendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
 		return
 	}
 
@@ -247,14 +246,14 @@ func (h *PreferenceHandler) SetPreferences(w http.ResponseWriter, r *http.Reques
 		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
 		if _, err := h.FeedbagService.UpsertItem(ctx, instance, frame, []wire.FeedbagItem{item}); err != nil {
 			h.Logger.ErrorContext(ctx, "failed to set preferences", "err", err.Error())
-			h.sendError(w, r, http.StatusInternalServerError, "failed to save preferences")
+			SendError(w, r, http.StatusInternalServerError, "failed to save preferences")
 			return
 		}
 
 		// Notify the client's open windows via the event stream so display
 		// changes (e.g. bubbles/classic) take effect immediately without a
 		// browser refresh.
-		session.EventQueue.Push(types.EventTypePreference, applied)
+		session.EventQueue.Push(EventTypePreference, applied)
 	}
 
 	h.Logger.DebugContext(ctx, "preferences updated",
@@ -263,15 +262,11 @@ func (h *PreferenceHandler) SetPreferences(w http.ResponseWriter, r *http.Reques
 	)
 
 	// Send success response
-	response := BaseResponse{}
-	response.Response.StatusCode = 200
-	response.Response.StatusText = "OK"
-	response.Response.Data = applied
-	SendResponse(w, r, response, h.Logger)
+	SendOK(w, r, applied, h.Logger)
 }
 
 // GetPreferences handles GET /preference/get requests to retrieve user preferences.
-func (h *PreferenceHandler) GetPreferences(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *PreferenceHandler) GetPreferences(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
 	// Load the buddy-prefs bitmask from the feedbag. Absent prefs fall back to
@@ -328,11 +323,7 @@ func (h *PreferenceHandler) GetPreferences(w http.ResponseWriter, r *http.Reques
 	}
 
 	// Send response in requested format
-	response := BaseResponse{}
-	response.Response.StatusCode = 200
-	response.Response.StatusText = "OK"
-	response.Response.Data = payload
-	SendResponse(w, r, response, h.Logger)
+	SendOK(w, r, payload, h.Logger)
 }
 
 // buddyPrefsItem returns the user's buddy-prefs feedbag item, creating a fresh
@@ -462,19 +453,19 @@ func boolToPrefInt(b bool) int {
 }
 
 // SetPermitDeny handles GET /preference/setPermitDeny requests to update permit/deny settings.
-func (h *PreferenceHandler) SetPermitDeny(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *PreferenceHandler) SetPermitDeny(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
 	fb, err := h.FeedbagService.Query(r.Context(), session.OSCARSession, frame)
 	if err != nil {
-		h.sendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
+		SendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
 		return
 	}
 
 	reply, ok := fb.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
 	if !ok {
-		h.sendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
+		SendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
 		return
 	}
 
@@ -495,7 +486,7 @@ func (h *PreferenceHandler) SetPermitDeny(w http.ResponseWriter, r *http.Request
 		case "permitOnList", "5":
 			fl.SetMode(uint8(wire.FeedbagPDModePermitOnList))
 		default:
-			h.sendError(w, r, http.StatusBadRequest, "invalid pdMode value")
+			SendError(w, r, http.StatusBadRequest, "invalid pdMode value")
 			return
 		}
 	}
@@ -546,7 +537,7 @@ func (h *PreferenceHandler) SetPermitDeny(w http.ResponseWriter, r *http.Request
 		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
 		if _, err := h.FeedbagService.UpsertItem(ctx, session.OSCARSession, frame, pending); err != nil {
 			h.Logger.ErrorContext(ctx, "failed to set PD mode", "err", err.Error())
-			h.sendError(w, r, http.StatusInternalServerError, "failed to update PD mode")
+			SendError(w, r, http.StatusInternalServerError, "failed to update PD mode")
 			return
 		}
 	}
@@ -556,7 +547,7 @@ func (h *PreferenceHandler) SetPermitDeny(w http.ResponseWriter, r *http.Request
 		body := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: pending}
 		if _, err := h.FeedbagService.DeleteItem(ctx, session.OSCARSession, frame, body); err != nil {
 			h.Logger.ErrorContext(ctx, "failed to set PD mode", "err", err.Error())
-			h.sendError(w, r, http.StatusInternalServerError, "failed to update PD mode")
+			SendError(w, r, http.StatusInternalServerError, "failed to update PD mode")
 			return
 		}
 	}
@@ -567,7 +558,7 @@ func (h *PreferenceHandler) SetPermitDeny(w http.ResponseWriter, r *http.Request
 	// block/unblock menu label) only from the permitDeny event, and it sees no
 	// SNAC for the write it just made. Without this the block takes effect
 	// server-side but the UI keeps showing the buddy as unblocked.
-	session.EventQueue.Push(types.EventTypePermitDeny, pdd)
+	session.EventQueue.Push(EventTypePermitDeny, pdd)
 
 	h.Logger.DebugContext(ctx, "permit/deny settings updated",
 		"screenName", session.ScreenName.String(),
@@ -576,11 +567,7 @@ func (h *PreferenceHandler) SetPermitDeny(w http.ResponseWriter, r *http.Request
 		"denyCount", len(pdd.DenyList),
 	)
 
-	response := BaseResponse{}
-	response.Response.StatusCode = 200
-	response.Response.StatusText = "OK"
-	response.Response.Data = pdd
-	SendResponse(w, r, response, h.Logger)
+	SendOK(w, r, pdd, h.Logger)
 }
 
 func permitDenyData(fl []wire.FeedbagItem) PermitDenyData {
@@ -614,19 +601,19 @@ func permitDenyData(fl []wire.FeedbagItem) PermitDenyData {
 }
 
 // GetPermitDeny handles GET /preference/getPermitDeny requests to retrieve permit/deny settings.
-func (h *PreferenceHandler) GetPermitDeny(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *PreferenceHandler) GetPermitDeny(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
 	fb, err := h.FeedbagService.Query(r.Context(), session.OSCARSession, frame)
 	if err != nil {
-		h.sendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
+		SendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
 		return
 	}
 
 	reply, ok := fb.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
 	if !ok {
-		h.sendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
+		SendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
 		return
 	}
 
@@ -638,14 +625,5 @@ func (h *PreferenceHandler) GetPermitDeny(w http.ResponseWriter, r *http.Request
 		"denyCount", len(pdd.DenyList),
 	)
 
-	response := BaseResponse{}
-	response.Response.StatusCode = 200
-	response.Response.StatusText = "OK"
-	response.Response.Data = pdd
-	SendResponse(w, r, response, h.Logger)
-}
-
-func (h *PreferenceHandler) sendError(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
-	// todo log
-	SendError(w, r, statusCode, message)
+	SendOK(w, r, pdd, h.Logger)
 }

+ 17 - 17
server/webapi/handlers/preference_test.go → server/webapi/preference_handler_test.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"context"
@@ -10,7 +10,6 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 
-	"github.com/mk6i/open-oscar-server/server/webapi/types"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
@@ -32,19 +31,21 @@ func buddyPrefsFeedbag(prefs map[uint16]bool) wire.SNACMessage {
 }
 
 func TestPreferenceHandler_SetPreferences(t *testing.T) {
-	fs := &MockFeedbagService{}
+	fs := newMockFeedbagService(t)
 	oscarInstance := state.NewSession().AddInstance()
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
 	// Existing feedbag already has acceptCustomBart (0x0B, default false) enabled;
 	// it must survive the read-modify-write. Using a default-false pref means an
 	// observed true value can only come from the stored bit, not the default.
-	fs.On("Query", mock.Anything, oscarInstance, mock.Anything).
+	fs.EXPECT().Query(mock.Anything, oscarInstance, mock.Anything).
 		Return(buddyPrefsFeedbag(map[uint16]bool{wire.FeedbagBuddyPrefsAcceptCustomBart: true}), nil)
 
 	var upserted []wire.FeedbagItem
-	fs.On("UpsertItem", mock.Anything, oscarInstance, mock.Anything, mock.Anything).
-		Run(func(args mock.Arguments) { upserted = args.Get(3).([]wire.FeedbagItem) }).
+	fs.EXPECT().UpsertItem(mock.Anything, oscarInstance, mock.Anything, mock.Anything).
+		Run(func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) {
+			upserted = items
+		}).
 		Return(nil, nil)
 
 	handler := &PreferenceHandler{
@@ -69,16 +70,15 @@ func TestPreferenceHandler_SetPreferences(t *testing.T) {
 		assertPref(0x15, false)                                  // playIMSound off (default true)
 		assertPref(0x16, true)                                   // discloseTyping on
 	}
-	fs.AssertExpectations(t)
 }
 
 func TestPreferenceHandler_GetPreferences_Selected(t *testing.T) {
-	fs := &MockFeedbagService{}
+	fs := newMockFeedbagService(t)
 	oscarInstance := state.NewSession().AddInstance()
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
 	// playIMSound (0x15) explicitly disabled in the feedbag.
-	fs.On("Query", mock.Anything, oscarInstance, mock.Anything).
+	fs.EXPECT().Query(mock.Anything, oscarInstance, mock.Anything).
 		Return(buddyPrefsFeedbag(map[uint16]bool{0x15: false}), nil)
 
 	handler := &PreferenceHandler{
@@ -101,12 +101,12 @@ func TestPreferenceHandler_GetPreferences_Selected(t *testing.T) {
 }
 
 func TestPreferenceHandler_GetPreferences_All(t *testing.T) {
-	fs := &MockFeedbagService{}
+	fs := newMockFeedbagService(t)
 	oscarInstance := state.NewSession().AddInstance()
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
 	// Empty feedbag -> every pref resolves to its spec default.
-	fs.On("Query", mock.Anything, oscarInstance, mock.Anything).
+	fs.EXPECT().Query(mock.Anything, oscarInstance, mock.Anything).
 		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil)
 
 	handler := &PreferenceHandler{
@@ -126,11 +126,11 @@ func TestPreferenceHandler_GetPreferences_All(t *testing.T) {
 }
 
 func TestPreferenceHandler_GetPreferences_AMF(t *testing.T) {
-	fs := &MockFeedbagService{}
+	fs := newMockFeedbagService(t)
 	oscarInstance := state.NewSession().AddInstance()
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
-	fs.On("Query", mock.Anything, oscarInstance, mock.Anything).
+	fs.EXPECT().Query(mock.Anything, oscarInstance, mock.Anything).
 		Return(buddyPrefsFeedbag(map[uint16]bool{0x15: false}), nil)
 
 	handler := &PreferenceHandler{
@@ -180,13 +180,13 @@ func TestEffectiveBuddyPrefs_AppliesDefaultsWhenNothingSet(t *testing.T) {
 func TestPreferenceHandler_SetPermitDeny_QueuesPermitDenyEvent(t *testing.T) {
 	// The client renders blocked buddies from the permitDeny event alone, and it
 	// sees no SNAC for its own write, so the handler has to queue the new state.
-	fs := &MockFeedbagService{}
+	fs := newMockFeedbagService(t)
 	oscarInstance := state.NewSession().AddInstance()
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
-	fs.On("Query", mock.Anything, oscarInstance, mock.Anything).
+	fs.EXPECT().Query(mock.Anything, oscarInstance, mock.Anything).
 		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil)
-	fs.On("UpsertItem", mock.Anything, oscarInstance, mock.Anything, mock.Anything).
+	fs.EXPECT().UpsertItem(mock.Anything, oscarInstance, mock.Anything, mock.Anything).
 		Return(nil, nil)
 
 	handler := &PreferenceHandler{
@@ -206,7 +206,7 @@ func TestPreferenceHandler_SetPermitDeny_QueuesPermitDenyEvent(t *testing.T) {
 	var pdd PermitDenyData
 	var found bool
 	for _, event := range session.EventQueue.GetAllEvents() {
-		if event.Type == types.EventTypePermitDeny {
+		if event.Type == EventTypePermitDeny {
 			pdd, found = event.Data.(PermitDenyData)
 		}
 	}

+ 21 - 63
server/webapi/handlers/presence.go → server/webapi/presence_handler.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"context"
@@ -8,14 +8,13 @@ import (
 	"strings"
 	"time"
 
-	"github.com/mk6i/open-oscar-server/server/webapi/types"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
 // PresenceHandler handles Web AIM API presence-related endpoints.
 type PresenceHandler struct {
-	SessionManager   *state.WebAPISessionManager
+	SessionManager   *SessionManager
 	FeedbagService   FeedbagService
 	BuddyBroadcaster BuddyBroadcaster
 	LocateService    LocateService
@@ -23,20 +22,6 @@ type PresenceHandler struct {
 	Logger           *slog.Logger
 }
 
-// LocateService issues OSCAR locate user-info queries. A single query performs
-// the blocking relationship check, the online/offline session lookup, and
-// returns the user's presence info plus optional profile and away-message data.
-type LocateService interface {
-	SetInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x02_0x04_LocateSetInfo) error
-	UserInfoQuery(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error)
-}
-
-// BuddyBroadcaster broadcasts buddy presence updates
-type BuddyBroadcaster interface {
-	BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error
-	BroadcastBuddyDeparted(ctx context.Context, screenName state.IdentScreenName) error
-}
-
 // maxPresenceTargets caps how many screen names a single presence/get request
 // may query in target-list ("t=") mode.
 const maxPresenceTargets = 10
@@ -91,7 +76,7 @@ type BuddyPresenceInfo struct {
 }
 
 // GetPresence handles GET /presence/get requests.
-func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 	aimsid := session.AimSID
 
@@ -102,11 +87,6 @@ func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request, se
 	// Get target users if specified
 	targetUsers := r.URL.Query().Get("t")
 
-	// Prepare response
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-
 	// Create PresenceData struct to hold the response data
 	presenceData := PresenceData{}
 
@@ -123,7 +103,7 @@ func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request, se
 		// Get presence for specific users
 		users := strings.Split(targetUsers, ",")
 		if len(users) > maxPresenceTargets {
-			h.sendError(w, r, http.StatusBadRequest, fmt.Sprintf("too many screen names requested (max %d)", maxPresenceTargets))
+			SendError(w, r, http.StatusBadRequest, fmt.Sprintf("too many screen names requested (max %d)", maxPresenceTargets))
 			return
 		}
 		presenceList := make([]BuddyPresenceInfo, 0, len(users))
@@ -148,11 +128,8 @@ func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request, se
 		presenceData.Groups = []BuddyGroupInfo{}
 	}
 
-	// Set the data to the response
-	resp.Response.Data = presenceData
-
 	// Send response in requested format
-	SendResponse(w, r, resp, h.Logger)
+	SendOK(w, r, presenceData, h.Logger)
 
 	h.Logger.DebugContext(ctx, "presence retrieved",
 		"aimsid", aimsid,
@@ -162,7 +139,7 @@ func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request, se
 }
 
 // getBuddyListGroups retrieves the buddy list organized by groups.
-func (h *PresenceHandler) getBuddyListGroups(ctx context.Context, session *state.WebAPISession, wantProfileMsg bool) ([]BuddyGroupInfo, error) {
+func (h *PresenceHandler) getBuddyListGroups(ctx context.Context, session *Session, wantProfileMsg bool) ([]BuddyGroupInfo, error) {
 	// Get feedbag items via the feedbag service
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
 	reply, err := h.FeedbagService.Query(ctx, session.OSCARSession, frame)
@@ -334,13 +311,8 @@ func isICQScreenName(screenName string) bool {
 	return true
 }
 
-// sendError is a convenience method that wraps the common SendError function.
-func (h *PresenceHandler) sendError(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
-	SendError(w, r, statusCode, message)
-}
-
 // SetState handles GET /presence/setState requests to update user's presence state.
-func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
 	stateParam := r.URL.Query().Get("state")
@@ -372,7 +344,7 @@ func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request, sessi
 	case "dnd":
 		statusBitmask = wire.OServiceUserStatusDND
 	default:
-		h.sendError(w, r, http.StatusBadRequest, "invalid state parameter")
+		SendError(w, r, http.StatusBadRequest, "invalid state parameter")
 		return
 	}
 
@@ -406,10 +378,7 @@ func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request, sessi
 	)
 
 	// Send success response
-	response := BaseResponse{}
-	response.Response.StatusCode = 200
-	response.Response.StatusText = "OK"
-	response.Response.Data = &SetStateData{
+	SendOK(w, r, &SetStateData{
 		AimID:      session.ScreenName.IdentScreenName().String(),
 		DisplayID:  session.ScreenName.String(),
 		State:      stateParam,
@@ -417,12 +386,11 @@ func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request, sessi
 		StatusMsg:  "",
 		UserType:   "aim",
 		OnlineTime: time.Now().Unix(),
-	}
-	SendResponse(w, r, response, h.Logger)
+	}, h.Logger)
 }
 
 // SetStatus handles GET /presence/setStatus requests to update user's status message.
-func (h *PresenceHandler) SetStatus(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *PresenceHandler) SetStatus(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
 	// Get the status message
@@ -452,14 +420,11 @@ func (h *PresenceHandler) SetStatus(w http.ResponseWriter, r *http.Request, sess
 	)
 
 	// Send success response
-	response := BaseResponse{}
-	response.Response.StatusCode = 200
-	response.Response.StatusText = "OK"
-	SendResponse(w, r, response, h.Logger)
+	SendOK(w, r, nil, h.Logger)
 }
 
 // SetProfile handles GET /presence/setProfile requests to update user's profile.
-func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
 	// Get the profile content
@@ -467,7 +432,7 @@ func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request, ses
 
 	// Limit profile size (4KB max)
 	if len(profileText) > 4096 {
-		h.sendError(w, r, http.StatusBadRequest, "profile too large (max 4KB)")
+		SendError(w, r, http.StatusBadRequest, "profile too large (max 4KB)")
 		return
 	}
 
@@ -483,7 +448,7 @@ func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request, ses
 	}
 	if err := h.LocateService.SetInfo(ctx, instance, setInfo); err != nil {
 		h.Logger.ErrorContext(ctx, "failed to set profile", "err", err.Error())
-		h.sendError(w, r, http.StatusInternalServerError, "failed to save profile")
+		SendError(w, r, http.StatusInternalServerError, "failed to save profile")
 		return
 	}
 
@@ -493,14 +458,11 @@ func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request, ses
 	)
 
 	// Send success response
-	response := BaseResponse{}
-	response.Response.StatusCode = 200
-	response.Response.StatusText = "OK"
-	SendResponse(w, r, response, h.Logger)
+	SendOK(w, r, nil, h.Logger)
 }
 
 // GetProfile handles GET /presence/getProfile requests to retrieve user's profile.
-func (h *PresenceHandler) GetProfile(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+func (h *PresenceHandler) GetProfile(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
 	// Get target screen name (optional - defaults to self)
@@ -525,11 +487,7 @@ func (h *PresenceHandler) GetProfile(w http.ResponseWriter, r *http.Request, ses
 	// Send response
 	responseData := &ProfileData{ScreenName: targetSN, Profile: profileText}
 
-	response := BaseResponse{}
-	response.Response.StatusCode = 200
-	response.Response.StatusText = "OK"
-	response.Response.Data = responseData
-	SendResponse(w, r, response, h.Logger)
+	SendOK(w, r, responseData, h.Logger)
 }
 
 // Icon handles GET /presence/icon requests for presence icons.
@@ -540,7 +498,7 @@ func (h *PresenceHandler) Icon(w http.ResponseWriter, r *http.Request) {
 	iconType := r.URL.Query().Get("type")
 
 	if name == "" {
-		h.sendError(w, r, http.StatusBadRequest, "missing name parameter")
+		SendError(w, r, http.StatusBadRequest, "missing name parameter")
 		return
 	}
 
@@ -611,7 +569,7 @@ func currentWebState(instance *state.SessionInstance) string {
 // re-renders its self-presence badge. The client binds its identity-badge render
 // to "myInfo" events only, so state changes made via setState/setStatus are
 // invisible in the user's own UI unless a myInfo event is delivered.
-func (h *PresenceHandler) pushMyInfo(session *state.WebAPISession, webState, awayMsg, statusMsg string) {
+func (h *PresenceHandler) pushMyInfo(session *Session, webState, awayMsg, statusMsg string) {
 	if !session.IsSubscribedTo("myInfo") && !session.IsSubscribedTo("presence") {
 		return
 	}
@@ -623,5 +581,5 @@ func (h *PresenceHandler) pushMyInfo(session *state.WebAPISession, webState, awa
 	myInfo.AwayMsg = awayMsg
 	myInfo.StatusMsg = statusMsg
 
-	session.EventQueue.Push(types.EventType("myInfo"), myInfo)
+	session.EventQueue.Push(EventType("myInfo"), myInfo)
 }

+ 44 - 125
server/webapi/handlers/presence_test.go → server/webapi/presence_handler_test.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"context"
@@ -17,76 +17,6 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-// 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
-type MockBuddyBroadcaster struct {
-	mock.Mock
-}
-
-func (m *MockBuddyBroadcaster) BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error {
-	args := m.Called(ctx, screenName, userInfo)
-	return args.Error(0)
-}
-
-func (m *MockBuddyBroadcaster) BroadcastBuddyDeparted(ctx context.Context, screenName state.IdentScreenName) error {
-	args := m.Called(ctx, screenName)
-	return args.Error(0)
-}
-
 // onlineUserInfoReply builds a locate UserInfoReply for an online user,
 // optionally marking them idle by the given number of minutes (0 = not idle).
 func onlineUserInfoReply(screenName string, idleMinutes uint16) wire.SNACMessage {
@@ -110,16 +40,16 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 	tests := []struct {
 		name               string
 		queryParams        string
-		setupMocks         func(*MockFeedbagService, *MockLocateService)
+		setupMocks         func(*mockFeedbagService, *mockLocateService)
 		expectedStatusCode int
 		checkResponse      func(*testing.T, string)
 	}{
 		{
 			name:        "Success_BuddyList",
 			queryParams: "bl=1",
-			setupMocks: func(fr *MockFeedbagService, ls *MockLocateService) {
+			setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
 				// Return feedbag with a group and buddy
-				fr.On("Query", mock.Anything, mock.Anything, mock.Anything).
+				fr.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
 					Return(wire.SNACMessage{
 						Body: wire.SNAC_0x13_0x06_FeedbagReply{
 							Items: []wire.FeedbagItem{
@@ -128,7 +58,7 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 							},
 						},
 					}, nil)
-				ls.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("buddy1")).
+				ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("buddy1")).
 					Return(onlineUserInfoReply("buddy1", 0), nil)
 			},
 			expectedStatusCode: http.StatusOK,
@@ -143,11 +73,11 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 		{
 			name:        "Success_TargetUsers",
 			queryParams: "t=user1,user2",
-			setupMocks: func(fr *MockFeedbagService, ls *MockLocateService) {
-				ls.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("user1")).
+			setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
+				ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("user1")).
 					Return(onlineUserInfoReply("user1", 0), nil)
 				// user2 is idle for 7 minutes.
-				ls.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("user2")).
+				ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("user2")).
 					Return(onlineUserInfoReply("user2", 7), nil)
 			},
 			expectedStatusCode: http.StatusOK,
@@ -162,9 +92,9 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 		{
 			name:        "Success_BlockedOrOfflineUser",
 			queryParams: "t=blockeduser",
-			setupMocks: func(fr *MockFeedbagService, ls *MockLocateService) {
+			setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
 				// A blocked or offline user comes back as a locate error.
-				ls.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("blockeduser")).
+				ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("blockeduser")).
 					Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil)
 			},
 			expectedStatusCode: http.StatusOK,
@@ -177,7 +107,7 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 		{
 			name:               "Success_EmptyRequest",
 			queryParams:        "",
-			setupMocks:         func(fr *MockFeedbagService, ls *MockLocateService) {},
+			setupMocks:         func(fr *mockFeedbagService, ls *mockLocateService) {},
 			expectedStatusCode: http.StatusOK,
 			checkResponse: func(t *testing.T, body string) {
 				assert.Contains(t, body, `"statusCode":200`)
@@ -187,7 +117,7 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 			name:        "Error_TooManyTargets",
 			queryParams: "t=u1,u2,u3,u4,u5,u6,u7,u8,u9,u10,u11",
 			// No UserInfoQuery should be issued; the request is rejected up front.
-			setupMocks:         func(fr *MockFeedbagService, ls *MockLocateService) {},
+			setupMocks:         func(fr *mockFeedbagService, ls *mockLocateService) {},
 			expectedStatusCode: http.StatusBadRequest,
 			checkResponse: func(t *testing.T, body string) {
 				assert.Contains(t, body, "too many screen names requested")
@@ -197,8 +127,8 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			feedbagService := &MockFeedbagService{}
-			locateService := &MockLocateService{}
+			feedbagService := newMockFeedbagService(t)
+			locateService := newMockLocateService(t)
 
 			oscarInstance := state.NewSession().AddInstance()
 			sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
@@ -214,7 +144,7 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 
 			// Presence payloads carry the viewer's alias, so GetPresence reads the
 			// feedbag. Registered last so a case's own Query stub takes precedence.
-			feedbagService.On("Query", mock.Anything, mock.Anything, mock.Anything).
+			feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
 				Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
 
 			reqURL := "/presence/get?aimsid=" + aimsid
@@ -234,9 +164,6 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 				responseBody := strings.TrimSpace(rr.Body.String())
 				tt.checkResponse(t, responseBody)
 			}
-
-			feedbagService.AssertExpectations(t)
-			locateService.AssertExpectations(t)
 		})
 	}
 }
@@ -248,18 +175,18 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 func TestPresenceHandler_GetPresence_PublishesIconForOnlineBuddiesOnly(t *testing.T) {
 	ctx := context.Background()
 
-	feedbagService := &MockFeedbagService{}
-	feedbagService.On("Query", mock.Anything, mock.Anything, mock.Anything).
+	feedbagService := newMockFeedbagService(t)
+	feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
 		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
 
-	locateService := &MockLocateService{}
-	locateService.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("onlineuser")).
+	locateService := newMockLocateService(t)
+	locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("onlineuser")).
 		Return(onlineUserInfoReply("onlineuser", 0), nil)
-	locateService.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("offlineuser")).
+	locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("offlineuser")).
 		Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil)
 
-	iconRetriever := &MockBuddyIconRetriever{}
-	iconRetriever.On("BuddyIconMetadata", mock.Anything, state.NewIdentScreenName("onlineuser")).
+	iconRetriever := newMockBuddyIconRetriever(t)
+	iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("onlineuser")).
 		Return(bartID([]byte{0xab, 0xcd}), nil).Once()
 
 	oscarInstance := state.NewSession().AddInstance()
@@ -310,7 +237,6 @@ func TestPresenceHandler_GetPresence_PublishesIconForOnlineBuddiesOnly(t *testin
 	assert.Equal(t, "offline", states["offlineuser"])
 	assert.Empty(t, icons["offlineuser"])
 	iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, state.NewIdentScreenName("offlineuser"))
-	iconRetriever.AssertExpectations(t)
 }
 
 // TestPresenceHandler_GetPresence_BuddyListGrouping verifies that bl=1 places
@@ -318,8 +244,8 @@ func TestPresenceHandler_GetPresence_PublishesIconForOnlineBuddiesOnly(t *testin
 // carry ItemID 0 and a distinct nonzero GroupID, and buddy rows reference those
 // GroupIDs. This is the shape the OSCAR feedbag actually stores.
 func TestPresenceHandler_GetPresence_BuddyListGrouping(t *testing.T) {
-	feedbagService := &MockFeedbagService{}
-	locateService := &MockLocateService{}
+	feedbagService := newMockFeedbagService(t)
+	locateService := newMockLocateService(t)
 
 	items := []wire.FeedbagItem{
 		// Root order group: ItemID 0, GroupID 0, empty name — not a real buddy group.
@@ -331,11 +257,11 @@ func TestPresenceHandler_GetPresence_BuddyListGrouping(t *testing.T) {
 		{ItemID: 101, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "alice"},
 		{ItemID: 201, GroupID: 20, ClassID: wire.FeedbagClassIdBuddy, Name: "bob"},
 	}
-	feedbagService.On("Query", mock.Anything, mock.Anything, mock.Anything).
+	feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
 		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
-	locateService.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("alice")).
+	locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("alice")).
 		Return(onlineUserInfoReply("alice", 0), nil)
-	locateService.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("bob")).
+	locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("bob")).
 		Return(onlineUserInfoReply("bob", 0), nil)
 
 	oscarInstance := state.NewSession().AddInstance()
@@ -380,14 +306,11 @@ func TestPresenceHandler_GetPresence_BuddyListGrouping(t *testing.T) {
 	assert.Len(t, parsed.Response.Data.Groups, 2)
 	assert.Equal(t, []string{"alice"}, byGroup["Friends"])
 	assert.Equal(t, []string{"bob"}, byGroup["Work"])
-
-	feedbagService.AssertExpectations(t)
-	locateService.AssertExpectations(t)
 }
 
 func TestPresenceHandler_GetPresence_MissingAimsid(t *testing.T) {
 	handler := &PresenceHandler{
-		SessionManager: state.NewWebAPISessionManager(),
+		SessionManager: NewSessionManager(),
 		Logger:         slog.Default(),
 	}
 
@@ -403,7 +326,7 @@ func TestPresenceHandler_GetPresence_MissingAimsid(t *testing.T) {
 
 func TestPresenceHandler_GetPresence_SessionNotFound(t *testing.T) {
 	handler := &PresenceHandler{
-		SessionManager: state.NewWebAPISessionManager(),
+		SessionManager: NewSessionManager(),
 		Logger:         slog.Default(),
 	}
 
@@ -419,7 +342,7 @@ func TestPresenceHandler_GetPresence_SessionNotFound(t *testing.T) {
 
 func TestPresenceHandler_SetState_MissingAimsid(t *testing.T) {
 	handler := &PresenceHandler{
-		SessionManager: state.NewWebAPISessionManager(),
+		SessionManager: NewSessionManager(),
 		Logger:         slog.Default(),
 	}
 
@@ -459,8 +382,8 @@ func TestPresenceHandler_SetState_EmitsMyInfoEvent(t *testing.T) {
 	oscarInstance := state.NewSession().AddInstance()
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
-	broadcaster := &MockBuddyBroadcaster{}
-	broadcaster.On("BroadcastBuddyArrived", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+	broadcaster := newMockBuddyBroadcaster(t)
+	broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
 
 	handler := &PresenceHandler{
 		SessionManager:   sessionMgr,
@@ -492,8 +415,8 @@ func TestPresenceHandler_SetState_MyInfoNormalizesAimID(t *testing.T) {
 	oscarInstance := state.NewSession().AddInstance()
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("Mike Kelly", oscarInstance)
 
-	broadcaster := &MockBuddyBroadcaster{}
-	broadcaster.On("BroadcastBuddyArrived", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+	broadcaster := newMockBuddyBroadcaster(t)
+	broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
 
 	handler := &PresenceHandler{
 		SessionManager:   sessionMgr,
@@ -577,8 +500,8 @@ func TestPresenceHandler_Icon(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			handler := &PresenceHandler{
-				SessionManager: state.NewWebAPISessionManager(),
-				LocateService:  &MockLocateService{},
+				SessionManager: NewSessionManager(),
+				LocateService:  newMockLocateService(t),
 				Logger:         slog.Default(),
 			}
 
@@ -608,15 +531,15 @@ func TestPresenceHandler_SetProfile(t *testing.T) {
 	tests := []struct {
 		name               string
 		queryParams        string
-		setupMocks         func(*MockLocateService)
+		setupMocks         func(*mockLocateService)
 		expectedStatusCode int
 		checkResponse      func(*testing.T, string)
 	}{
 		{
 			name:        "Success_SetProfile",
 			queryParams: "profile=Hello+World",
-			setupMocks: func(ls *MockLocateService) {
-				ls.On("SetInfo", mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNAC_0x02_0x04_LocateSetInfo")).Return(nil)
+			setupMocks: func(ls *mockLocateService) {
+				ls.EXPECT().SetInfo(mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNAC_0x02_0x04_LocateSetInfo")).Return(nil)
 			},
 			expectedStatusCode: http.StatusOK,
 			checkResponse: func(t *testing.T, body string) {
@@ -626,7 +549,7 @@ func TestPresenceHandler_SetProfile(t *testing.T) {
 		{
 			name:               "Error_ProfileTooLarge",
 			queryParams:        "profile=" + strings.Repeat("x", 4097),
-			setupMocks:         func(ls *MockLocateService) {},
+			setupMocks:         func(ls *mockLocateService) {},
 			expectedStatusCode: http.StatusBadRequest,
 			checkResponse: func(t *testing.T, body string) {
 				assert.Contains(t, body, "profile too large")
@@ -636,7 +559,7 @@ func TestPresenceHandler_SetProfile(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			locateService := &MockLocateService{}
+			locateService := newMockLocateService(t)
 
 			sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
@@ -662,14 +585,12 @@ func TestPresenceHandler_SetProfile(t *testing.T) {
 				responseBody := strings.TrimSpace(rr.Body.String())
 				tt.checkResponse(t, responseBody)
 			}
-
-			locateService.AssertExpectations(t)
 		})
 	}
 }
 
 func TestPresenceHandler_GetProfile(t *testing.T) {
-	locateService := &MockLocateService{}
+	locateService := newMockLocateService(t)
 
 	oscarInstance := state.NewSession().AddInstance()
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
@@ -680,7 +601,7 @@ func TestPresenceHandler_GetProfile(t *testing.T) {
 		Logger:         slog.Default(),
 	}
 
-	locateService.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("testuser")).
+	locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("testuser")).
 		Return(wire.SNACMessage{
 			Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
 				LocateInfo: wire.TLVRestBlock{
@@ -702,12 +623,10 @@ func TestPresenceHandler_GetProfile(t *testing.T) {
 	assert.Contains(t, body, `"statusCode":200`)
 	assert.Contains(t, body, `"My profile"`)
 	assert.Contains(t, body, `"testuser"`)
-
-	locateService.AssertExpectations(t)
 }
 
 // queuedMyInfo returns the myInfo event the session has queued, if any.
-func queuedMyInfo(session *state.WebAPISession) *MyInfo {
+func queuedMyInfo(session *Session) *MyInfo {
 	var myInfo *MyInfo
 	for _, event := range session.EventQueue.GetAllEvents() {
 		if event.Type == "myInfo" {

+ 75 - 9
server/webapi/handlers/common.go → server/webapi/response.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"context"
@@ -12,6 +12,38 @@ import (
 	"strings"
 )
 
+// Web API status codes. These are the client's own vocabulary, not HTTP codes,
+// and it reads them from the envelope rather than from the HTTP status — which
+// is why several of them ship on an HTTP 200 (see SendEnvelopeStatus).
+const (
+	// statusMoreAuthRequired is what makes a failed sign-in a demand for better
+	// credentials rather than an error: paired with the detail code naming what
+	// was wrong, it is what tells a client to say "incorrect password".
+	statusMoreAuthRequired = 330
+	// statusRateLimited is swallowed by the client on the IM path, so that the
+	// rateLimit event owns the user-facing message instead of a generic send
+	// failure alert.
+	statusRateLimited      = 430
+	statusMissingParameter = 460
+	// statusParameterError is for a parameter that is present but unusable.
+	statusParameterError = 462
+	// statusNoSuchService is what the client accepts as "this account has no such
+	// linked service". Its getAttributes callback treats 601 as an expected
+	// outcome and returns early; any other status sends it into the success
+	// branch, where it dereferences response.data.serviceName and marks the
+	// service associated. A 404 therefore both crashes the callback and, if it
+	// did not, would advertise a linked account that does not exist.
+	statusNoSuchService = 601
+	// statusSendFailed is one of the two codes (602/603) the client recognizes as
+	// "recipient offline or blocked". Any other code, and an empty body most of
+	// all, falls through to its generic "Bummer. Your message failed." alert.
+	statusSendFailed = 602
+
+	// detailBadPassword is the statusDetailCode under statusMoreAuthRequired that
+	// names a wrong password specifically.
+	detailBadPassword = 3011
+)
+
 // BaseResponse is the standard response envelope for all Web API responses.
 // It supports both JSON and XML marshaling.
 type BaseResponse struct {
@@ -45,6 +77,10 @@ type ErrorResponse struct {
 		// otherwise read as a detail code of its own.
 		StatusDetailCode int    `json:"statusDetailCode,omitempty" xml:"statusDetailCode,omitempty"`
 		StatusText       string `json:"statusText" xml:"statusText"`
+		// RequestID echoes the client's correlation id, the same way BaseResponse
+		// carries it. A client that indexes replies by it cannot match a failure
+		// that omits it, so an error needs it as much as a success does.
+		RequestID string `json:"requestId,omitempty" xml:"requestId,omitempty"`
 		// Data carries an empty object for the same reason the JSONP error path
 		// sends one: a client callback that reaches response.data on a failure
 		// throws a TypeError when it is absent.
@@ -166,8 +202,42 @@ func SendErrorDetail(w http.ResponseWriter, r *http.Request, httpStatus, statusC
 	sendErrorEnvelope(w, r, httpStatus, newErrorResponseDetail(statusCode, detailCode, message))
 }
 
+// SendOK sends the success envelope every Web API method answers with, carrying
+// data as its payload.
+//
+// Pass nil for a bare acknowledgement: SendResponse substitutes the empty data
+// object the client dereferences unconditionally on success.
+func SendOK(w http.ResponseWriter, r *http.Request, data interface{}, logger *slog.Logger) {
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = data
+	SendResponse(w, r, resp, logger)
+}
+
+// SendEnvelopeStatus reports a non-success outcome that the client must read
+// from the envelope, leaving the HTTP status at 200.
+//
+// This is the counterpart to SendError, which puts the code on the HTTP response
+// too. Use it where a 4xx would keep the client from ever reading statusCode: the
+// AIM client's request layer routes any non-2xx to its error handlers, which
+// synthesize a generic failure and never look at the body. That makes the
+// difference between "recipient is offline" (602) or "no such linked service"
+// (601) and a generic "your message failed" alert.
+//
+// It carries no data element; SendResponse substitutes an empty one. A caller
+// that needs to send data alongside a non-200 status builds the envelope itself.
+func SendEnvelopeStatus(w http.ResponseWriter, r *http.Request, statusCode int, statusText string, logger *slog.Logger) {
+	resp := BaseResponse{}
+	resp.Response.StatusCode = statusCode
+	resp.Response.StatusText = statusText
+	SendResponse(w, r, resp, logger)
+}
+
 // sendErrorEnvelope writes an error envelope in the format the client asked for.
 func sendErrorEnvelope(w http.ResponseWriter, r *http.Request, httpStatus int, resp ErrorResponse) {
+	resp.Response.RequestID = requestIDFromRequest(r)
+
 	if callback := jsonpCallback(r); callback != "" && isValidCallback(callback) {
 		sendJSONPError(w, r, callback, resp)
 		return
@@ -221,7 +291,7 @@ func sendJSONPError(w http.ResponseWriter, r *http.Request, callback string, res
 		return
 	}
 
-	w.Header().Set("Content-Type", "application/javascript")
+	w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
 	_, _ = w.Write([]byte(callback))
 	_, _ = w.Write([]byte("("))
 	_, _ = w.Write(body)
@@ -325,7 +395,7 @@ func sendJSONP(w http.ResponseWriter, r *http.Request, callback string, data int
 		return
 	}
 
-	w.Header().Set("Content-Type", "application/javascript")
+	w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
 	_, _ = w.Write([]byte(callback))
 	_, _ = w.Write([]byte("("))
 	_, _ = w.Write(jsonData)
@@ -354,14 +424,12 @@ func isValidCallback(callback string) bool {
 // sendAMF sends an AMF response
 func sendAMF(w http.ResponseWriter, r *http.Request, data interface{}, logger *slog.Logger) {
 	encoder := NewAMFEncoder(logger)
-	version := DetectAMFVersion(r)
 
-	amfData, err := encoder.EncodeAMF(data, version)
+	amfData, err := encoder.EncodeAMF(data)
 	if err != nil {
 		if logger != nil {
 			logger.Error("failed to encode AMF response",
 				"err", err.Error(),
-				"version", version,
 				"dataType", fmt.Sprintf("%T", data))
 		}
 		// Fall back to JSON error
@@ -384,7 +452,6 @@ func sendAMF(w http.ResponseWriter, r *http.Request, data interface{}, logger *s
 		}
 
 		logger.Debug("sending AMF response",
-			"version", version,
 			"size", len(amfData),
 			"path", r.URL.Path,
 			"hexPreview", hexPreview)
@@ -401,9 +468,8 @@ func sendAMF(w http.ResponseWriter, r *http.Request, data interface{}, logger *s
 // sendAMFError sends an AMF error response
 func sendAMFError(w http.ResponseWriter, r *http.Request, httpStatus int, resp ErrorResponse, logger *slog.Logger) {
 	encoder := NewAMFEncoder(logger)
-	version := DetectAMFVersion(r)
 
-	amfData, err := encoder.EncodeAMF(resp, version)
+	amfData, err := encoder.EncodeAMF(resp)
 	if err != nil {
 		// If AMF encoding fails, fall back to JSON error
 		sendJSONError(w, httpStatus, resp)

+ 1 - 1
server/webapi/handlers/common_test.go → server/webapi/response_test.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"encoding/xml"

+ 83 - 61
server/webapi/server.go

@@ -2,6 +2,7 @@ package webapi
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"log/slog"
@@ -10,41 +11,36 @@ import (
 
 	"golang.org/x/sync/errgroup"
 
-	"github.com/mk6i/open-oscar-server/server/webapi/handlers"
-	"github.com/mk6i/open-oscar-server/server/webapi/middleware"
+	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyValidator middleware.APIKeyValidator, sessionManager *state.WebAPISessionManager) *Server {
+func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyValidator APIKeyValidator, sessionManager *SessionManager) *Server {
 	servers := make([]*http.Server, 0, len(listeners))
 
-	authMiddleware := middleware.NewAuthMiddleware(apiKeyValidator, logger)
-	rateLimiter := handlers.NewRateLimitMiddleware(handler.SNACRateLimits, logger)
+	authMiddleware := NewAuthMiddleware(apiKeyValidator, logger)
+	rateLimiter := NewRateLimitMiddleware(handler.SNACRateLimits, logger)
 
-	authHandler := &handlers.AuthHandler{
+	authHandler := &AuthHandler{
 		AuthService: handler.AuthService,
 		Logger:      logger,
 	}
 
-	sessionHandler := &handlers.SessionHandler{
+	aimHandler := &AimHandler{
 		SessionManager:   sessionManager,
-		OSCARAuthService: handler.AuthService,
+		AuthService:      handler.AuthService,
 		FeedbagService:   handler.FeedbagService,
 		ICBMService:      handler.ICBMService,
-		BuddyListManager: handler.BuddyListManager.(*handlers.BuddyListManager),
-		IconSource:       handler.IconSource,
-		Logger:           logger,
 		OServiceService:  handler.OServiceService,
+		BuddyListManager: handler.BuddyListManager,
+		IconSource:       handler.IconSource,
+		BOSListener:      handler.BOSListener,
 		SNACRateLimits:   handler.SNACRateLimits,
+		Logger:           logger,
 	}
 
-	eventsHandler := &handlers.EventsHandler{
-		SessionManager: sessionManager,
-		Logger:         logger,
-	}
-
-	presenceHandler := &handlers.PresenceHandler{
+	presenceHandler := &PresenceHandler{
 		SessionManager:   sessionManager,
 		FeedbagService:   handler.FeedbagService,
 		BuddyBroadcaster: handler.BuddyBroadcaster,
@@ -53,38 +49,31 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		Logger:           logger,
 	}
 
-	buddyListHandler := &handlers.BuddyListHandler{
-		BuddyListManager: handler.BuddyListManager.(*handlers.BuddyListManager),
+	buddyListHandler := &BuddyListHandler{
+		BuddyListManager: handler.BuddyListManager,
 		Logger:           logger,
 		FeedbagService:   handler.FeedbagService,
 	}
 
-	messagingHandler := &handlers.MessagingHandler{
-		SessionManager: sessionManager,
+	messagingHandler := &MessagingHandler{
 		ICBMService:    handler.ICBMService,
 		LocateService:  handler.LocateService,
 		FeedbagService: handler.FeedbagService,
 		Logger:         logger,
 	}
 
-	preferenceHandler := &handlers.PreferenceHandler{
+	preferenceHandler := &PreferenceHandler{
 		SessionManager: sessionManager,
 		FeedbagService: handler.FeedbagService,
 		Logger:         logger,
 	}
 
-	memberDirHandler := &handlers.MemberDirHandler{
+	memberDirHandler := &MemberDirHandler{
 		DirSearchService: handler.DirSearchService,
 		LocateService:    handler.LocateService,
 		Logger:           logger,
 	}
 
-	oscarBridgeHandler := &handlers.OSCARBridgeHandler{
-		OSCARAuthService: handler.AuthService,
-		Listener:         handler.BOSListener,
-		Logger:           logger,
-	}
-
 	shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
 
 	for _, l := range listeners {
@@ -100,13 +89,13 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		// oscarRoute charges the request against the rate class for (foodGroup,
 		// subGroup) before the handler runs; sessionRoute and stubRoute reach no
 		// food group and so are not rate limited here.
-		oscarRoute := func(foodGroup uint16, subGroup uint16, h handlers.SessionHandlerFunc) http.Handler {
+		oscarRoute := func(foodGroup uint16, subGroup uint16, h SessionHandlerFunc) http.Handler {
 			return authMiddleware.CORSMiddleware(
 				authMiddleware.AuthenticateFlexible(
 					authMiddleware.RequireSession(sessionManager,
 						rateLimiter.OSCAR(foodGroup, subGroup)(h))))
 		}
-		sessionRoute := func(h handlers.SessionHandlerFunc) http.Handler {
+		sessionRoute := func(h SessionHandlerFunc) http.Handler {
 			return authMiddleware.CORSMiddleware(
 				authMiddleware.AuthenticateFlexible(
 					authMiddleware.RequireSession(sessionManager, h)))
@@ -123,7 +112,7 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		// Unauthenticated and outside every middleware: Flash Player fetches the
 		// policy before it has a session, and refuses to look at a redirect or an
 		// error envelope.
-		mux.Handle("GET /crossdomain.xml", &handlers.CrossDomainPolicyHandler{Logger: logger})
+		mux.Handle("GET /crossdomain.xml", &CrossDomainPolicyHandler{Logger: logger})
 
 		// Authentication endpoint (public - no API key required for user login)
 		// Using pattern with explicit method for Go 1.22+ routing.
@@ -174,29 +163,34 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		mux.Handle("GET /aim/startSession",
 			authMiddleware.CORSMiddleware(
 				authMiddleware.AuthenticateFlexible(
-					http.HandlerFunc(sessionHandler.StartSession))))
+					http.HandlerFunc(aimHandler.StartSession))))
 
 		// End session - uses aimsid for auth, no k required
-		mux.Handle("GET /aim/endSession", sessionRoute(sessionHandler.EndSession))
+		mux.Handle("GET /aim/endSession", sessionRoute(aimHandler.EndSession))
 
 		// Event fetching - uses aimsid for auth, no k required. This is the
 		// long-poll loop the client runs continuously.
-		mux.Handle("GET /aim/fetchEvents", sessionRoute(eventsHandler.FetchEvents))
+		mux.Handle("GET /aim/fetchEvents", sessionRoute(aimHandler.FetchEvents))
 
 		// Temp buddies are session-local rather than feedbag-backed, but they
 		// are the Web API's equivalent of the BUDDY temp buddy SNACs and are
 		// charged as such.
-		mux.Handle("GET /aim/addTempBuddy", oscarRoute(wire.Buddy, wire.BuddyAddTempBuddies, buddyListHandler.AddTempBuddy))
-		mux.Handle("GET /aim/removeTempBuddy", oscarRoute(wire.Buddy, wire.BuddyDelTempBuddies, buddyListHandler.RemoveTempBuddy))
+		mux.Handle("GET /aim/addTempBuddy", oscarRoute(wire.Buddy, wire.BuddyAddTempBuddies, aimHandler.AddTempBuddy))
+		mux.Handle("GET /aim/removeTempBuddy", oscarRoute(wire.Buddy, wire.BuddyDelTempBuddies, aimHandler.RemoveTempBuddy))
 
-		aimStub := &handlers.AimStubHandler{Logger: logger}
-		mux.Handle("GET /aim/setForwardDomain", stubRoute(aimStub.SetForwardDomain))
-		mux.Handle("GET /aim/getData", stubRoute(aimStub.GetData))
-		mux.Handle("GET /aim/reportAction", stubRoute(aimStub.ReportAction))
+		mux.Handle("GET /aim/setForwardDomain", stubRoute(aimHandler.SetForwardDomain))
+		mux.Handle("GET /aim/getData", stubRoute(aimHandler.GetData))
+		mux.Handle("GET /aim/reportAction", stubRoute(aimHandler.ReportAction))
 
-		conversationStub := &handlers.ConversationStubHandler{
-			SessionManager: sessionManager,
-			Logger:         logger,
+		// OSCAR Bridge endpoint. Hands off to a BOS session rather than reaching
+		// a food group, so there is no OSCAR budget to charge.
+		mux.Handle("GET /aim/startOSCARSession",
+			authMiddleware.CORSMiddleware(
+				authMiddleware.Authenticate(
+					http.HandlerFunc(aimHandler.StartOSCARSession))))
+
+		conversationStub := &ConversationStubHandler{
+			Logger: logger,
 		}
 		mux.Handle("GET /conversation/update", stubRoute(conversationStub.Update))
 		mux.Handle("GET /conversation/close", stubRoute(conversationStub.Close))
@@ -248,13 +242,6 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		mux.Handle("GET /preference/setPermitDeny", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, preferenceHandler.SetPermitDeny))
 		mux.Handle("GET /preference/getPermitDeny", oscarRoute(wire.Feedbag, wire.FeedbagQuery, preferenceHandler.GetPermitDeny))
 
-		// OSCAR Bridge endpoint. Hands off to a BOS session rather than reaching
-		// a food group, so there is no OSCAR budget to charge.
-		mux.Handle("GET /aim/startOSCARSession",
-			authMiddleware.CORSMiddleware(
-				authMiddleware.Authenticate(
-					http.HandlerFunc(oscarBridgeHandler.StartOSCARSession))))
-
 		// Expressions endpoint (for buddy icons, etc.).
 		//
 		// Unauthenticated, like /presence/icon: the buddyIcon URLs this serves are
@@ -262,8 +249,8 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		// neither an aimsid nor an API key. Threading a session token through them
 		// instead would leak it into the DOM and defeat caching, since these URLs
 		// outlive the session that produced them. Buddy icons are public assets.
-		expressionsHandler := handlers.NewExpressionsHandler(
-			handler.IconSource, handler.BARTUploader, handler.FeedbagService, logger)
+		expressionsHandler := NewExpressionsHandler(
+			handler.IconSource, handler.BARTService, handler.FeedbagService, logger)
 		mux.Handle("GET /expressions/get",
 			authMiddleware.CORSMiddleware(
 				http.HandlerFunc(expressionsHandler.Get)))
@@ -271,7 +258,7 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 			oscarRoute(wire.BART, wire.BARTUploadQuery, expressionsHandler.Upload))
 
 		// Web AIM calls lifestream/* on the API host (e.g. /lifestream/getUserDetails).
-		lifestreamStub := &handlers.UserInfoStubHandler{Logger: logger}
+		lifestreamStub := &UserInfoStubHandler{Logger: logger}
 		// getUserDetails returns a minimal AIM identity and getServices the service
 		// list behind it. Every other lifestream/* method is an unimplemented
 		// social-feed feature; the subtree catch-all acknowledges them with an
@@ -284,7 +271,7 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		// The client probes for a linked Google Talk account as soon as the
 		// session comes up, and its callback dereferences response.data unless
 		// the status says the service is absent.
-		serviceStub := &handlers.ServiceStubHandler{Logger: logger}
+		serviceStub := &ServiceStubHandler{Logger: logger}
 		mux.Handle("GET /service/getAttributes", stubRoute(serviceStub.GetAttributes))
 
 		// Go 1.22 patterns are method-exact, so an OPTIONS preflight matches none of
@@ -303,16 +290,16 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		mux.Handle("/", authMiddleware.CORSMiddleware(
 			http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				logger.Debug("webapi 404", "method", r.Method, "path", r.URL.Path)
-				handlers.SendError(w, r, http.StatusNotFound, "not found")
+				SendError(w, r, http.StatusNotFound, "not found")
 			})))
 
 		servers = append(servers, &http.Server{
 			Addr:    l,
-			Handler: middleware.RequestLogger(logger, mux),
+			Handler: RequestLogger(logger, mux),
 		})
 	}
 
-	sessionHandler.FnSessCfg = func(sess *state.Session) {
+	aimHandler.FnSessCfg = func(sess *state.Session) {
 		sess.OnSessionClose(func() {
 			ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
 			defer cancel()
@@ -334,7 +321,7 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		})
 	}
 
-	sessionHandler.FnSessInit = func(instance *state.SessionInstance) func() error {
+	aimHandler.FnSessInit = func(instance *state.SessionInstance) func() error {
 		return func() error {
 			// make buddy list visible to other users
 			if err := handler.BuddyListRegistry.RegisterBuddyList(shutdownCtx, instance.IdentScreenName()); err != nil {
@@ -352,7 +339,7 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		}
 	}
 
-	sessionHandler.FnInstanceClose = func(instance *state.SessionInstance) func() {
+	aimHandler.FnInstanceClose = func(instance *state.SessionInstance) func() {
 		return func() {
 			if shuttingDown(shutdownCtx) {
 				return
@@ -385,7 +372,7 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 type Server struct {
 	servers        []*http.Server
 	logger         *slog.Logger
-	sessionManager *state.WebAPISessionManager
+	sessionManager *SessionManager
 	shutdownCtx    context.Context
 	shutdownCancel context.CancelFunc
 }
@@ -449,3 +436,38 @@ func shuttingDown(ctx context.Context) bool {
 	}
 	return false
 }
+
+type Handler struct {
+	AuthService        AuthService
+	BuddyListRegistry  BuddyListRegistry
+	ICBMService        ICBMService
+	LocateService      LocateService
+	Logger             *slog.Logger
+	OServiceService    OServiceService
+	BuddyBroadcaster   BuddyBroadcaster
+	BOSListener        config.ListenerGroup
+	BuddyListManager   *BuddyListManager
+	RecalcWarning      func(ctx context.Context, instance *state.SessionInstance) error
+	LowerWarnLevel     func(ctx context.Context, instance *state.SessionInstance)
+	ChatSessionManager ChatSessionManager
+	FeedbagService     FeedbagService
+	DirSearchService   DirSearchService
+	IconSource         BuddyIconSource
+	BARTService        BARTService
+	SNACRateLimits     wire.SNACRateLimits
+}
+
+func (h Handler) GetHelloWorldHandler(w http.ResponseWriter, r *http.Request) {
+	_, _ = 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)
+}

+ 198 - 69
state/webapi_session.go → server/webapi/session.go

@@ -1,4 +1,4 @@
-package state
+package webapi
 
 import (
 	"bytes"
@@ -8,11 +8,13 @@ import (
 	"errors"
 	"log/slog"
 	mrand "math/rand/v2"
+	"sort"
 	"strconv"
+	"strings"
 	"sync"
 	"time"
 
-	"github.com/mk6i/open-oscar-server/server/webapi/types"
+	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
@@ -53,14 +55,14 @@ const (
 	webAPISessionReapInterval = 30 * time.Second
 )
 
-// WebAPISession represents an active Web AIM API session.
-type WebAPISession struct {
+// Session represents an active Web AIM API session.
+type Session struct {
 	AimSID              string                                         // Unique session ID for web client
-	ScreenName          DisplayScreenName                              // User identity
-	OSCARSession        *SessionInstance                               // Bridge to existing OSCAR session
+	ScreenName          state.DisplayScreenName                        // User identity
+	OSCARSession        *state.SessionInstance                         // Bridge to existing OSCAR session
 	BaseURL             string                                         // Web API base URL advertised to the web client, used to build absolute asset URLs
 	Events              []string                                       // Subscribed event types
-	EventQueue          *types.EventQueue                              // Per-session event queue
+	EventQueue          *EventQueue                                    // Per-session event queue
 	DevID               string                                         // Developer ID that created this session
 	ClientName          string                                         // Client application name
 	ClientVersion       string                                         // Client application version
@@ -77,7 +79,7 @@ type WebAPISession struct {
 	BuddyAliasLoader    func(ctx context.Context) (map[string]string, error)
 	// BuddyIconURL formats the absolute buddyIcon URL for a buddy from the icon
 	// hash carried in a presence SNAC. Returns "" when no URL can be published.
-	BuddyIconURL func(screenName IdentScreenName, hash []byte) string
+	BuddyIconURL func(screenName state.IdentScreenName, hash []byte) string
 	aliases      map[string]string // cached BuddyAliasLoader result, nil when unloaded or invalidated
 	aliasMu      sync.Mutex
 	imLog        map[string][]WebAPIStoredIM
@@ -97,7 +99,7 @@ type WebAPISession struct {
 }
 
 // IsExpired checks if the session has expired.
-func (s *WebAPISession) IsExpired() bool {
+func (s *Session) IsExpired() bool {
 	return time.Now().After(s.ExpiresAt)
 }
 
@@ -114,7 +116,7 @@ func (s *WebAPISession) IsExpired() bool {
 // the lock, that query's pre-rename result could be stored *after* the
 // invalidation and serve the old alias until the next feedbag change. Holding the
 // lock makes the invalidation wait for the load and then win.
-func (s *WebAPISession) Aliases(ctx context.Context) map[string]string {
+func (s *Session) Aliases(ctx context.Context) map[string]string {
 	s.aliasMu.Lock()
 	defer s.aliasMu.Unlock()
 
@@ -138,7 +140,7 @@ func (s *WebAPISession) Aliases(ctx context.Context) map[string]string {
 // Callers that change the owner's feedbag must call this: the feedbag service
 // relays FeedbagUpdateItem only to the owner's *other* instances, so a session
 // never sees a SNAC for its own writes.
-func (s *WebAPISession) InvalidateAliases() {
+func (s *Session) InvalidateAliases() {
 	s.aliasMu.Lock()
 	defer s.aliasMu.Unlock()
 	s.aliases = nil
@@ -147,13 +149,13 @@ func (s *WebAPISession) InvalidateAliases() {
 // aliasFor returns this session owner's private alias for buddy, or "" when none is
 // set. The web client deletes the alias it holds whenever it merges a user map, so
 // every event naming a buddy has to repeat it.
-func (s *WebAPISession) aliasFor(buddy IdentScreenName) string {
+func (s *Session) aliasFor(buddy state.IdentScreenName) string {
 	// Runs on the SNAC listener goroutine, which has no request context.
 	return s.Aliases(s.ctx)[buddy.String()]
 }
 
 // Touch updates the last accessed time and extends expiration if needed.
-func (s *WebAPISession) Touch() {
+func (s *Session) Touch() {
 	s.LastAccessed = time.Now()
 	newExpiry := s.LastAccessed.Add(webAPISessionTTL)
 	if newExpiry.After(s.ExpiresAt) {
@@ -162,7 +164,7 @@ func (s *WebAPISession) Touch() {
 }
 
 // IsSubscribedTo checks if the session is subscribed to a specific event type.
-func (s *WebAPISession) IsSubscribedTo(eventType string) bool {
+func (s *Session) IsSubscribedTo(eventType string) bool {
 	for _, event := range s.Events {
 		if event == eventType {
 			return true
@@ -173,7 +175,7 @@ func (s *WebAPISession) IsSubscribedTo(eventType string) bool {
 
 // StartListeningToOSCARSession starts a goroutine that listens to the OSCAR session's
 // message channel and converts SNAC messages into WebAPI events.
-func (s *WebAPISession) StartListeningToOSCARSession() {
+func (s *Session) StartListeningToOSCARSession() {
 	s.closeMu.Lock()
 	defer s.closeMu.Unlock()
 	if s.closed {
@@ -202,7 +204,7 @@ func (s *WebAPISession) StartListeningToOSCARSession() {
 				// A teardown this session started needs no event, and gets
 				// none: Close closes the queue before it closes the instance,
 				// so this Push is a no-op on that path.
-				s.EventQueue.Push(types.EventTypeSessionEnded, struct{}{})
+				s.EventQueue.Push(EventTypeSessionEnded, struct{}{})
 				return
 			}
 		}
@@ -212,7 +214,7 @@ func (s *WebAPISession) StartListeningToOSCARSession() {
 // Close tears down the session: it releases any parked event fetchers, closes
 // the OSCAR instance, and waits for the listener goroutine to unwind. Safe to
 // call more than once.
-func (s *WebAPISession) Close() {
+func (s *Session) Close() {
 	s.closeMu.Lock()
 	if s.closed {
 		s.closeMu.Unlock()
@@ -229,7 +231,7 @@ func (s *WebAPISession) Close() {
 }
 
 // handleSNACMessage converts a SNAC message into WebAPI events and pushes them to the event queue.
-func (s *WebAPISession) handleSNACMessage(msg wire.SNACMessage) {
+func (s *Session) handleSNACMessage(msg wire.SNACMessage) {
 	// Convert SNAC message to WebAPI events based on food group and subgroup
 	switch msg.Frame.FoodGroup {
 	case wire.ICBM:
@@ -245,7 +247,7 @@ func (s *WebAPISession) handleSNACMessage(msg wire.SNACMessage) {
 
 // handleOServiceMessage handles OService SNAC messages relayed to the session's
 // own OSCAR instance.
-func (s *WebAPISession) handleOServiceMessage(msg wire.SNACMessage) {
+func (s *Session) handleOServiceMessage(msg wire.SNACMessage) {
 	switch msg.Frame.SubGroup {
 	case wire.OServiceUserInfoUpdate:
 		s.handleUserInfoUpdate(msg)
@@ -258,7 +260,7 @@ func (s *WebAPISession) handleOServiceMessage(msg wire.SNACMessage) {
 // a user when their own user info changes (notably a buddy icon upload or clear).
 // The client re-renders its identity badge from myInfo events only, so we
 // translate this into a fresh myInfo.
-func (s *WebAPISession) handleUserInfoUpdate(msg wire.SNACMessage) {
+func (s *Session) handleUserInfoUpdate(msg wire.SNACMessage) {
 	if !s.IsSubscribedTo("myInfo") && !s.IsSubscribedTo("presence") {
 		return
 	}
@@ -270,7 +272,7 @@ func (s *WebAPISession) handleUserInfoUpdate(msg wire.SNACMessage) {
 		s.logger.Error("failed to refresh myInfo after user-info update", "err", err)
 		return
 	}
-	s.EventQueue.Push(types.EventType("myInfo"), data)
+	s.EventQueue.Push(EventType("myInfo"), data)
 }
 
 // handleRateLimitUpdate translates a rate limit status change — broadcast by the
@@ -278,7 +280,7 @@ func (s *WebAPISession) handleUserInfoUpdate(msg wire.SNACMessage) {
 // surfaced, since the client feeds any rateLimit event into the
 // conversation-window alert. Code 1 is a class-params change, not a status
 // transition, and is ignored.
-func (s *WebAPISession) handleRateLimitUpdate(msg wire.SNACMessage) {
+func (s *Session) handleRateLimitUpdate(msg wire.SNACMessage) {
 	if s.IMRateClassID == 0 {
 		return
 	}
@@ -302,15 +304,15 @@ func (s *WebAPISession) handleRateLimitUpdate(msg wire.SNACMessage) {
 		return
 	}
 
-	s.EventQueue.Push(types.EventTypeRateLimit, types.RateLimitEvent{
-		Classes: []types.RateLimitClass{
+	s.EventQueue.Push(EventTypeRateLimit, RateLimitEvent{
+		Classes: []RateLimitClass{
 			{ID: int(body.Rate.ID), Status: status},
 		},
 	})
 }
 
 // handleICBMMessage handles ICBM (instant messaging) SNAC messages.
-func (s *WebAPISession) handleICBMMessage(msg wire.SNACMessage) {
+func (s *Session) handleICBMMessage(msg wire.SNACMessage) {
 	switch msg.Frame.SubGroup {
 	case wire.ICBMChannelMsgToClient:
 		s.handleIncomingIM(msg)
@@ -320,7 +322,7 @@ func (s *WebAPISession) handleICBMMessage(msg wire.SNACMessage) {
 }
 
 // handleIncomingIM handles incoming instant messages.
-func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
+func (s *Session) handleIncomingIM(msg wire.SNACMessage) {
 	body, ok := msg.Body.(wire.SNAC_0x04_0x07_ICBMChannelMsgToClient)
 	if !ok {
 		return
@@ -362,7 +364,7 @@ func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
 	// keys conversations and users by the normalized aimId and only renders
 	// displayId, so the two forms must not be interchanged.
 	partnerDisplay := body.ScreenName
-	partnerAimID := NewIdentScreenName(partnerDisplay).String()
+	partnerAimID := state.NewIdentScreenName(partnerDisplay).String()
 
 	// An offline message is logged under the time it was sent, so the stored-IM
 	// history it lands in stays in the order the conversation happened.
@@ -373,7 +375,7 @@ func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
 	s.AddStoredIM(partnerAimID, partnerAimID, messageText, msgID, timestamp)
 
 	if isOffline {
-		s.EventQueue.Push(types.EventTypeOfflineIM, types.OfflineIMEvent{
+		s.EventQueue.Push(EventTypeOfflineIM, OfflineIMEvent{
 			AimID:     partnerAimID,
 			Message:   messageText,
 			MsgID:     msgID,
@@ -385,11 +387,11 @@ func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
 			"to", s.ScreenName,
 			"sent", timestamp)
 	} else {
-		s.EventQueue.Push(types.EventTypeIM, types.IMEvent{
-			Source: types.UserInfo{
+		s.EventQueue.Push(EventTypeIM, IMEvent{
+			Source: UserInfo{
 				AimID:     partnerAimID,
 				DisplayID: partnerDisplay,
-				Friendly:  s.aliasFor(NewIdentScreenName(partnerAimID)),
+				Friendly:  s.aliasFor(state.NewIdentScreenName(partnerAimID)),
 				UserType:  "aim",
 				State:     "online",
 			},
@@ -410,8 +412,8 @@ func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
 		// this conversation's unreadCount, so sending 1 here would double-count
 		// the message (badge shows 2 for the first IM). Mirrors the sent-IM path,
 		// which also passes 0.
-		s.EventQueue.Push(types.EventTypeConversation, types.ConversationEventData("update", []types.ConversationEntryData{
-			types.ConversationEntry(
+		s.EventQueue.Push(EventTypeConversation, ConversationEventData("update", []ConversationEntryData{
+			ConversationEntry(
 				partnerAimID,
 				partnerDisplay,
 				messageText,
@@ -425,7 +427,7 @@ func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
 }
 
 // handleTypingNotification handles typing notifications.
-func (s *WebAPISession) handleTypingNotification(msg wire.SNACMessage) {
+func (s *Session) handleTypingNotification(msg wire.SNACMessage) {
 	if !s.IsSubscribedTo("typing") {
 		return
 	}
@@ -446,16 +448,16 @@ func (s *WebAPISession) handleTypingNotification(msg wire.SNACMessage) {
 		typingStatus = "none"
 	}
 
-	typingEvent := types.TypingEvent{
-		AimID:        NewIdentScreenName(body.ScreenName).String(),
+	typingEvent := TypingEvent{
+		AimID:        state.NewIdentScreenName(body.ScreenName).String(),
 		TypingStatus: typingStatus,
 	}
 
-	s.EventQueue.Push(types.EventTypeTyping, typingEvent)
+	s.EventQueue.Push(EventTypeTyping, typingEvent)
 }
 
 // handleBuddyMessage handles buddy/presence SNAC messages.
-func (s *WebAPISession) handleBuddyMessage(msg wire.SNACMessage) {
+func (s *Session) handleBuddyMessage(msg wire.SNACMessage) {
 	switch msg.Frame.SubGroup {
 	case wire.BuddyArrived:
 		s.handleBuddyArrived(msg)
@@ -465,7 +467,7 @@ func (s *WebAPISession) handleBuddyMessage(msg wire.SNACMessage) {
 }
 
 // handleBuddyArrived handles when a buddy comes online.
-func (s *WebAPISession) handleBuddyArrived(msg wire.SNACMessage) {
+func (s *Session) handleBuddyArrived(msg wire.SNACMessage) {
 	if !s.IsSubscribedTo("presence") {
 		return
 	}
@@ -491,8 +493,8 @@ func (s *WebAPISession) handleBuddyArrived(msg wire.SNACMessage) {
 		}
 	}
 
-	buddy := NewIdentScreenName(body.ScreenName)
-	presenceEvent := types.PresenceEvent{
+	buddy := state.NewIdentScreenName(body.ScreenName)
+	presenceEvent := PresenceEvent{
 		AimID:    buddy.String(),
 		Friendly: s.aliasFor(buddy),
 		State:    stateStr,
@@ -517,11 +519,11 @@ func (s *WebAPISession) handleBuddyArrived(msg wire.SNACMessage) {
 		presenceEvent.BuddyIcon = s.BuddyIconURL(buddy, hash)
 	}
 
-	s.EventQueue.Push(types.EventTypePresence, presenceEvent)
+	s.EventQueue.Push(EventTypePresence, presenceEvent)
 }
 
 // handleBuddyDeparted handles when a buddy goes offline.
-func (s *WebAPISession) handleBuddyDeparted(msg wire.SNACMessage) {
+func (s *Session) handleBuddyDeparted(msg wire.SNACMessage) {
 	if !s.IsSubscribedTo("presence") {
 		return
 	}
@@ -531,20 +533,20 @@ func (s *WebAPISession) handleBuddyDeparted(msg wire.SNACMessage) {
 		return
 	}
 
-	buddy := NewIdentScreenName(body.ScreenName)
+	buddy := state.NewIdentScreenName(body.ScreenName)
 	// BuddyIcon is deliberately omitted: an offline buddy keeps their icon, and
 	// omitting it lets the client's merge preserve the icon it already holds.
-	presenceEvent := types.PresenceEvent{
+	presenceEvent := PresenceEvent{
 		AimID:    buddy.String(),
 		Friendly: s.aliasFor(buddy),
 		State:    "offline",
 		UserType: "aim",
 	}
 
-	s.EventQueue.Push(types.EventTypePresence, presenceEvent)
+	s.EventQueue.Push(EventTypePresence, presenceEvent)
 }
 
-func (s *WebAPISession) handleFeedbagMessage(msg wire.SNACMessage) {
+func (s *Session) handleFeedbagMessage(msg wire.SNACMessage) {
 	switch msg.Frame.SubGroup {
 	case wire.FeedbagInsertItem, wire.FeedbagUpdateItem, wire.FeedbagDeleteItem:
 		// A buddy item carries its alias, so any feedbag write can change the map.
@@ -555,7 +557,7 @@ func (s *WebAPISession) handleFeedbagMessage(msg wire.SNACMessage) {
 			if err != nil {
 				s.logger.Error("failed to refresh buddy list after feedbag change", "err", err)
 			} else {
-				s.EventQueue.Push(types.EventTypeBuddyList, payload)
+				s.EventQueue.Push(EventTypeBuddyList, payload)
 			}
 		}
 		if s.PermitDenyRefresher != nil {
@@ -576,7 +578,7 @@ func (s *WebAPISession) handleFeedbagMessage(msg wire.SNACMessage) {
 					if err != nil {
 						s.logger.Error("failed to refresh permit/deny after feedbag change", "err", err)
 					} else {
-						s.EventQueue.Push(types.EventTypePermitDeny, pdd)
+						s.EventQueue.Push(EventTypePermitDeny, pdd)
 					}
 					break
 				}
@@ -585,21 +587,21 @@ func (s *WebAPISession) handleFeedbagMessage(msg wire.SNACMessage) {
 	}
 }
 
-// WebAPISessionManager manages Web API sessions with thread-safe operations.
-// Construct it with NewWebAPISessionManager and drive its reaper with Run.
-type WebAPISessionManager struct {
-	sessions map[string]*WebAPISession // Keyed by aimsid
+// SessionManager manages Web API sessions with thread-safe operations.
+// Construct it with NewSessionManager and drive its reaper with Run.
+type SessionManager struct {
+	sessions map[string]*Session // Keyed by aimsid
 	mu       sync.RWMutex
 	closed   bool           // set by Shutdown; rejects new sessions and makes drain idempotent
 	stopCh   chan struct{}  // closed by Shutdown to stop the reaper
 	reaperWG sync.WaitGroup // tracks a running reaper so Shutdown can join it
 }
 
-// NewWebAPISessionManager creates a new WebAPI session manager. It does not start
+// NewSessionManager creates a new WebAPI session manager. It does not start
 // any goroutines; call Run to start reaping expired sessions.
-func NewWebAPISessionManager() *WebAPISessionManager {
-	return &WebAPISessionManager{
-		sessions: make(map[string]*WebAPISession),
+func NewSessionManager() *SessionManager {
+	return &SessionManager{
+		sessions: make(map[string]*Session),
 		stopCh:   make(chan struct{}),
 	}
 }
@@ -611,7 +613,7 @@ func NewWebAPISessionManager() *WebAPISessionManager {
 // MyInfoRefresher, ...) and then call StartListeningToOSCARSession. Wiring them
 // after the listener starts would race the goroutine, which reads them as it
 // converts SNACs into events.
-func (m *WebAPISessionManager) CreateSession(screenName DisplayScreenName, devID string, events []string, oscarSession *SessionInstance, baseURL string, logger *slog.Logger) (*WebAPISession, error) {
+func (m *SessionManager) CreateSession(screenName state.DisplayScreenName, devID string, events []string, oscarSession *state.SessionInstance, baseURL string, logger *slog.Logger) (*Session, error) {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
@@ -629,7 +631,7 @@ func (m *WebAPISessionManager) CreateSession(screenName DisplayScreenName, devID
 
 	now := time.Now()
 	sessCtx, sessCancel := context.WithCancel(context.Background())
-	session := &WebAPISession{
+	session := &Session{
 		ctx:             sessCtx,
 		cancel:          sessCancel,
 		AimSID:          aimsid,
@@ -637,7 +639,7 @@ func (m *WebAPISessionManager) CreateSession(screenName DisplayScreenName, devID
 		OSCARSession:    oscarSession,
 		BaseURL:         baseURL,
 		Events:          events,
-		EventQueue:      types.NewEventQueue(1000), // Max 1000 events per session
+		EventQueue:      NewEventQueue(1000), // Max 1000 events per session
 		DevID:           devID,
 		CreatedAt:       now,
 		LastAccessed:    now,
@@ -657,7 +659,7 @@ func (m *WebAPISessionManager) CreateSession(screenName DisplayScreenName, devID
 }
 
 // GetSession retrieves a session by aimsid.
-func (m *WebAPISessionManager) GetSession(ctx context.Context, aimsid string) (*WebAPISession, error) {
+func (m *SessionManager) GetSession(ctx context.Context, aimsid string) (*Session, error) {
 	m.mu.RLock()
 	defer m.mu.RUnlock()
 
@@ -683,7 +685,7 @@ func (m *WebAPISessionManager) GetSession(ctx context.Context, aimsid string) (*
 }
 
 // RemoveSession removes a session by aimsid.
-func (m *WebAPISessionManager) RemoveSession(ctx context.Context, aimsid string) error {
+func (m *SessionManager) RemoveSession(ctx context.Context, aimsid string) error {
 	m.mu.Lock()
 
 	session, exists := m.sessions[aimsid]
@@ -702,7 +704,7 @@ func (m *WebAPISessionManager) RemoveSession(ctx context.Context, aimsid string)
 }
 
 // TouchSession updates the last accessed time for a session.
-func (m *WebAPISessionManager) TouchSession(ctx context.Context, aimsid string) error {
+func (m *SessionManager) TouchSession(ctx context.Context, aimsid string) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
@@ -723,7 +725,7 @@ func (m *WebAPISessionManager) TouchSession(ctx context.Context, aimsid string)
 //
 // Run is a no-op once the manager is closed, so a reaper that loses the race
 // with Shutdown never starts reaping a drained manager.
-func (m *WebAPISessionManager) Run(ctx context.Context) {
+func (m *SessionManager) Run(ctx context.Context) {
 	m.mu.Lock()
 	if m.closed {
 		m.mu.Unlock()
@@ -755,10 +757,10 @@ func (m *WebAPISessionManager) Run(ctx context.Context) {
 // closed out from under it (e.g. by a rate-limit disconnect) — the latter is
 // already rejected by GetSession, and reaping it here frees the entry promptly
 // rather than leaving it until time expiry.
-func (m *WebAPISessionManager) reapExpired() {
+func (m *SessionManager) reapExpired() {
 	m.mu.Lock()
 	now := time.Now()
-	var expired []*WebAPISession
+	var expired []*Session
 	for aimsid, session := range m.sessions {
 		if now.After(session.ExpiresAt) || session.OSCARSession.IsClosed() {
 			delete(m.sessions, aimsid)
@@ -779,7 +781,7 @@ func (m *WebAPISessionManager) reapExpired() {
 // cancelling Run's context. Safe to call more than once, though only the first
 // call waits for the drain. The drain is bounded by ctx: Shutdown returns
 // ctx.Err() rather than block forever on a listener that ignores cancellation.
-func (m *WebAPISessionManager) Shutdown(ctx context.Context) error {
+func (m *SessionManager) Shutdown(ctx context.Context) error {
 	m.mu.Lock()
 	if m.closed {
 		m.mu.Unlock()
@@ -788,12 +790,12 @@ func (m *WebAPISessionManager) Shutdown(ctx context.Context) error {
 	m.closed = true
 	close(m.stopCh)
 
-	sessions := make([]*WebAPISession, 0, len(m.sessions))
+	sessions := make([]*Session, 0, len(m.sessions))
 	for _, session := range m.sessions {
 		sessions = append(sessions, session)
 	}
 	// Clear all sessions
-	m.sessions = make(map[string]*WebAPISession)
+	m.sessions = make(map[string]*Session)
 	m.mu.Unlock()
 
 	drained := make(chan struct{})
@@ -823,3 +825,130 @@ func generateSessionID() (string, error) {
 	}
 	return hex.EncodeToString(bytes), nil
 }
+
+// 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 *Session) 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,
+	})
+}
+
+// StoredIM is one entry in a fetchStoredIMs reply.
+//
+// Date is a float because AMF3 encodes whole numbers in 29 bits, which a Unix
+// timestamp overflows.
+type StoredIM struct {
+	Sender  string  `json:"sender" xml:"sender"`
+	Message string  `json:"message" xml:"message"`
+	MsgID   string  `json:"msgId" xml:"msgId"`
+	Date    float64 `json:"date" xml:"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 *Session) GetStoredIMs(q StoredIMQuery) []StoredIM {
+	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 []StoredIM{}
+	}
+
+	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([]StoredIM, len(filtered))
+	for i, msg := range filtered {
+		out[i] = StoredIM{
+			Sender:  msg.Sender,
+			Message: msg.Message,
+			MsgID:   msg.MsgID,
+			Date:    float64(msg.Date),
+		}
+	}
+	return out
+}
+
+// normalizeWebAPIAimID keys the IM log by the same normalization the web client
+// applies to aimIds, so a partner stored from a display screen name is still
+// found when the client queries by aimId.
+func normalizeWebAPIAimID(aimID string) string {
+	return state.NewIdentScreenName(aimID).String()
+}

+ 215 - 175
state/webapi_session_test.go → server/webapi/session_test.go

@@ -1,4 +1,4 @@
-package state
+package webapi
 
 import (
 	"context"
@@ -11,37 +11,37 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
-	"github.com/mk6i/open-oscar-server/server/webapi/types"
+	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-func TestWebAPISession_TempBuddies(t *testing.T) {
+func TestSession_TempBuddies(t *testing.T) {
 	tests := []struct {
 		name           string
-		setupSession   func() *WebAPISession
-		operations     func(*WebAPISession)
-		expectedChecks func(*testing.T, *WebAPISession)
+		setupSession   func() *Session
+		operations     func(*Session)
+		expectedChecks func(*testing.T, *Session)
 	}{
 		{
 			name: "Initialize_NilTempBuddies",
-			setupSession: func() *WebAPISession {
-				return &WebAPISession{
+			setupSession: func() *Session {
+				return &Session{
 					AimSID:       "test-session",
-					ScreenName:   DisplayScreenName("testuser"),
-					EventQueue:   types.NewEventQueue(100),
+					ScreenName:   state.DisplayScreenName("testuser"),
+					EventQueue:   NewEventQueue(100),
 					CreatedAt:    time.Now(),
 					LastAccessed: time.Now(),
 					ExpiresAt:    time.Now().Add(time.Hour),
 				}
 			},
-			operations: func(s *WebAPISession) {
+			operations: func(s *Session) {
 				// Initialize TempBuddies if nil
 				if s.TempBuddies == nil {
 					s.TempBuddies = make(map[string]bool)
 				}
 				s.TempBuddies["buddy1"] = true
 			},
-			expectedChecks: func(t *testing.T, s *WebAPISession) {
+			expectedChecks: func(t *testing.T, s *Session) {
 				assert.NotNil(t, s.TempBuddies)
 				assert.True(t, s.TempBuddies["buddy1"])
 				assert.Equal(t, 1, len(s.TempBuddies))
@@ -49,23 +49,23 @@ func TestWebAPISession_TempBuddies(t *testing.T) {
 		},
 		{
 			name: "Add_MultipleTempBuddies",
-			setupSession: func() *WebAPISession {
-				return &WebAPISession{
+			setupSession: func() *Session {
+				return &Session{
 					AimSID:       "test-session",
-					ScreenName:   DisplayScreenName("testuser"),
+					ScreenName:   state.DisplayScreenName("testuser"),
 					TempBuddies:  make(map[string]bool),
-					EventQueue:   types.NewEventQueue(100),
+					EventQueue:   NewEventQueue(100),
 					CreatedAt:    time.Now(),
 					LastAccessed: time.Now(),
 					ExpiresAt:    time.Now().Add(time.Hour),
 				}
 			},
-			operations: func(s *WebAPISession) {
+			operations: func(s *Session) {
 				s.TempBuddies["buddy1"] = true
 				s.TempBuddies["buddy2"] = true
 				s.TempBuddies["buddy3"] = true
 			},
-			expectedChecks: func(t *testing.T, s *WebAPISession) {
+			expectedChecks: func(t *testing.T, s *Session) {
 				assert.Equal(t, 3, len(s.TempBuddies))
 				assert.True(t, s.TempBuddies["buddy1"])
 				assert.True(t, s.TempBuddies["buddy2"])
@@ -74,22 +74,22 @@ func TestWebAPISession_TempBuddies(t *testing.T) {
 		},
 		{
 			name: "Add_DuplicateTempBuddy",
-			setupSession: func() *WebAPISession {
-				return &WebAPISession{
+			setupSession: func() *Session {
+				return &Session{
 					AimSID:       "test-session",
-					ScreenName:   DisplayScreenName("testuser"),
+					ScreenName:   state.DisplayScreenName("testuser"),
 					TempBuddies:  map[string]bool{"buddy1": true},
-					EventQueue:   types.NewEventQueue(100),
+					EventQueue:   NewEventQueue(100),
 					CreatedAt:    time.Now(),
 					LastAccessed: time.Now(),
 					ExpiresAt:    time.Now().Add(time.Hour),
 				}
 			},
-			operations: func(s *WebAPISession) {
+			operations: func(s *Session) {
 				// Add the same buddy again
 				s.TempBuddies["buddy1"] = true
 			},
-			expectedChecks: func(t *testing.T, s *WebAPISession) {
+			expectedChecks: func(t *testing.T, s *Session) {
 				// Should still only have one entry
 				assert.Equal(t, 1, len(s.TempBuddies))
 				assert.True(t, s.TempBuddies["buddy1"])
@@ -97,24 +97,24 @@ func TestWebAPISession_TempBuddies(t *testing.T) {
 		},
 		{
 			name: "Remove_TempBuddy",
-			setupSession: func() *WebAPISession {
-				return &WebAPISession{
+			setupSession: func() *Session {
+				return &Session{
 					AimSID:     "test-session",
-					ScreenName: DisplayScreenName("testuser"),
+					ScreenName: state.DisplayScreenName("testuser"),
 					TempBuddies: map[string]bool{
 						"buddy1": true,
 						"buddy2": true,
 					},
-					EventQueue:   types.NewEventQueue(100),
+					EventQueue:   NewEventQueue(100),
 					CreatedAt:    time.Now(),
 					LastAccessed: time.Now(),
 					ExpiresAt:    time.Now().Add(time.Hour),
 				}
 			},
-			operations: func(s *WebAPISession) {
+			operations: func(s *Session) {
 				delete(s.TempBuddies, "buddy1")
 			},
-			expectedChecks: func(t *testing.T, s *WebAPISession) {
+			expectedChecks: func(t *testing.T, s *Session) {
 				assert.Equal(t, 1, len(s.TempBuddies))
 				assert.False(t, s.TempBuddies["buddy1"])
 				assert.True(t, s.TempBuddies["buddy2"])
@@ -122,21 +122,21 @@ func TestWebAPISession_TempBuddies(t *testing.T) {
 		},
 		{
 			name: "Check_NonExistentBuddy",
-			setupSession: func() *WebAPISession {
-				return &WebAPISession{
+			setupSession: func() *Session {
+				return &Session{
 					AimSID:       "test-session",
-					ScreenName:   DisplayScreenName("testuser"),
+					ScreenName:   state.DisplayScreenName("testuser"),
 					TempBuddies:  map[string]bool{"buddy1": true},
-					EventQueue:   types.NewEventQueue(100),
+					EventQueue:   NewEventQueue(100),
 					CreatedAt:    time.Now(),
 					LastAccessed: time.Now(),
 					ExpiresAt:    time.Now().Add(time.Hour),
 				}
 			},
-			operations: func(s *WebAPISession) {
+			operations: func(s *Session) {
 				// No operations, just checking
 			},
-			expectedChecks: func(t *testing.T, s *WebAPISession) {
+			expectedChecks: func(t *testing.T, s *Session) {
 				assert.False(t, s.TempBuddies["nonexistent"])
 				assert.True(t, s.TempBuddies["buddy1"])
 			},
@@ -157,7 +157,7 @@ func TestWebAPISession_TempBuddies(t *testing.T) {
 	}
 }
 
-func TestWebAPISession_IsExpired(t *testing.T) {
+func TestSession_IsExpired(t *testing.T) {
 	tests := []struct {
 		name      string
 		expiresAt time.Time
@@ -182,9 +182,9 @@ func TestWebAPISession_IsExpired(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			session := &WebAPISession{
+			session := &Session{
 				AimSID:     "test-session",
-				ScreenName: DisplayScreenName("testuser"),
+				ScreenName: state.DisplayScreenName("testuser"),
 				ExpiresAt:  tt.expiresAt,
 			}
 
@@ -193,12 +193,12 @@ func TestWebAPISession_IsExpired(t *testing.T) {
 	}
 }
 
-func TestWebAPISession_WithTempBuddiesIntegration(t *testing.T) {
+func TestSession_WithTempBuddiesIntegration(t *testing.T) {
 	// Test that temp buddies work correctly with a full session
-	session := &WebAPISession{
+	session := &Session{
 		AimSID:       "integration-test",
-		ScreenName:   DisplayScreenName("testuser"),
-		EventQueue:   types.NewEventQueue(100),
+		ScreenName:   state.DisplayScreenName("testuser"),
+		EventQueue:   NewEventQueue(100),
 		TempBuddies:  nil,
 		CreatedAt:    time.Now(),
 		LastAccessed: time.Now(),
@@ -234,18 +234,18 @@ func TestWebAPISession_WithTempBuddiesIntegration(t *testing.T) {
 	assert.True(t, session.TempBuddies["charlie"])
 }
 
-func TestWebAPISession_TempBuddiesIndependence(t *testing.T) {
+func TestSession_TempBuddiesIndependence(t *testing.T) {
 	// Test that temp buddies are independent across sessions
-	session1 := &WebAPISession{
+	session1 := &Session{
 		AimSID:      "session1",
-		ScreenName:  DisplayScreenName("user1"),
+		ScreenName:  state.DisplayScreenName("user1"),
 		TempBuddies: map[string]bool{"buddy1": true},
 		ExpiresAt:   time.Now().Add(time.Hour),
 	}
 
-	session2 := &WebAPISession{
+	session2 := &Session{
 		AimSID:      "session2",
-		ScreenName:  DisplayScreenName("user2"),
+		ScreenName:  state.DisplayScreenName("user2"),
 		TempBuddies: map[string]bool{"buddy2": true},
 		ExpiresAt:   time.Now().Add(time.Hour),
 	}
@@ -265,11 +265,11 @@ func TestWebAPISession_TempBuddiesIndependence(t *testing.T) {
 	assert.False(t, session2.TempBuddies["buddy3"])
 }
 
-// TestWebAPISessionManager_ShutdownIdempotent verifies Shutdown is safe to call
+// TestSessionManager_ShutdownIdempotent verifies Shutdown is safe to call
 // more than once (e.g. from overlapping shutdown paths): the closed flag makes
 // the second call a no-op instead of re-draining.
-func TestWebAPISessionManager_ShutdownIdempotent(t *testing.T) {
-	mgr := NewWebAPISessionManager()
+func TestSessionManager_ShutdownIdempotent(t *testing.T) {
+	mgr := NewSessionManager()
 
 	_ = mgr.Shutdown(context.Background())
 
@@ -278,15 +278,15 @@ func TestWebAPISessionManager_ShutdownIdempotent(t *testing.T) {
 	})
 }
 
-// TestWebAPISessionManager_CreateAfterShutdown verifies that a session cannot be
+// TestSessionManager_CreateAfterShutdown verifies that a session cannot be
 // created once the manager is shut down. Otherwise the reaper is stopped and the
 // session would never be closed or reaped, leaking its OSCAR session.
-func TestWebAPISessionManager_CreateAfterShutdown(t *testing.T) {
-	mgr := NewWebAPISessionManager()
+func TestSessionManager_CreateAfterShutdown(t *testing.T) {
+	mgr := NewSessionManager()
 
 	_ = mgr.Shutdown(context.Background())
 
-	sess, err := mgr.CreateSession(DisplayScreenName("testuser"), "dev", []string{"presence"}, nil, "", nil)
+	sess, err := mgr.CreateSession(state.DisplayScreenName("testuser"), "dev", []string{"presence"}, nil, "", nil)
 	assert.Nil(t, sess)
 	assert.ErrorIs(t, err, ErrWebAPISessionManagerClosed)
 }
@@ -294,13 +294,13 @@ func TestWebAPISessionManager_CreateAfterShutdown(t *testing.T) {
 // A broadcast rate limit SNAC surfaces to the client only for the IM class: the
 // web client renders any rateLimit event as the conversation-window alert. Code 1
 // (a class-params change) is not a status transition and is dropped.
-func TestWebAPISession_handleRateLimitUpdate(t *testing.T) {
+func TestSession_handleRateLimitUpdate(t *testing.T) {
 	const imClass = wire.RateLimitClassID(3)
 
-	newSession := func() *WebAPISession {
-		return &WebAPISession{
+	newSession := func() *Session {
+		return &Session{
 			IMRateClassID: imClass,
-			EventQueue:    types.NewEventQueue(10),
+			EventQueue:    NewEventQueue(10),
 			logger:        slog.New(slog.NewTextHandler(io.Discard, nil)),
 		}
 	}
@@ -319,8 +319,8 @@ func TestWebAPISession_handleRateLimitUpdate(t *testing.T) {
 
 		events := sess.EventQueue.GetAllEvents()
 		require.Len(t, events, 2)
-		assert.Equal(t, "limit", events[0].Data.(types.RateLimitEvent).Classes[0].Status)
-		assert.Equal(t, "clear", events[1].Data.(types.RateLimitEvent).Classes[0].Status)
+		assert.Equal(t, "limit", events[0].Data.(RateLimitEvent).Classes[0].Status)
+		assert.Equal(t, "clear", events[1].Data.(RateLimitEvent).Classes[0].Status)
 	})
 
 	t.Run("other classes and non-status codes are ignored", func(t *testing.T) {
@@ -346,8 +346,8 @@ func TestWebAPISession_handleRateLimitUpdate(t *testing.T) {
 // requests against a dead session (and, downstream, spam clear events on every
 // one of them). Once the aimsid is turned away at RequireSession, neither is
 // possible.
-func TestWebAPISessionManager_GetSession_rejectsAfterRateLimitDisconnect(t *testing.T) {
-	mgr := NewWebAPISessionManager()
+func TestSessionManager_GetSession_rejectsAfterRateLimitDisconnect(t *testing.T) {
+	mgr := NewSessionManager()
 
 	// A rate class that escalates to disconnect after a short back-to-back burst.
 	var classes [5]wire.RateClass
@@ -362,10 +362,10 @@ func TestWebAPISessionManager_GetSession_rejectsAfterRateLimitDisconnect(t *test
 			MaxLevel:        200,
 		}
 	}
-	inst := NewSession().AddInstance()
+	inst := state.NewSession().AddInstance()
 	inst.Session().SetRateClasses(time.Now(), wire.NewRateLimitClasses(classes))
 
-	sess, err := mgr.CreateSession(DisplayScreenName("advbot"), "dev", []string{"presence"}, inst, "", slog.Default())
+	sess, err := mgr.CreateSession(state.DisplayScreenName("advbot"), "dev", []string{"presence"}, inst, "", slog.Default())
 	require.NoError(t, err)
 
 	// Healthy session resolves.
@@ -395,19 +395,19 @@ func TestWebAPISessionManager_GetSession_rejectsAfterRateLimitDisconnect(t *test
 	assert.NotContains(t, mgr.sessions, sess.AimSID)
 }
 
-// TestWebAPISessionManager_ShutdownDrainsAndClosesSessions verifies that Shutdown
+// TestSessionManager_ShutdownDrainsAndClosesSessions verifies that Shutdown
 // collects every live session and tears it down: it drains the maps and closes
 // each session's event queue and OSCAR instance.
-func TestWebAPISessionManager_ShutdownDrainsAndClosesSessions(t *testing.T) {
-	mgr := NewWebAPISessionManager()
+func TestSessionManager_ShutdownDrainsAndClosesSessions(t *testing.T) {
+	mgr := NewSessionManager()
 	ctx := context.Background()
 
-	inst1 := NewSession().AddInstance()
-	inst2 := NewSession().AddInstance()
+	inst1 := state.NewSession().AddInstance()
+	inst2 := state.NewSession().AddInstance()
 
-	s1, err := mgr.CreateSession(DisplayScreenName("alice"), "dev", []string{"presence"}, inst1, "", slog.Default())
+	s1, err := mgr.CreateSession(state.DisplayScreenName("alice"), "dev", []string{"presence"}, inst1, "", slog.Default())
 	assert.NoError(t, err)
-	s2, err := mgr.CreateSession(DisplayScreenName("bob"), "dev", []string{"presence"}, inst2, "", slog.Default())
+	s2, err := mgr.CreateSession(state.DisplayScreenName("bob"), "dev", []string{"presence"}, inst2, "", slog.Default())
 	assert.NoError(t, err)
 
 	assert.NoError(t, mgr.Shutdown(context.Background()))
@@ -417,10 +417,10 @@ func TestWebAPISessionManager_ShutdownDrainsAndClosesSessions(t *testing.T) {
 
 	// Each session's event queue and OSCAR instance were closed: the teardown
 	// loop ran for every collected session.
-	for _, s := range []*WebAPISession{s1, s2} {
+	for _, s := range []*Session{s1, s2} {
 		assertQueueClosed(t, ctx, s)
 	}
-	for _, inst := range []*SessionInstance{inst1, inst2} {
+	for _, inst := range []*state.SessionInstance{inst1, inst2} {
 		select {
 		case <-inst.Closed():
 		default:
@@ -429,14 +429,14 @@ func TestWebAPISessionManager_ShutdownDrainsAndClosesSessions(t *testing.T) {
 	}
 }
 
-// TestWebAPISessionManager_ReapExpired verifies reapExpired removes and tears
+// TestSessionManager_ReapExpired verifies reapExpired removes and tears
 // down only expired sessions, leaving live ones untouched.
-func TestWebAPISessionManager_ReapExpired(t *testing.T) {
-	mgr := NewWebAPISessionManager()
+func TestSessionManager_ReapExpired(t *testing.T) {
+	mgr := NewSessionManager()
 	ctx := context.Background()
 
-	expiredInst := NewSession().AddInstance()
-	liveInst := NewSession().AddInstance()
+	expiredInst := state.NewSession().AddInstance()
+	liveInst := state.NewSession().AddInstance()
 
 	expired, err := mgr.CreateSession("alice", "dev", []string{"presence"}, expiredInst, "", slog.Default())
 	assert.NoError(t, err)
@@ -470,7 +470,7 @@ func TestWebAPISessionManager_ReapExpired(t *testing.T) {
 
 // assertQueueClosed asserts the session's event queue is closed: a fetch returns
 // straight away with no events and no error, rather than parking for the timeout.
-func assertQueueClosed(t *testing.T, ctx context.Context, sess *WebAPISession) {
+func assertQueueClosed(t *testing.T, ctx context.Context, sess *Session) {
 	t.Helper()
 
 	const timeout = 5 * time.Second
@@ -482,11 +482,11 @@ func assertQueueClosed(t *testing.T, ctx context.Context, sess *WebAPISession) {
 	assert.Less(t, time.Since(start), timeout/2, "fetch parked instead of returning on a closed queue")
 }
 
-// TestWebAPISessionManager_ShutdownWithoutReaper verifies Shutdown returns when no
+// TestSessionManager_ShutdownWithoutReaper verifies Shutdown returns when no
 // reaper was ever started. Shutdown must not depend on the caller cancelling the
 // context passed to Run.
-func TestWebAPISessionManager_ShutdownWithoutReaper(t *testing.T) {
-	mgr := NewWebAPISessionManager()
+func TestSessionManager_ShutdownWithoutReaper(t *testing.T) {
+	mgr := NewSessionManager()
 
 	done := make(chan struct{})
 	go func() {
@@ -502,10 +502,10 @@ func TestWebAPISessionManager_ShutdownWithoutReaper(t *testing.T) {
 	}
 }
 
-// TestWebAPISessionManager_ShutdownJoinsReaper verifies Shutdown stops a running
+// TestSessionManager_ShutdownJoinsReaper verifies Shutdown stops a running
 // reaper on its own and does not return until that reaper has exited.
-func TestWebAPISessionManager_ShutdownJoinsReaper(t *testing.T) {
-	mgr := NewWebAPISessionManager()
+func TestSessionManager_ShutdownJoinsReaper(t *testing.T) {
+	mgr := NewSessionManager()
 
 	reaperExited := make(chan struct{})
 	go func() {
@@ -537,10 +537,10 @@ func TestWebAPISessionManager_ShutdownJoinsReaper(t *testing.T) {
 	}
 }
 
-// TestWebAPISessionManager_RunAfterShutdown verifies a reaper that loses the race
+// TestSessionManager_RunAfterShutdown verifies a reaper that loses the race
 // with Shutdown never starts, so it cannot reap an already-drained manager.
-func TestWebAPISessionManager_RunAfterShutdown(t *testing.T) {
-	mgr := NewWebAPISessionManager()
+func TestSessionManager_RunAfterShutdown(t *testing.T) {
+	mgr := NewSessionManager()
 	assert.NoError(t, mgr.Shutdown(context.Background()))
 
 	done := make(chan struct{})
@@ -559,12 +559,12 @@ func TestWebAPISessionManager_RunAfterShutdown(t *testing.T) {
 // The client deletes the alias it holds each time it merges a user map, so every
 // event naming a buddy has to repeat it. An incoming IM and a presence change both
 // carry a user map, and both would otherwise rename an aliased buddy.
-func TestWebAPISession_RepeatsBuddyAliasOnOSCAREvents(t *testing.T) {
-	newSession := func() *WebAPISession {
-		return &WebAPISession{
-			ScreenName: DisplayScreenName("me"),
+func TestSession_RepeatsBuddyAliasOnOSCAREvents(t *testing.T) {
+	newSession := func() *Session {
+		return &Session{
+			ScreenName: state.DisplayScreenName("me"),
 			Events:     []string{"im", "conversation", "presence"},
-			EventQueue: types.NewEventQueue(10),
+			EventQueue: NewEventQueue(10),
 			logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
 			BuddyAliasLoader: func(_ context.Context) (map[string]string, error) {
 				return map[string]string{"mikekelly": "MICHAELKELLY"}, nil
@@ -586,7 +586,7 @@ func TestWebAPISession_RepeatsBuddyAliasOnOSCAREvents(t *testing.T) {
 
 		events := sess.EventQueue.GetAllEvents()
 		require.NotEmpty(t, events)
-		imEvent := events[0].Data.(types.IMEvent)
+		imEvent := events[0].Data.(IMEvent)
 		assert.Equal(t, "mikekelly", imEvent.Source.AimID)
 		assert.Equal(t, "Mike Kelly", imEvent.Source.DisplayID)
 		assert.Equal(t, "MICHAELKELLY", imEvent.Source.Friendly)
@@ -600,7 +600,7 @@ func TestWebAPISession_RepeatsBuddyAliasOnOSCAREvents(t *testing.T) {
 
 		events := sess.EventQueue.GetAllEvents()
 		require.Len(t, events, 1)
-		presence := events[0].Data.(types.PresenceEvent)
+		presence := events[0].Data.(PresenceEvent)
 		assert.Equal(t, "mikekelly", presence.AimID)
 		assert.Equal(t, "MICHAELKELLY", presence.Friendly)
 	})
@@ -613,7 +613,7 @@ func TestWebAPISession_RepeatsBuddyAliasOnOSCAREvents(t *testing.T) {
 
 		events := sess.EventQueue.GetAllEvents()
 		require.Len(t, events, 1)
-		presence := events[0].Data.(types.PresenceEvent)
+		presence := events[0].Data.(PresenceEvent)
 		assert.Equal(t, "mikekelly", presence.AimID)
 		assert.Equal(t, "MICHAELKELLY", presence.Friendly)
 	})
@@ -626,18 +626,18 @@ func TestWebAPISession_RepeatsBuddyAliasOnOSCAREvents(t *testing.T) {
 
 		events := sess.EventQueue.GetAllEvents()
 		require.Len(t, events, 1)
-		assert.Empty(t, events[0].Data.(types.PresenceEvent).Friendly)
+		assert.Empty(t, events[0].Data.(PresenceEvent).Friendly)
 	})
 }
 
 // Aliases all come from one feedbag query, so a signon that brings a whole buddy
 // list online must not re-query the feedbag per buddy.
-func TestWebAPISession_CachesBuddyAliases(t *testing.T) {
+func TestSession_CachesBuddyAliases(t *testing.T) {
 	var loads int
-	sess := &WebAPISession{
-		ScreenName: DisplayScreenName("me"),
+	sess := &Session{
+		ScreenName: state.DisplayScreenName("me"),
 		Events:     []string{"presence"},
-		EventQueue: types.NewEventQueue(10),
+		EventQueue: NewEventQueue(10),
 		logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
 		BuddyAliasLoader: func(_ context.Context) (map[string]string, error) {
 			loads++
@@ -654,32 +654,32 @@ func TestWebAPISession_CachesBuddyAliases(t *testing.T) {
 	events := sess.EventQueue.GetAllEvents()
 	require.Len(t, events, 5)
 	for _, event := range events {
-		assert.Equal(t, "MICHAELKELLY", event.Data.(types.PresenceEvent).Friendly)
+		assert.Equal(t, "MICHAELKELLY", event.Data.(PresenceEvent).Friendly)
 	}
 	assert.Equal(t, 1, loads, "aliases should be loaded once, not once per event")
 }
 
 // A feedbag change from another of the owner's clients arrives as a SNAC, which is
 // the session's only signal that its cached aliases are stale.
-func TestWebAPISession_FeedbagSNACInvalidatesAliasCache(t *testing.T) {
+func TestSession_FeedbagSNACInvalidatesAliasCache(t *testing.T) {
 	alias := "MICHAELKELLY"
-	sess := &WebAPISession{
-		ScreenName: DisplayScreenName("me"),
+	sess := &Session{
+		ScreenName: state.DisplayScreenName("me"),
 		Events:     []string{"presence"},
-		EventQueue: types.NewEventQueue(10),
+		EventQueue: NewEventQueue(10),
 		logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
 		BuddyAliasLoader: func(_ context.Context) (map[string]string, error) {
 			return map[string]string{"mikekelly": alias}, nil
 		},
 	}
 
-	arrive := func() types.PresenceEvent {
+	arrive := func() PresenceEvent {
 		sess.handleBuddyArrived(wire.SNACMessage{Body: wire.SNAC_0x03_0x0B_BuddyArrived{
 			TLVUserInfo: wire.TLVUserInfo{ScreenName: "Mike Kelly"},
 		}})
 		events := sess.EventQueue.GetAllEvents()
 		require.NotEmpty(t, events)
-		return events[len(events)-1].Data.(types.PresenceEvent)
+		return events[len(events)-1].Data.(PresenceEvent)
 	}
 
 	assert.Equal(t, "MICHAELKELLY", arrive().Friendly)
@@ -697,7 +697,7 @@ func TestWebAPISession_FeedbagSNACInvalidatesAliasCache(t *testing.T) {
 // Permit/deny changes from another of the owner's clients arrive as an insert,
 // an update, or a delete, and all three have to refresh the client's privacy
 // state.
-func TestWebAPISession_FeedbagSNACRefreshesPermitDeny(t *testing.T) {
+func TestSession_FeedbagSNACRefreshesPermitDeny(t *testing.T) {
 	denyItem := wire.FeedbagItem{ClassID: wire.FeedbagClassIDDeny, Name: "blockeduser"}
 	buddyItem := wire.FeedbagItem{ClassID: wire.FeedbagClassIdBuddy, Name: "friend"}
 
@@ -735,9 +735,9 @@ func TestWebAPISession_FeedbagSNACRefreshesPermitDeny(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			sess := &WebAPISession{
-				ScreenName: DisplayScreenName("me"),
-				EventQueue: types.NewEventQueue(10),
+			sess := &Session{
+				ScreenName: state.DisplayScreenName("me"),
+				EventQueue: NewEventQueue(10),
 				logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
 				PermitDenyRefresher: func(_ context.Context) (interface{}, error) {
 					return map[string]any{"pdMode": "denySome"}, nil
@@ -751,7 +751,7 @@ func TestWebAPISession_FeedbagSNACRefreshesPermitDeny(t *testing.T) {
 
 			var got int
 			for _, event := range sess.EventQueue.GetAllEvents() {
-				if event.Type == types.EventTypePermitDeny {
+				if event.Type == EventTypePermitDeny {
 					got++
 				}
 			}
@@ -766,9 +766,9 @@ func TestWebAPISession_FeedbagSNACRefreshesPermitDeny(t *testing.T) {
 
 // A session sees no SNAC for feedbag writes it makes itself, so the handlers that
 // perform those writes invalidate the cache directly.
-func TestWebAPISession_InvalidateAliases(t *testing.T) {
+func TestSession_InvalidateAliases(t *testing.T) {
 	alias := "MICHAELKELLY"
-	sess := &WebAPISession{
+	sess := &Session{
 		logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
 		BuddyAliasLoader: func(_ context.Context) (map[string]string, error) {
 			return map[string]string{"mikekelly": alias}, nil
@@ -786,9 +786,9 @@ func TestWebAPISession_InvalidateAliases(t *testing.T) {
 
 // A failed load must not be cached as an empty map: aliases would stay missing for
 // the life of the session.
-func TestWebAPISession_AliasLoadErrorIsNotCached(t *testing.T) {
+func TestSession_AliasLoadErrorIsNotCached(t *testing.T) {
 	var loads int
-	sess := &WebAPISession{
+	sess := &Session{
 		logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
 		BuddyAliasLoader: func(_ context.Context) (map[string]string, error) {
 			loads++
@@ -803,11 +803,11 @@ func TestWebAPISession_AliasLoadErrorIsNotCached(t *testing.T) {
 	assert.Equal(t, "MICHAELKELLY", sess.Aliases(context.Background())["mikekelly"])
 }
 
-func TestWebAPISession_HandleIncomingIM_NormalizesAimID(t *testing.T) {
-	sess := &WebAPISession{
-		ScreenName: DisplayScreenName("me"),
+func TestSession_HandleIncomingIM_NormalizesAimID(t *testing.T) {
+	sess := &Session{
+		ScreenName: state.DisplayScreenName("me"),
 		Events:     []string{"im", "conversation"},
-		EventQueue: types.NewEventQueue(10),
+		EventQueue: NewEventQueue(10),
 		logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
 	}
 
@@ -825,11 +825,11 @@ func TestWebAPISession_HandleIncomingIM_NormalizesAimID(t *testing.T) {
 	events := sess.EventQueue.GetAllEvents()
 	require.Len(t, events, 2)
 
-	imEvent := events[0].Data.(types.IMEvent)
+	imEvent := events[0].Data.(IMEvent)
 	assert.Equal(t, "mikekelly", imEvent.Source.AimID)
 	assert.Equal(t, "Mike Kelly", imEvent.Source.DisplayID)
 
-	convData := events[1].Data.(*types.ConversationData)
+	convData := events[1].Data.(*ConversationData)
 	require.Len(t, convData.Conversations, 1)
 	entry := convData.Conversations[0]
 	assert.Equal(t, "mikekelly", entry.AimID)
@@ -844,10 +844,10 @@ func TestWebAPISession_HandleIncomingIM_NormalizesAimID(t *testing.T) {
 	assert.Equal(t, "hello", msgs[0].Message)
 }
 
-func TestWebAPISession_HandleTypingNotification_NormalizesAimID(t *testing.T) {
-	sess := &WebAPISession{
+func TestSession_HandleTypingNotification_NormalizesAimID(t *testing.T) {
+	sess := &Session{
 		Events:     []string{"typing"},
-		EventQueue: types.NewEventQueue(10),
+		EventQueue: NewEventQueue(10),
 	}
 
 	sess.handleTypingNotification(wire.SNACMessage{
@@ -859,15 +859,15 @@ func TestWebAPISession_HandleTypingNotification_NormalizesAimID(t *testing.T) {
 
 	events := sess.EventQueue.GetAllEvents()
 	require.Len(t, events, 1)
-	typing := events[0].Data.(types.TypingEvent)
+	typing := events[0].Data.(TypingEvent)
 	assert.Equal(t, "mikekelly", typing.AimID)
 	assert.Equal(t, "typing", typing.TypingStatus)
 }
 
-func TestWebAPISession_HandleBuddyArrivedDeparted_NormalizesAimID(t *testing.T) {
-	sess := &WebAPISession{
+func TestSession_HandleBuddyArrivedDeparted_NormalizesAimID(t *testing.T) {
+	sess := &Session{
 		Events:     []string{"presence"},
-		EventQueue: types.NewEventQueue(10),
+		EventQueue: NewEventQueue(10),
 	}
 
 	sess.handleBuddyArrived(wire.SNACMessage{
@@ -884,11 +884,11 @@ func TestWebAPISession_HandleBuddyArrivedDeparted_NormalizesAimID(t *testing.T)
 	events := sess.EventQueue.GetAllEvents()
 	require.Len(t, events, 2)
 
-	arrived := events[0].Data.(types.PresenceEvent)
+	arrived := events[0].Data.(PresenceEvent)
 	assert.Equal(t, "mikekelly", arrived.AimID)
 	assert.Equal(t, "online", arrived.State)
 
-	departed := events[1].Data.(types.PresenceEvent)
+	departed := events[1].Data.(PresenceEvent)
 	assert.Equal(t, "mikekelly", departed.AimID)
 	assert.Equal(t, "offline", departed.State)
 }
@@ -897,14 +897,14 @@ func TestWebAPISession_HandleBuddyArrivedDeparted_NormalizesAimID(t *testing.T)
 // rides along on the presence broadcast and must reach the presence event. The
 // stub BuddyIconURL stands in for the handlers-side URL formatter, which state
 // cannot import.
-func TestWebAPISession_PublishesBuddyIconOnPresence(t *testing.T) {
-	newSession := func() *WebAPISession {
-		return &WebAPISession{
-			ScreenName: DisplayScreenName("me"),
+func TestSession_PublishesBuddyIconOnPresence(t *testing.T) {
+	newSession := func() *Session {
+		return &Session{
+			ScreenName: state.DisplayScreenName("me"),
 			Events:     []string{"presence"},
-			EventQueue: types.NewEventQueue(10),
+			EventQueue: NewEventQueue(10),
 			logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
-			BuddyIconURL: func(sn IdentScreenName, hash []byte) string {
+			BuddyIconURL: func(sn state.IdentScreenName, hash []byte) string {
 				if len(hash) == 0 {
 					return "placeholder:" + sn.String()
 				}
@@ -913,7 +913,7 @@ func TestWebAPISession_PublishesBuddyIconOnPresence(t *testing.T) {
 		}
 	}
 
-	arrived := func(sess *WebAPISession, screenName string, hash []byte) {
+	arrived := func(sess *Session, screenName string, hash []byte) {
 		info := wire.TLVUserInfo{ScreenName: screenName}
 		if hash != nil {
 			info.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, wire.BARTID{
@@ -924,10 +924,10 @@ func TestWebAPISession_PublishesBuddyIconOnPresence(t *testing.T) {
 		sess.handleBuddyArrived(wire.SNACMessage{Body: wire.SNAC_0x03_0x0B_BuddyArrived{TLVUserInfo: info}})
 	}
 
-	lastPresence := func(sess *WebAPISession) types.PresenceEvent {
+	lastPresence := func(sess *Session) PresenceEvent {
 		events := sess.EventQueue.GetAllEvents()
 		require.Len(t, events, 1)
-		return events[0].Data.(types.PresenceEvent)
+		return events[0].Data.(PresenceEvent)
 	}
 
 	t.Run("icon hash yields the content-addressed URL", func(t *testing.T) {
@@ -966,13 +966,13 @@ func TestWebAPISession_PublishesBuddyIconOnPresence(t *testing.T) {
 
 // A user's own icon change is relayed to their session as OServiceUserInfoUpdate,
 // which the pump turns into a myInfo event so the identity badge re-renders.
-func TestWebAPISession_PushesMyInfoOnUserInfoUpdate(t *testing.T) {
-	newSession := func(events ...string) (*WebAPISession, *int) {
+func TestSession_PushesMyInfoOnUserInfoUpdate(t *testing.T) {
+	newSession := func(events ...string) (*Session, *int) {
 		var refreshes int
-		return &WebAPISession{
-			ScreenName: DisplayScreenName("me"),
+		return &Session{
+			ScreenName: state.DisplayScreenName("me"),
 			Events:     events,
-			EventQueue: types.NewEventQueue(10),
+			EventQueue: NewEventQueue(10),
 			logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
 			MyInfoRefresher: func(_ context.Context) (interface{}, error) {
 				refreshes++
@@ -1021,15 +1021,15 @@ func TestWebAPISession_PushesMyInfoOnUserInfoUpdate(t *testing.T) {
 	})
 }
 
-// TestWebAPISessionManager_ShutdownBoundedByContext verifies that Shutdown
+// TestSessionManager_ShutdownBoundedByContext verifies that Shutdown
 // honors its context instead of blocking indefinitely. A listener goroutine that
 // ignores cancellation must not be able to hold the whole server open: main
 // budgets a few seconds for every server's shutdown combined, so an unbounded
 // wait here means the process never exits.
-func TestWebAPISessionManager_ShutdownBoundedByContext(t *testing.T) {
-	mgr := NewWebAPISessionManager()
+func TestSessionManager_ShutdownBoundedByContext(t *testing.T) {
+	mgr := NewSessionManager()
 
-	inst := NewSession().AddInstance()
+	inst := state.NewSession().AddInstance()
 	sess, err := mgr.CreateSession("alice", "dev", []string{"presence"}, inst, "", slog.Default())
 	assert.NoError(t, err)
 
@@ -1053,13 +1053,13 @@ func TestWebAPISessionManager_ShutdownBoundedByContext(t *testing.T) {
 	assert.Less(t, elapsed, 2*time.Second, "Shutdown must give up at its deadline, not wait on the stuck listener")
 }
 
-// TestWebAPISession_CloseCancelsSessionContext verifies that Close cancels the
+// TestSession_CloseCancelsSessionContext verifies that Close cancels the
 // context handed to the refresher callbacks. The listener runs feedbag queries
 // through it, and without cancellation Close's wait lasts as long as the query.
-func TestWebAPISession_CloseCancelsSessionContext(t *testing.T) {
-	mgr := NewWebAPISessionManager()
+func TestSession_CloseCancelsSessionContext(t *testing.T) {
+	mgr := NewSessionManager()
 
-	inst := NewSession().AddInstance()
+	inst := state.NewSession().AddInstance()
 	sess, err := mgr.CreateSession("alice", "dev", []string{"presence"}, inst, "", slog.Default())
 	assert.NoError(t, err)
 
@@ -1074,14 +1074,14 @@ func TestWebAPISession_CloseCancelsSessionContext(t *testing.T) {
 // ICBMChannelMsgToClient stamped with a send time. The client models that as its
 // own offlineIM event, keyed by a bare aimId and timestamped when the sender sent
 // it rather than when it was delivered.
-func TestWebAPISession_OfflineIM(t *testing.T) {
+func TestSession_OfflineIM(t *testing.T) {
 	sentAt := time.Now().Add(-2 * time.Hour).Unix()
 
-	newSession := func(events ...string) *WebAPISession {
-		return &WebAPISession{
-			ScreenName: DisplayScreenName("me"),
+	newSession := func(events ...string) *Session {
+		return &Session{
+			ScreenName: state.DisplayScreenName("me"),
 			Events:     events,
-			EventQueue: types.NewEventQueue(10),
+			EventQueue: NewEventQueue(10),
 			logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
 		}
 	}
@@ -1107,8 +1107,8 @@ func TestWebAPISession_OfflineIM(t *testing.T) {
 
 		events := sess.EventQueue.GetAllEvents()
 		require.Len(t, events, 1)
-		assert.Equal(t, types.EventTypeOfflineIM, events[0].Type)
-		offline := events[0].Data.(types.OfflineIMEvent)
+		assert.Equal(t, EventTypeOfflineIM, events[0].Type)
+		offline := events[0].Data.(OfflineIMEvent)
 		assert.Equal(t, "mikekelly", offline.AimID)
 		assert.Equal(t, "sent while you were out", offline.Message)
 		assert.NotEmpty(t, offline.MsgID)
@@ -1121,7 +1121,7 @@ func TestWebAPISession_OfflineIM(t *testing.T) {
 
 		events := sess.EventQueue.GetAllEvents()
 		require.Len(t, events, 1)
-		assert.Equal(t, types.EventTypeIM, events[0].Type)
+		assert.Equal(t, EventTypeIM, events[0].Type)
 	})
 
 	t.Run("offlineIM subscriber gets a conversation update", func(t *testing.T) {
@@ -1130,8 +1130,8 @@ func TestWebAPISession_OfflineIM(t *testing.T) {
 
 		events := sess.EventQueue.GetAllEvents()
 		require.Len(t, events, 2)
-		assert.Equal(t, types.EventTypeOfflineIM, events[0].Type)
-		assert.Equal(t, types.EventTypeConversation, events[1].Type)
+		assert.Equal(t, EventTypeOfflineIM, events[0].Type)
+		assert.Equal(t, EventTypeConversation, events[1].Type)
 	})
 
 	// The history the client pulls with fetchStoredIMs has to order the message by
@@ -1161,17 +1161,17 @@ func TestWebAPISession_OfflineIM(t *testing.T) {
 // client is parked on a long poll at that moment, so it must be released with a
 // sessionEnded event rather than left to hang until the reaper's next sweep —
 // which measured 26-28s against a running server.
-func TestWebAPISession_BootReleasesParkedFetcherWithSessionEnded(t *testing.T) {
-	mgr := NewWebAPISessionManager()
-	inst := NewSession().AddInstance()
+func TestSession_BootReleasesParkedFetcherWithSessionEnded(t *testing.T) {
+	mgr := NewSessionManager()
+	inst := state.NewSession().AddInstance()
 
-	sess, err := mgr.CreateSession(DisplayScreenName("mike"), "dev", []string{"presence"}, inst, "", slog.Default())
+	sess, err := mgr.CreateSession(state.DisplayScreenName("mike"), "dev", []string{"presence"}, inst, "", slog.Default())
 	require.NoError(t, err)
 	sess.StartListeningToOSCARSession()
 
 	// Park a fetcher the way fetchEvents does, with nothing pending.
 	type result struct {
-		events []types.Event
+		events []Event
 		err    error
 	}
 	done := make(chan result, 1)
@@ -1189,7 +1189,7 @@ func TestWebAPISession_BootReleasesParkedFetcherWithSessionEnded(t *testing.T) {
 	case got := <-done:
 		require.NoError(t, got.err)
 		require.Len(t, got.events, 1)
-		assert.Equal(t, types.EventTypeSessionEnded, got.events[0].Type)
+		assert.Equal(t, EventTypeSessionEnded, got.events[0].Type)
 	case <-time.After(5 * time.Second):
 		t.Fatal("parked fetcher was not released by the boot")
 	}
@@ -1198,11 +1198,11 @@ func TestWebAPISession_BootReleasesParkedFetcherWithSessionEnded(t *testing.T) {
 // A session tearing itself down — endSession, or the idle reaper — needs no
 // sessionEnded event: the client already knows it is leaving. Close closes the
 // queue before the instance, so the listener's push lands on a closed queue.
-func TestWebAPISession_SelfCloseEmitsNoSessionEndedEvent(t *testing.T) {
-	mgr := NewWebAPISessionManager()
-	inst := NewSession().AddInstance()
+func TestSession_SelfCloseEmitsNoSessionEndedEvent(t *testing.T) {
+	mgr := NewSessionManager()
+	inst := state.NewSession().AddInstance()
 
-	sess, err := mgr.CreateSession(DisplayScreenName("mike"), "dev", []string{"presence"}, inst, "", slog.Default())
+	sess, err := mgr.CreateSession(state.DisplayScreenName("mike"), "dev", []string{"presence"}, inst, "", slog.Default())
 	require.NoError(t, err)
 	sess.StartListeningToOSCARSession()
 
@@ -1212,3 +1212,43 @@ func TestWebAPISession_SelfCloseEmitsNoSessionEndedEvent(t *testing.T) {
 	require.NoError(t, err)
 	assert.Empty(t, events)
 }
+
+func TestSession_GetStoredIMs(t *testing.T) {
+	sess := &Session{}
+	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)
+}
+
+func TestSession_GetStoredIMs_NormalizesPartner(t *testing.T) {
+	sess := &Session{}
+	sess.AddStoredIM("Mike Kelly", "mikekelly", "hello", "msg-1", 100)
+
+	// The web client queries history by the normalized aimId, never by the
+	// display screen name it was stored under.
+	msgs := sess.GetStoredIMs(StoredIMQuery{
+		PartnerAimID: "mikekelly",
+		NToGet:       10,
+	})
+	require.Len(t, msgs, 1)
+	assert.Equal(t, "msg-1", msgs[0].MsgID)
+}

+ 18 - 26
server/webapi/handlers/user_info_stub.go → server/webapi/stub_handler.go

@@ -1,10 +1,21 @@
-package handlers
+package webapi
 
 import (
 	"log/slog"
 	"net/http"
 )
 
+// ServiceStubHandler serves the /service/* endpoints that manage third-party
+// service linking (Google Talk, Facebook), none of which this server federates.
+type ServiceStubHandler struct {
+	Logger *slog.Logger
+}
+
+// GetAttributes reports that the requested third-party service is not linked.
+func (h *ServiceStubHandler) GetAttributes(w http.ResponseWriter, r *http.Request) {
+	SendEnvelopeStatus(w, r, statusNoSuchService, "Service not available", h.Logger)
+}
+
 type UserInfoStubHandler struct {
 	Logger *slog.Logger
 }
@@ -59,10 +70,7 @@ type Service struct {
 // asks at sign-on to build its service list and to decide whether to offer a
 // link-account prompt for the others.
 func (h *UserInfoStubHandler) GetServices(w http.ResponseWriter, r *http.Request) {
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = &ServicesData{
+	SendOK(w, r, &ServicesData{
 		Services: []Service{
 			{
 				Name:       "aim",
@@ -71,18 +79,13 @@ func (h *UserInfoStubHandler) GetServices(w http.ResponseWriter, r *http.Request
 				Online:     true,
 			},
 		},
-	}
-	SendResponse(w, r, resp, h.Logger)
+	}, h.Logger)
 }
 
 func (h *UserInfoStubHandler) GetUserDetails(w http.ResponseWriter, r *http.Request) {
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = &UserDetailsData{
+	SendOK(w, r, &UserDetailsData{
 		UserDetails: UserDetails{Services: []UserService{{Service: "aim"}}},
-	}
-	SendResponse(w, r, resp, h.Logger)
+	}, h.Logger)
 }
 
 // HeyGetNotifications returns an empty social-notification feed.
@@ -92,20 +95,9 @@ func (h *UserInfoStubHandler) GetUserDetails(w http.ResponseWriter, r *http.Requ
 // "Array.prototype.map called on null or undefined" and aborts the rest of the
 // notification setup.
 func (h *UserInfoStubHandler) HeyGetNotifications(w http.ResponseWriter, r *http.Request) {
-	resp := BaseResponse{}
-	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
-	resp.Response.Data = &NotificationsData{Activities: []string{}}
-	SendResponse(w, r, resp, h.Logger)
+	SendOK(w, r, &NotificationsData{Activities: []string{}}, 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"
-	SendResponse(w, r, resp, h.Logger)
+	SendOK(w, r, nil, h.Logger)
 }

+ 1 - 1
server/webapi/handlers/user_info_stub_test.go → server/webapi/stub_handler_test.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"encoding/json"

+ 53 - 61
server/webapi/types.go

@@ -4,100 +4,92 @@ import (
 	"context"
 	"time"
 
-	"github.com/google/uuid"
 	"github.com/mk6i/open-oscar-server/config"
-
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-type ICBMService interface {
-	ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
-	ClientEvent(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error
-	EvilRequest(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error)
-	ParameterQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
-	ClientErr(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x0B_ICBMClientErr) error
-	OfflineRetrieve(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error)
+// SessionResolver resolves and refreshes Web API sessions by aimsid.
+type SessionResolver interface {
+	GetSession(ctx context.Context, aimsid string) (*Session, error)
+	TouchSession(ctx context.Context, aimsid string) error
 }
 
-type OServiceService interface {
-	ClientOnline(ctx context.Context, service uint16, inBody wire.SNAC_0x01_0x02_OServiceClientOnline, instance *state.SessionInstance) error
-	IdleNotification(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x01_0x11_OServiceIdleNotification) error
-	MonitorRateLimits(ctx context.Context, session *state.Session)
-	RateParamsSubAdd(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd)
-	ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listenerGroup config.ListenerGroup) (wire.SNACMessage, error)
+// APIKeyValidator validates the dev_key a client sends as its "k" parameter.
+type APIKeyValidator interface {
+	GetAPIKeyByDevKey(ctx context.Context, devKey string) (*state.WebAPIKey, error)
+	UpdateLastUsed(ctx context.Context, devKey string) error
 }
 
+// AuthService cracks auth cookies and registers the BOS sessions they name.
 type AuthService interface {
-	BUCPChallenge(ctx context.Context, inBody wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
-	BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, endpointCfg config.Endpoint) (wire.SNACMessage, error)
 	CrackCookie(authCookie []byte) (state.ServerCookie, time.Time, error)
 	FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error)
 	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, conf func(sess *state.Session)) (*state.SessionInstance, error)
-	RegisterChatSession(ctx context.Context, authCookie state.ServerCookie, cfg func(sess *state.Session)) (*state.SessionInstance, error)
-	RetrieveBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.SessionInstance, error)
 	Signout(ctx context.Context, session *state.Session)
-	SignoutChat(ctx context.Context, sess *state.Session)
 }
 
-type LocateService interface {
-	SetDirInfo(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error)
-	SetInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x02_0x04_LocateSetInfo) error
-	UserInfoQuery(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error)
-	DirInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error)
+// BARTService stores and retrieves BART (buddy art) assets by content hash.
+type BARTService interface {
+	RetrieveItem(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error)
+	UpsertItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x02_BARTUploadQuery) (wire.SNACMessage, error)
 }
 
-// DirSearchService issues OSCAR ODir directory searches on behalf of the Web
-// AIM member-directory endpoints.
+// BuddyBroadcaster announces a user's arrival and departure to their watchers.
+type BuddyBroadcaster interface {
+	BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error
+	BroadcastBuddyDeparted(ctx context.Context, screenName state.IdentScreenName) error
+}
+
+// DirSearchService runs ODir member-directory searches.
 type DirSearchService interface {
 	InfoQuery(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)
 }
 
-// BuddyListRegistry is the interface for keeping track of users with active
-// buddy lists. Once registered, a user becomes visible to other users' buddy
-// lists and vice versa.
-type BuddyListRegistry interface {
-	RegisterBuddyList(ctx context.Context, user state.IdentScreenName) error
-	UnregisterBuddyList(ctx context.Context, user state.IdentScreenName) error
+// FeedbagService reads and edits the server-stored buddy list.
+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)
+	UpsertItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) (*wire.SNACMessage, error)
+	Use(ctx context.Context, instance *state.SessionInstance) error
 }
 
-// CookieBaker defines methods for issuing and verifying AIM authentication tokens ("cookies").
-// These tokens are used for authenticating client sessions with AIM services.
-type CookieBaker interface {
-	// Crack verifies and decodes a previously issued authentication token.
-	// Returns the original payload and the token's expiry if it is valid.
-	Crack(data []byte) ([]byte, time.Time, error)
+// ICBMService sends instant messages and typing events and drains offline ones.
+type ICBMService interface {
+	ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
+	ClientEvent(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error
+	OfflineRetrieve(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error)
+}
 
-	// Issue creates a new authentication token from the given payload that
-	// stays valid for ttl. The resulting token can later be verified using
-	// Crack.
-	Issue(data []byte, ttl time.Duration) ([]byte, error)
+// LocateService reads and writes user profiles and directory info.
+type LocateService interface {
+	SetDirInfo(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error)
+	SetInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x02_0x04_LocateSetInfo) error
+	UserInfoQuery(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error)
+	DirInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error)
 }
 
-// SessionRetriever provides methods to retrieve OSCAR sessions.
-type SessionRetriever interface {
-	AllSessions() []*state.Session
-	RetrieveSession(screenName state.IdentScreenName) *state.Session
+// OServiceService completes sign-on and manages rate limit subscriptions.
+type OServiceService interface {
+	ClientOnline(ctx context.Context, service uint16, inBody wire.SNAC_0x01_0x02_OServiceClientOnline, instance *state.SessionInstance) error
+	MonitorRateLimits(ctx context.Context, session *state.Session)
+	RateParamsSubAdd(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd)
 }
 
-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
+// BuddyIconRetriever resolves a user's icon reference from their feedbag, which
+// is where it lives, so it resolves for offline users too.
+type BuddyIconRetriever interface {
+	BuddyIconMetadata(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error)
 }
 
-// BuddyBroadcaster broadcasts buddy presence updates
-type BuddyBroadcaster interface {
-	BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error
-	BroadcastBuddyDeparted(ctx context.Context, screenName state.IdentScreenName) error
+// BuddyListRegistry registers a user's buddy list, making them and the users on
+// it visible to each other.
+type BuddyListRegistry interface {
+	RegisterBuddyList(ctx context.Context, user state.IdentScreenName) error
+	UnregisterBuddyList(ctx context.Context, user state.IdentScreenName) error
 }
 
+// ChatSessionManager removes a departing user from every chat room they joined.
 type ChatSessionManager interface {
 	RemoveUserFromAllChats(user state.IdentScreenName)
 }

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

@@ -1,67 +0,0 @@
-package types
-
-import "time"
-
-// ConversationData is a conversation event payload: an operation and the
-// conversations it applies to.
-type ConversationData struct {
-	Operation     string                  `json:"operation" xml:"operation"`
-	Conversations []ConversationEntryData `json:"conversations" xml:"conversations>conversation"`
-}
-
-// ConversationEntryData is one conversation in the client's list.
-type ConversationEntryData struct {
-	AimID string `json:"aimId" xml:"aimId"`
-	// Active is always sent, zero included, because the client reads it
-	// unconditionally.
-	Active      int `json:"active" xml:"active"`
-	UnreadCount int `json:"unreadCount" xml:"unreadCount"`
-	// DisplayID is omitted when empty rather than sent blank: the client falls
-	// back to the name it already has for aimID, whereas any value present here
-	// replaces it.
-	DisplayID string  `json:"displayId,omitempty" xml:"displayId,omitempty"`
-	LastIM    *LastIM `json:"lastIM,omitempty" xml:"lastIM,omitempty"`
-}
-
-// LastIM is the most recent message in a conversation.
-//
-// Timestamp is a float because AMF3 encodes whole numbers in 29 bits, which a
-// Unix timestamp overflows.
-type LastIM struct {
-	Message   string  `json:"message" xml:"message"`
-	MsgID     string  `json:"msgId" xml:"msgId"`
-	Sender    string  `json:"sender" xml:"sender"`
-	Sent      bool    `json:"sent" xml:"sent"`
-	Timestamp float64 `json:"timestamp" xml:"timestamp"`
-}
-
-// ConversationEventData builds a conversation fetchEvents payload.
-func ConversationEventData(operation string, conversations []ConversationEntryData) *ConversationData {
-	if conversations == nil {
-		conversations = []ConversationEntryData{}
-	}
-	return &ConversationData{
-		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) ConversationEntryData {
-	entry := ConversationEntryData{
-		AimID:       aimID,
-		Active:      0,
-		UnreadCount: unread,
-		DisplayID:   displayID,
-	}
-	if message != "" {
-		entry.LastIM = &LastIM{
-			Message:   message,
-			MsgID:     msgID,
-			Sender:    sender,
-			Sent:      sent,
-			Timestamp: float64(time.Now().Unix()),
-		}
-	}
-	return entry
-}

+ 6 - 7
server/webapi/handlers/xml_shape_test.go → server/webapi/xml_shape_test.go

@@ -1,4 +1,4 @@
-package handlers
+package webapi
 
 import (
 	"encoding/xml"
@@ -9,7 +9,6 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
-	"github.com/mk6i/open-oscar-server/server/webapi/types"
 	"github.com/mk6i/open-oscar-server/state"
 )
 
@@ -61,9 +60,9 @@ func TestXMLAlwaysRendersData(t *testing.T) {
 // derived from the container's name (allows holds allow, not user).
 func TestXMLItemNamesMatchSpec(t *testing.T) {
 	t.Run("buddy list", func(t *testing.T) {
-		body := renderXML(t, &BuddyListData{Groups: []WebAPIBuddyGroup{{
+		body := renderXML(t, &BuddyListData{Groups: []BuddyGroup{{
 			Name: "Friends",
-			Buddies: []WebAPIBuddyInfo{{
+			Buddies: []BuddyInfo{{
 				AimID:        "chattingchuck",
 				DisplayID:    "ChattingChuck",
 				State:        "away",
@@ -102,11 +101,11 @@ func TestXMLItemNamesMatchSpec(t *testing.T) {
 
 	t.Run("fetch events", func(t *testing.T) {
 		body := renderXML(t, &FetchEventsData{
-			Events: []types.Event{{
-				Type:      types.EventTypeTyping,
+			Events: []Event{{
+				Type:      EventTypeTyping,
 				SeqNum:    7,
 				Timestamp: 100,
-				Data:      types.TypingEvent{AimID: "chattingchuck", TypingStatus: "typing"},
+				Data:      TypingEvent{AimID: "chattingchuck", TypingStatus: "typing"},
 			}},
 			LastSeqNum: 7,
 		})

+ 0 - 133
state/webapi_imlog.go

@@ -1,133 +0,0 @@
-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,
-	})
-}
-
-// StoredIM is one entry in a fetchStoredIMs reply.
-//
-// Date is a float because AMF3 encodes whole numbers in 29 bits, which a Unix
-// timestamp overflows.
-type StoredIM struct {
-	Sender  string  `json:"sender" xml:"sender"`
-	Message string  `json:"message" xml:"message"`
-	MsgID   string  `json:"msgId" xml:"msgId"`
-	Date    float64 `json:"date" xml:"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) []StoredIM {
-	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 []StoredIM{}
-	}
-
-	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([]StoredIM, len(filtered))
-	for i, msg := range filtered {
-		out[i] = StoredIM{
-			Sender:  msg.Sender,
-			Message: msg.Message,
-			MsgID:   msg.MsgID,
-			Date:    float64(msg.Date),
-		}
-	}
-	return out
-}
-
-// normalizeWebAPIAimID keys the IM log by the same normalization the web client
-// applies to aimIds, so a partner stored from a display screen name is still
-// found when the client queries by aimId.
-func normalizeWebAPIAimID(aimID string) string {
-	return NewIdentScreenName(aimID).String()
-}

+ 0 - 48
state/webapi_imlog_test.go

@@ -1,48 +0,0 @@
-package state
-
-import (
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-)
-
-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)
-}
-
-func TestWebAPISession_GetStoredIMs_NormalizesPartner(t *testing.T) {
-	sess := &WebAPISession{}
-	sess.AddStoredIM("Mike Kelly", "mikekelly", "hello", "msg-1", 100)
-
-	// The web client queries history by the normalized aimId, never by the
-	// display screen name it was stored under.
-	msgs := sess.GetStoredIMs(StoredIMQuery{
-		PartnerAimID: "mikekelly",
-		NToGet:       10,
-	})
-	require.Len(t, msgs, 1)
-	assert.Equal(t, "msg-1", msgs[0].MsgID)
-}

Неке датотеке нису приказане због велике количине промена