Procházet zdrojové kódy

webapi: move redundant session checking logic to middleware

Mike před 2 týdny
rodič
revize
94b50200d8

+ 10 - 15
server/webapi/handler.go

@@ -11,20 +11,16 @@ import (
 )
 
 type Handler struct {
-	AuthService       AuthService
-	BuddyListRegistry BuddyListRegistry
-	CookieBaker       CookieBaker
-	ICBMService       ICBMService
-	LocateService     LocateService
-	Logger            *slog.Logger
-	OServiceService   OServiceService
-	// New fields for WebAPI handlers
-	SessionRetriever SessionRetriever
-	// Phase 2 additions
-	BuddyBroadcaster BuddyBroadcaster
-	// Phase 4 additions for OSCAR Bridge
-	OSCARConfig OSCARConfig
-	// Phase 5 additions for buddy list and messaging
+	AuthService        AuthService
+	BuddyListRegistry  BuddyListRegistry
+	CookieBaker        CookieBaker
+	ICBMService        ICBMService
+	LocateService      LocateService
+	Logger             *slog.Logger
+	OServiceService    OServiceService
+	SessionRetriever   SessionRetriever
+	BuddyBroadcaster   BuddyBroadcaster
+	OSCARConfig        OSCARConfig
 	BuddyListManager   interface{}
 	RecalcWarning      func(ctx context.Context, instance *state.SessionInstance) error
 	LowerWarnLevel     func(ctx context.Context, instance *state.SessionInstance)
@@ -33,7 +29,6 @@ type Handler struct {
 }
 
 func (h Handler) GetHelloWorldHandler(w http.ResponseWriter, r *http.Request) {
-	h.Logger.Info("got a request to the root endpoint", "method", r.Method, "path", r.URL.Path)
 	_, _ = 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)

+ 0 - 24
server/webapi/handlers/buddy_list_manager.go

@@ -230,10 +230,6 @@ func (m *BuddyListManager) RemoveBuddyFromFeedbag(ctx context.Context, sess *sta
 		return "error", fmt.Errorf("empty buddy")
 	}
 
-	if sess.OSCARSession == nil {
-		return "error", fmt.Errorf("no OSCAR session")
-	}
-
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
 	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
 	if err != nil {
@@ -287,10 +283,6 @@ func (m *BuddyListManager) RemoveGroupFromFeedbag(ctx context.Context, sess *sta
 	if req == "" {
 		return "error", fmt.Errorf("empty group")
 	}
-	if sess.OSCARSession == nil {
-		return "error", fmt.Errorf("no OSCAR session")
-	}
-
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
 	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
 	if err != nil {
@@ -339,10 +331,6 @@ func (m *BuddyListManager) RenameGroupInFeedbag(ctx context.Context, sess *state
 	if oldGroup == "" || newGroup == "" {
 		return "error", fmt.Errorf("empty group name")
 	}
-	if sess.OSCARSession == nil {
-		return "error", fmt.Errorf("no OSCAR session")
-	}
-
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
 	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
 	if err != nil {
@@ -393,10 +381,6 @@ func (m *BuddyListManager) MoveBuddyInFeedbag(ctx context.Context, sess *state.W
 	if buddyName == "" || fromGroup == "" {
 		return "error", fmt.Errorf("empty buddy or group")
 	}
-	if sess.OSCARSession == nil {
-		return "error", fmt.Errorf("no OSCAR session")
-	}
-
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
 	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
 	if err != nil {
@@ -473,10 +457,6 @@ func (m *BuddyListManager) SetBuddyAttributeInFeedbag(ctx context.Context, sess
 	if buddyName == "" {
 		return "error", fmt.Errorf("empty buddy")
 	}
-	if sess.OSCARSession == nil {
-		return "error", fmt.Errorf("no OSCAR session")
-	}
-
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
 	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
 	if err != nil {
@@ -513,10 +493,6 @@ func (m *BuddyListManager) SetBuddyAttributeInFeedbag(ctx context.Context, sess
 // targets the unnamed default group.
 func (m *BuddyListManager) SetGroupAttributeInFeedbag(ctx context.Context, sess *state.WebAPISession, groupName string, collapsed bool) (resultCode string, err error) {
 	groupName = strings.TrimSpace(groupName)
-	if sess.OSCARSession == nil {
-		return "error", fmt.Errorf("no OSCAR session")
-	}
-
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
 	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
 	if err != nil {

+ 0 - 56
server/webapi/handlers/buddylist.go

@@ -12,67 +12,11 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-// WebAPISessionManager provides methods to manage WebAPI sessions.
-type WebAPISessionManager interface {
-	GetSession(ctx context.Context, aimsid string) (*state.WebAPISession, error)
-	TouchSession(ctx context.Context, aimsid string) error
-}
-
 // BuddyListHandler handles Web AIM API buddy list management endpoints.
 type BuddyListHandler struct {
-	SessionManager   WebAPISessionManager
 	BuddyListManager *BuddyListManager
 	Logger           *slog.Logger
 	FeedbagService   FeedbagService
-	mux              *http.ServeMux
-}
-
-func NewBuddyListHandler(sessionManager WebAPISessionManager, blm *BuddyListManager, logger *slog.Logger, feedbagService FeedbagService) *BuddyListHandler {
-	h := &BuddyListHandler{
-		SessionManager:   sessionManager,
-		BuddyListManager: blm,
-		Logger:           logger,
-		FeedbagService:   feedbagService,
-	}
-	m := http.NewServeMux()
-	m.Handle("GET /buddylist/addBuddy", h.SessionMiddleware(h.AddBuddy))
-	m.Handle("GET /buddylist/addGroup", h.SessionMiddleware(h.AddGroup))
-	m.Handle("GET /buddylist/removeBuddy", h.SessionMiddleware(h.RemoveBuddy))
-	m.Handle("GET /buddylist/removeGroup", h.SessionMiddleware(h.RemoveGroup))
-	m.Handle("GET /buddylist/renameGroup", h.SessionMiddleware(h.RenameGroup))
-	m.Handle("GET /buddylist/moveBuddy", h.SessionMiddleware(h.MoveBuddy))
-	m.Handle("GET /buddylist/setBuddyAttribute", h.SessionMiddleware(h.SetBuddyAttribute))
-	m.Handle("GET /buddylist/setGroupAttribute", h.SessionMiddleware(h.SetGroupAttribute))
-	h.mux = m
-	return h
-}
-
-func (h *BuddyListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	h.mux.ServeHTTP(w, r)
-}
-
-func (h *BuddyListHandler) SessionMiddleware(next func(http.ResponseWriter, *http.Request, *state.WebAPISession)) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		aimsid := r.URL.Query().Get("aimsid")
-		if aimsid == "" {
-			h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
-			return
-		}
-		session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-		if err != nil {
-			switch err {
-			case state.ErrNoWebAPISession:
-				h.sendError(w, http.StatusNotFound, "session not found")
-			case state.ErrWebAPISessionExpired:
-				h.sendError(w, http.StatusGone, "session expired")
-			default:
-				h.sendError(w, http.StatusInternalServerError, "internal server error")
-			}
-			return
-		}
-		_ = h.SessionManager.TouchSession(r.Context(), aimsid)
-		next(w, r, session)
-	})
 }
 
 type FeedbagService interface {

+ 28 - 18
server/webapi/handlers/buddylist_test.go

@@ -14,6 +14,7 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 
+	"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"
@@ -380,7 +381,6 @@ func TestBuddyListHandler_AddBuddy(t *testing.T) {
 			logger := slog.Default()
 
 			handler := &BuddyListHandler{
-				SessionManager:   sessionManager,
 				FeedbagService:   feedbagService,
 				BuddyListManager: blm,
 				Logger:           logger,
@@ -523,7 +523,6 @@ func TestBuddyListHandler_AddGroup(t *testing.T) {
 			session := tt.setup(sm, fs, blmFs, aimsid)
 
 			handler := &BuddyListHandler{
-				SessionManager:   sm,
 				FeedbagService:   fs,
 				BuddyListManager: blm,
 				Logger:           slog.Default(),
@@ -681,7 +680,6 @@ func TestBuddyListHandler_RemoveBuddy(t *testing.T) {
 			session := tt.setup(sm, blm, fs, aimsid)
 
 			handler := &BuddyListHandler{
-				SessionManager:   sm,
 				BuddyListManager: blm,
 				Logger:           slog.Default(),
 			}
@@ -827,7 +825,6 @@ func TestBuddyListHandler_RemoveGroup(t *testing.T) {
 			session := tt.setup(sm, blm, fs, aimsid)
 
 			handler := &BuddyListHandler{
-				SessionManager:   sm,
 				BuddyListManager: blm,
 				Logger:           slog.Default(),
 			}
@@ -849,7 +846,7 @@ func TestBuddyListHandler_RemoveGroup(t *testing.T) {
 	}
 }
 
-func TestBuddyListHandler_sessionMiddleware(t *testing.T) {
+func TestRequireSession(t *testing.T) {
 	tests := []struct {
 		name               string
 		aimsid             string
@@ -872,8 +869,8 @@ func TestBuddyListHandler_sessionMiddleware(t *testing.T) {
 			setupMocks: func(sm *MockWebAPISessionManager, aimsid string) {
 				sm.On("GetSession", mock.Anything, aimsid).Return(nil, state.ErrNoWebAPISession)
 			},
-			expectedStatusCode: http.StatusNotFound,
-			expectedResponse:   `{"response":{"statusCode":404,"statusText":"session not found"}}`,
+			expectedStatusCode: http.StatusUnauthorized,
+			expectedResponse:   `{"response":{"statusCode":401,"statusText":"invalid or expired session"}}`,
 			expectNextCalled:   false,
 		},
 		{
@@ -882,8 +879,8 @@ func TestBuddyListHandler_sessionMiddleware(t *testing.T) {
 			setupMocks: func(sm *MockWebAPISessionManager, aimsid string) {
 				sm.On("GetSession", mock.Anything, aimsid).Return(nil, state.ErrWebAPISessionExpired)
 			},
-			expectedStatusCode: http.StatusGone,
-			expectedResponse:   `{"response":{"statusCode":410,"statusText":"session expired"}}`,
+			expectedStatusCode: http.StatusUnauthorized,
+			expectedResponse:   `{"response":{"statusCode":401,"statusText":"invalid or expired session"}}`,
 			expectNextCalled:   false,
 		},
 		{
@@ -892,8 +889,24 @@ func TestBuddyListHandler_sessionMiddleware(t *testing.T) {
 			setupMocks: func(sm *MockWebAPISessionManager, aimsid string) {
 				sm.On("GetSession", mock.Anything, aimsid).Return(nil, errors.New("db error"))
 			},
-			expectedStatusCode: http.StatusInternalServerError,
-			expectedResponse:   `{"response":{"statusCode":500,"statusText":"internal server error"}}`,
+			expectedStatusCode: http.StatusUnauthorized,
+			expectedResponse:   `{"response":{"statusCode":401,"statusText":"invalid or expired session"}}`,
+			expectNextCalled:   false,
+		},
+		{
+			name:   "Error_AnonymousSession",
+			aimsid: "anon-session",
+			setupMocks: func(sm *MockWebAPISessionManager, aimsid string) {
+				sess := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("Guest_abc"),
+					LastAccessed: time.Now(),
+					// OSCARSession is nil - anonymous session.
+				}
+				sm.On("GetSession", mock.Anything, aimsid).Return(sess, nil)
+			},
+			expectedStatusCode: http.StatusUnauthorized,
+			expectedResponse:   `{"response":{"statusCode":401,"statusText":"invalid or expired session"}}`,
 			expectNextCalled:   false,
 		},
 		{
@@ -903,6 +916,7 @@ func TestBuddyListHandler_sessionMiddleware(t *testing.T) {
 				sess := &state.WebAPISession{
 					AimSID:       aimsid,
 					ScreenName:   state.DisplayScreenName("testuser"),
+					OSCARSession: state.NewSession().AddInstance(),
 					LastAccessed: time.Now(),
 				}
 				sm.On("GetSession", mock.Anything, aimsid).Return(sess, nil)
@@ -919,21 +933,17 @@ func TestBuddyListHandler_sessionMiddleware(t *testing.T) {
 			sm := &MockWebAPISessionManager{}
 			tt.setupMocks(sm, tt.aimsid)
 
-			handler := &BuddyListHandler{
-				SessionManager: sm,
-				Logger:         slog.Default(),
-			}
-
 			nextCalled := false
 			next := func(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
 				nextCalled = true
 				resp := BaseResponse{}
 				resp.Response.StatusCode = 200
 				resp.Response.StatusText = "OK"
-				SendResponse(w, r, resp, handler.Logger)
+				SendResponse(w, r, resp, slog.Default())
 			}
 
-			wrapped := handler.SessionMiddleware(next)
+			authMiddleware := middleware.NewAuthMiddleware(nil, slog.Default())
+			wrapped := authMiddleware.RequireSession(sm, next)
 
 			reqURL := "/buddylist/test"
 			if tt.aimsid != "" {

+ 1 - 17
server/webapi/handlers/conversation_stub.go

@@ -38,23 +38,7 @@ func (h *ConversationStubHandler) MarkRead(w http.ResponseWriter, r *http.Reques
 }
 
 // FetchStoredIMs returns stored IM history for a conversation partner.
-func (h *ConversationStubHandler) FetchStoredIMs(w http.ResponseWriter, r *http.Request) {
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		SendError(w, http.StatusBadRequest, "missing required parameter: aimsid")
-		return
-	}
-
-	sess, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		if err == state.ErrNoWebAPISession || err == state.ErrWebAPISessionExpired {
-			SendError(w, http.StatusUnauthorized, "invalid or expired session")
-		} else {
-			SendError(w, http.StatusInternalServerError, "internal server error")
-		}
-		return
-	}
-
+func (h *ConversationStubHandler) FetchStoredIMs(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
 	partner := r.URL.Query().Get("to")
 	if partner == "" {
 		SendError(w, http.StatusBadRequest, "missing required parameter: to")

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

@@ -51,32 +51,9 @@ type FetchEventsXMLResponse struct {
 }
 
 // FetchEvents handles GET /aim/fetchEvents requests with long-polling support.
-func (h *EventsHandler) FetchEvents(w http.ResponseWriter, r *http.Request) {
+func (h *EventsHandler) FetchEvents(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
 	ctx := r.Context()
-
-	// Get session ID from parameters
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
-		return
-	}
-
-	// Get session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		switch err {
-		case state.ErrNoWebAPISession:
-			h.sendError(w, http.StatusNotFound, "session not found")
-		case state.ErrWebAPISessionExpired:
-			h.sendError(w, http.StatusGone, "session expired")
-		default:
-			h.sendError(w, http.StatusInternalServerError, "internal server error")
-		}
-		return
-	}
-
-	// Touch the session to update last accessed time
-	_ = h.SessionManager.TouchSession(r.Context(), aimsid)
+	aimsid := session.AimSID
 
 	// Get sequence number parameter
 	var lastSeqNum uint64

+ 2 - 60
server/webapi/handlers/messaging.go

@@ -43,31 +43,9 @@ 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) {
+func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
 	ctx := r.Context()
 
-	// Get session from aimsid
-	aimsid := queryOrFormParam(r, "aimsid")
-	if aimsid == "" {
-		h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: aimsid")
-		return
-	}
-
-	sess, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		if err == state.ErrNoWebAPISession || err == state.ErrWebAPISessionExpired {
-			h.sendErrorResponse(w, http.StatusUnauthorized, "invalid or expired session")
-		} else {
-			h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
-		}
-		return
-	}
-
-	// Update session activity
-	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
-		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
-	}
-
 	// Parse parameters
 	recipient := queryOrFormParam(r, "t")
 	if recipient == "" {
@@ -98,20 +76,6 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request) {
 	}
 	cookieUint64 := binary.BigEndian.Uint64(cookie[:])
 
-	// Get sender's OSCAR session if available
-	var senderInfo wire.TLVUserInfo
-	if sess.OSCARSession != nil {
-		senderInfo = sess.OSCARSession.Session().TLVUserInfo()
-	} else {
-		// Create minimal user info for web-only sessions
-		senderInfo = wire.TLVUserInfo{
-			ScreenName:   sess.ScreenName.String(),
-			WarningLevel: 0,
-		}
-		senderInfo.Append(wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(sess.CreatedAt.Unix())))
-		senderInfo.Append(wire.NewTLVBE(wire.OServiceUserInfoStatus, uint32(0x0000))) // online status
-	}
-
 	// Create message ID for response (UUID format like working implementation)
 	// Using the cookie bytes to generate a UUID-like string
 	messageID := fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
@@ -263,31 +227,9 @@ func (h *MessagingHandler) sendErrorResponse(w http.ResponseWriter, statusCode i
 }
 
 // SetTyping handles the /im/setTyping endpoint for typing indicators
-func (h *MessagingHandler) SetTyping(w http.ResponseWriter, r *http.Request) {
+func (h *MessagingHandler) SetTyping(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
 	ctx := r.Context()
 
-	// Get session from aimsid
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: aimsid")
-		return
-	}
-
-	sess, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		if err == state.ErrNoWebAPISession || err == state.ErrWebAPISessionExpired {
-			h.sendErrorResponse(w, http.StatusUnauthorized, "invalid or expired session")
-		} else {
-			h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
-		}
-		return
-	}
-
-	// Update session activity
-	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
-		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
-	}
-
 	// Parse parameters
 	recipient := r.URL.Query().Get("t")
 	if recipient == "" {

+ 14 - 8
server/webapi/handlers/messaging_test.go

@@ -12,10 +12,16 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 
+	"github.com/mk6i/open-oscar-server/server/webapi/middleware"
 	"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)
+}
+
 // MockICBMService is a mock implementation of ICBMService
 type MockICBMService struct {
 	mock.Mock
@@ -117,7 +123,7 @@ func TestMessagingHandler_SendIM(t *testing.T) {
 
 			rr := httptest.NewRecorder()
 
-			handler.SendIM(rr, req)
+			requireSession(handler.SessionManager, handler.SendIM).ServeHTTP(rr, req)
 
 			assert.Equal(t, tt.expectedStatusCode, rr.Code)
 
@@ -152,7 +158,7 @@ func TestMessagingHandler_SendIM_POST(t *testing.T) {
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 
 	rr := httptest.NewRecorder()
-	handler.SendIM(rr, req)
+	requireSession(handler.SessionManager, handler.SendIM).ServeHTTP(rr, req)
 
 	assert.Equal(t, http.StatusOK, rr.Code)
 	assert.Contains(t, rr.Body.String(), `"msgId"`)
@@ -169,10 +175,10 @@ func TestMessagingHandler_SendIM_MissingAimsid(t *testing.T) {
 	assert.NoError(t, err)
 
 	rr := httptest.NewRecorder()
-	handler.SendIM(rr, req)
+	requireSession(handler.SessionManager, handler.SendIM).ServeHTTP(rr, req)
 
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
-	assert.Contains(t, rr.Body.String(), "missing required parameter: aimsid")
+	assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
 }
 
 func TestMessagingHandler_SendIM_InvalidSession(t *testing.T) {
@@ -185,7 +191,7 @@ func TestMessagingHandler_SendIM_InvalidSession(t *testing.T) {
 	assert.NoError(t, err)
 
 	rr := httptest.NewRecorder()
-	handler.SendIM(rr, req)
+	requireSession(handler.SessionManager, handler.SendIM).ServeHTTP(rr, req)
 
 	assert.Equal(t, http.StatusUnauthorized, rr.Code)
 	assert.Contains(t, rr.Body.String(), "invalid or expired session")
@@ -271,7 +277,7 @@ func TestMessagingHandler_SetTyping(t *testing.T) {
 
 			rr := httptest.NewRecorder()
 
-			handler.SetTyping(rr, req)
+			requireSession(handler.SessionManager, handler.SetTyping).ServeHTTP(rr, req)
 
 			assert.Equal(t, tt.expectedStatusCode, rr.Code)
 
@@ -295,8 +301,8 @@ func TestMessagingHandler_SetTyping_MissingAimsid(t *testing.T) {
 	assert.NoError(t, err)
 
 	rr := httptest.NewRecorder()
-	handler.SetTyping(rr, req)
+	requireSession(handler.SessionManager, handler.SetTyping).ServeHTTP(rr, req)
 
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
-	assert.Contains(t, rr.Body.String(), "missing required parameter: aimsid")
+	assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
 }

+ 9 - 91
server/webapi/handlers/preference.go

@@ -118,35 +118,12 @@ type PermitDenyData struct {
 }
 
 // SetPreferences handles GET /preference/set requests to update user preferences.
-func (h *PreferenceHandler) SetPreferences(w http.ResponseWriter, r *http.Request) {
+func (h *PreferenceHandler) SetPreferences(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
 	ctx := r.Context()
 
-	// Get session ID from parameters
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
-		return
-	}
-
-	// Get session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		h.sendError(w, http.StatusUnauthorized, "invalid or expired session")
-		return
-	}
-
-	// Update session activity
-	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
-		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
-	}
-
 	// Preferences are stored as OSCAR buddy prefs in the feedbag, which requires
 	// an OSCAR session to act on behalf of.
 	instance := session.OSCARSession
-	if instance == nil {
-		h.sendError(w, http.StatusBadRequest, "no OSCAR session")
-		return
-	}
 
 	// Read-modify-write the buddy-prefs item so bits the web client doesn't
 	// manage (e.g. the typing-events bit consumed by the OSCAR session) survive.
@@ -196,37 +173,16 @@ func (h *PreferenceHandler) SetPreferences(w http.ResponseWriter, r *http.Reques
 }
 
 // GetPreferences handles GET /preference/get requests to retrieve user preferences.
-func (h *PreferenceHandler) GetPreferences(w http.ResponseWriter, r *http.Request) {
+func (h *PreferenceHandler) GetPreferences(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
 	ctx := r.Context()
 
-	// Get session ID from parameters
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
-		return
-	}
-
-	// Get session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		h.sendError(w, http.StatusUnauthorized, "invalid or expired session")
-		return
-	}
-
-	// Update session activity
-	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
-		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
-	}
-
 	// Load the buddy-prefs bitmask from the feedbag. Absent prefs fall back to
-	// the spec default. Web-only sessions resolve entirely to defaults.
+	// the spec default.
 	var prefsList wire.TLVList
-	if instance := session.OSCARSession; instance != nil {
-		if item, err := buddyPrefsItem(ctx, h.FeedbagService, instance); err != nil {
-			h.Logger.WarnContext(ctx, "failed to get preferences", "err", err.Error())
-		} else {
-			prefsList = item.TLVList
-		}
+	if item, err := buddyPrefsItem(ctx, h.FeedbagService, session.OSCARSession); err != nil {
+		h.Logger.WarnContext(ctx, "failed to get preferences", "err", err.Error())
+	} else {
+		prefsList = item.TLVList
 	}
 
 	// When specific preferences are named in the query (e.g. playIMSound=1), the
@@ -349,28 +305,9 @@ 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) {
+func (h *PreferenceHandler) SetPermitDeny(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
 	ctx := r.Context()
 
-	// Get session ID from parameters
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
-		return
-	}
-
-	// Get session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		h.sendError(w, http.StatusUnauthorized, "invalid or expired session")
-		return
-	}
-
-	// Update session activity
-	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
-		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
-	}
-
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
 	fb, err := h.FeedbagService.Query(r.Context(), session.OSCARSession, frame)
 	if err != nil {
@@ -511,28 +448,9 @@ 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) {
+func (h *PreferenceHandler) GetPermitDeny(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
 	ctx := r.Context()
 
-	// Get session ID from parameters
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
-		return
-	}
-
-	// Get session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		h.sendError(w, http.StatusUnauthorized, "invalid or expired session")
-		return
-	}
-
-	// Update session activity
-	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
-		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
-	}
-
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
 	fb, err := h.FeedbagService.Query(r.Context(), session.OSCARSession, frame)
 	if err != nil {

+ 9 - 8
server/webapi/handlers/preference_test.go

@@ -4,7 +4,6 @@ import (
 	"log/slog"
 	"net/http"
 	"net/http/httptest"
-	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -54,7 +53,7 @@ func TestPreferenceHandler_SetPreferences(t *testing.T) {
 
 	req, _ := http.NewRequest("GET", "/preference/set?aimsid="+aimsid+"&playIMSound=0&discloseTyping=1", nil)
 	rr := httptest.NewRecorder()
-	handler.SetPreferences(rr, req)
+	requireSession(handler.SessionManager, handler.SetPreferences).ServeHTTP(rr, req)
 
 	assert.Equal(t, http.StatusOK, rr.Code)
 	if assert.Len(t, upserted, 1) {
@@ -89,7 +88,7 @@ func TestPreferenceHandler_GetPreferences_Selected(t *testing.T) {
 	// Request two prefs: playIMSound (stored=false) and acceptIcons (unset -> default true).
 	req, _ := http.NewRequest("GET", "/preference/get?aimsid="+aimsid+"&playIMSound&acceptIcons", nil)
 	rr := httptest.NewRecorder()
-	handler.GetPreferences(rr, req)
+	requireSession(handler.SessionManager, handler.GetPreferences).ServeHTTP(rr, req)
 
 	assert.Equal(t, http.StatusOK, rr.Code)
 	body := rr.Body.String()
@@ -116,7 +115,7 @@ func TestPreferenceHandler_GetPreferences_All(t *testing.T) {
 
 	req, _ := http.NewRequest("GET", "/preference/get?aimsid="+aimsid, nil)
 	rr := httptest.NewRecorder()
-	handler.GetPreferences(rr, req)
+	requireSession(handler.SessionManager, handler.GetPreferences).ServeHTTP(rr, req)
 
 	assert.Equal(t, http.StatusOK, rr.Code)
 	body := rr.Body.String()
@@ -141,7 +140,7 @@ func TestPreferenceHandler_GetPreferences_AMF(t *testing.T) {
 	// Single-pref AMF request returns a numeric value (not wrapped in jsonData).
 	req, _ := http.NewRequest("GET", "/preference/get?aimsid="+aimsid+"&f=amf&playIMSound", nil)
 	rr := httptest.NewRecorder()
-	handler.GetPreferences(rr, req)
+	requireSession(handler.SessionManager, handler.GetPreferences).ServeHTTP(rr, req)
 
 	assert.Equal(t, http.StatusOK, rr.Code)
 	body := rr.Body.String()
@@ -188,9 +187,11 @@ func TestPreferenceHandler_SetPreferences_NoOSCARSession(t *testing.T) {
 
 	req, _ := http.NewRequest("GET", "/preference/set?aimsid="+aimsid+"&playIMSound=1", nil)
 	rr := httptest.NewRecorder()
-	handler.SetPreferences(rr, req)
+	requireSession(handler.SessionManager, handler.SetPreferences).ServeHTTP(rr, req)
 
-	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	// Anonymous (nil OSCAR) sessions are rejected by the session middleware
+	// before the handler runs, so no feedbag lookup occurs.
+	assert.Equal(t, http.StatusUnauthorized, rr.Code)
+	assert.Contains(t, rr.Body.String(), "invalid or expired session")
 	fs.AssertNotCalled(t, "Query", mock.Anything, mock.Anything, mock.Anything)
-	_ = strings.TrimSpace(rr.Body.String())
 }

+ 21 - 145
server/webapi/handlers/presence.go

@@ -65,34 +65,9 @@ type BuddyPresenceInfo struct {
 }
 
 // GetPresence handles GET /presence/get requests.
-func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request) {
+func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
 	ctx := r.Context()
-
-	// Get session ID from parameters
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
-		return
-	}
-
-	// Get session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		switch err {
-		case state.ErrNoWebAPISession:
-			h.sendError(w, http.StatusNotFound, "session not found")
-		case state.ErrWebAPISessionExpired:
-			h.sendError(w, http.StatusGone, "session expired")
-		default:
-			h.sendError(w, http.StatusInternalServerError, "internal server error")
-		}
-		return
-	}
-
-	// Touch the session
-	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
-		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
-	}
+	aimsid := session.AimSID
 
 	// Check if buddy list is requested
 	getBuddyList := r.URL.Query().Get("bl") == "1"
@@ -317,28 +292,9 @@ func (h *PresenceHandler) sendError(w http.ResponseWriter, statusCode int, messa
 }
 
 // SetState handles GET /presence/setState requests to update user's presence state.
-func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request) {
+func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
 	ctx := r.Context()
 
-	// Get session ID from parameters
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
-		return
-	}
-
-	// Get session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		h.sendError(w, http.StatusUnauthorized, "invalid or expired session")
-		return
-	}
-
-	// Update session activity
-	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
-		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
-	}
-
 	stateParam := r.URL.Query().Get("state")
 	if stateParam == "" {
 		stateParam = r.URL.Query().Get("view")
@@ -348,20 +304,7 @@ func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request) {
 		awayMsg = r.URL.Query().Get("away")
 	}
 
-	// Get OSCAR session if available
 	oscarSession := session.OSCARSession
-	if oscarSession == nil {
-		// For web-only sessions, we'll need to track state in the WebAPI session
-		// For now, just store in event data
-		h.Logger.WarnContext(ctx, "no OSCAR session for presence update", "aimsid", aimsid)
-
-		// Still send success response
-		response := BaseResponse{}
-		response.Response.StatusCode = 200
-		response.Response.StatusText = "OK"
-		SendResponse(w, r, response, h.Logger)
-		return
-	}
 
 	// Map web state to OSCAR status bits
 	var statusBitmask uint32
@@ -427,28 +370,9 @@ func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request) {
 }
 
 // SetStatus handles GET /presence/setStatus requests to update user's status message.
-func (h *PresenceHandler) SetStatus(w http.ResponseWriter, r *http.Request) {
+func (h *PresenceHandler) SetStatus(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
 	ctx := r.Context()
 
-	// Get session ID from parameters
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
-		return
-	}
-
-	// Get session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		h.sendError(w, http.StatusUnauthorized, "invalid or expired session")
-		return
-	}
-
-	// Update session activity
-	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
-		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
-	}
-
 	// Get the status message
 	statusMsg := r.URL.Query().Get("statusMsg")
 	statusCode := r.URL.Query().Get("statusCode")
@@ -456,15 +380,13 @@ func (h *PresenceHandler) SetStatus(w http.ResponseWriter, r *http.Request) {
 	// Store status message in session (this would normally be stored in a profile/status service)
 	// For now, we'll broadcast it as part of presence
 
-	// Get OSCAR session if available
-	if oscarSession := session.OSCARSession; oscarSession != nil {
-		// In OSCAR, status messages are typically part of the profile
-		// We'll need to extend this based on the actual implementation
+	oscarSession := session.OSCARSession
+	// In OSCAR, status messages are typically part of the profile
+	// We'll need to extend this based on the actual implementation
 
-		// Broadcast presence update with new status
-		if err := h.BuddyBroadcaster.BroadcastBuddyArrived(ctx, oscarSession.IdentScreenName(), oscarSession.Session().TLVUserInfo()); err != nil {
-			h.Logger.ErrorContext(ctx, "failed to broadcast status update", "err", err.Error())
-		}
+	// Broadcast presence update with new status
+	if err := h.BuddyBroadcaster.BroadcastBuddyArrived(ctx, oscarSession.IdentScreenName(), oscarSession.Session().TLVUserInfo()); err != nil {
+		h.Logger.ErrorContext(ctx, "failed to broadcast status update", "err", err.Error())
 	}
 
 	// Queue status event for other WebAPI sessions
@@ -484,28 +406,9 @@ func (h *PresenceHandler) SetStatus(w http.ResponseWriter, r *http.Request) {
 }
 
 // SetProfile handles GET /presence/setProfile requests to update user's profile.
-func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request) {
+func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
 	ctx := r.Context()
 
-	// Get session ID from parameters
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
-		return
-	}
-
-	// Get session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		h.sendError(w, http.StatusUnauthorized, "invalid or expired session")
-		return
-	}
-
-	// Update session activity
-	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
-		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
-	}
-
 	// Get the profile content
 	profileText := r.URL.Query().Get("profile")
 
@@ -515,13 +418,7 @@ func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// Web-only sessions have no OSCAR instance to set info on behalf of.
 	instance := session.OSCARSession
-	if instance == nil {
-		h.Logger.WarnContext(ctx, "no OSCAR session for profile update", "aimsid", aimsid)
-		h.sendError(w, http.StatusBadRequest, "no OSCAR session")
-		return
-	}
 
 	// Save profile via OSCAR LocateService.
 	setInfo := wire.SNAC_0x02_0x04_LocateSetInfo{
@@ -550,46 +447,25 @@ func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request) {
 }
 
 // GetProfile handles GET /presence/getProfile requests to retrieve user's profile.
-func (h *PresenceHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
+func (h *PresenceHandler) GetProfile(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
 	ctx := r.Context()
 
-	// Get session ID from parameters
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
-		return
-	}
-
-	// Get session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		h.sendError(w, http.StatusUnauthorized, "invalid or expired session")
-		return
-	}
-
-	// Update session activity
-	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
-		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
-	}
-
 	// Get target screen name (optional - defaults to self)
 	targetSN := r.URL.Query().Get("sn")
 	if targetSN == "" {
 		targetSN = session.ScreenName.String()
 	}
 
-	// Retrieve profile via OSCAR LocateService. Web-only sessions have no OSCAR
-	// instance to query on behalf of, so they resolve to an empty profile.
+	// Retrieve profile via OSCAR LocateService.
 	var profileText string
-	if instance := session.OSCARSession; instance != nil {
-		reply, err := h.LocateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},
-			wire.SNAC_0x02_0x05_LocateUserInfoQuery{Type: uint16(wire.LocateTypeSig), ScreenName: targetSN})
-		if err != nil {
-			h.Logger.WarnContext(ctx, "failed to get profile", "err", err.Error())
-		} else if info, ok := reply.Body.(wire.SNAC_0x02_0x06_LocateUserInfoReply); ok {
-			if prof, ok := info.LocateInfo.String(wire.LocateTLVTagsInfoSigData); ok {
-				profileText = prof
-			}
+	instance := session.OSCARSession
+	reply, err := h.LocateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},
+		wire.SNAC_0x02_0x05_LocateUserInfoQuery{Type: uint16(wire.LocateTypeSig), ScreenName: targetSN})
+	if err != nil {
+		h.Logger.WarnContext(ctx, "failed to get profile", "err", err.Error())
+	} else if info, ok := reply.Body.(wire.SNAC_0x02_0x06_LocateUserInfoReply); ok {
+		if prof, ok := info.LocateInfo.String(wire.LocateTLVTagsInfoSigData); ok {
+			profileText = prof
 		}
 	}
 

+ 20 - 19
server/webapi/handlers/presence_test.go

@@ -220,7 +220,7 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 
 			rr := httptest.NewRecorder()
 
-			handler.GetPresence(rr, req)
+			requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
 
 			assert.Equal(t, tt.expectedStatusCode, rr.Code)
 
@@ -272,7 +272,7 @@ func TestPresenceHandler_GetPresence_BuddyListGrouping(t *testing.T) {
 	req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&bl=1", nil)
 	assert.NoError(t, err)
 	rr := httptest.NewRecorder()
-	handler.GetPresence(rr, req)
+	requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
 
 	assert.Equal(t, http.StatusOK, rr.Code)
 
@@ -317,7 +317,7 @@ func TestPresenceHandler_GetPresence_MissingAimsid(t *testing.T) {
 	assert.NoError(t, err)
 
 	rr := httptest.NewRecorder()
-	handler.GetPresence(rr, req)
+	requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
 
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
@@ -333,10 +333,10 @@ func TestPresenceHandler_GetPresence_SessionNotFound(t *testing.T) {
 	assert.NoError(t, err)
 
 	rr := httptest.NewRecorder()
-	handler.GetPresence(rr, req)
+	requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
 
-	assert.Equal(t, http.StatusNotFound, rr.Code)
-	assert.Contains(t, rr.Body.String(), "session not found")
+	assert.Equal(t, http.StatusUnauthorized, rr.Code)
+	assert.Contains(t, rr.Body.String(), "invalid or expired session")
 }
 
 func TestPresenceHandler_SetState_MissingAimsid(t *testing.T) {
@@ -349,14 +349,15 @@ func TestPresenceHandler_SetState_MissingAimsid(t *testing.T) {
 	assert.NoError(t, err)
 
 	rr := httptest.NewRecorder()
-	handler.SetState(rr, req)
+	requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
 
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
 }
 
 func TestPresenceHandler_SetState_InvalidState(t *testing.T) {
-	sessionMgr, aimsid := createTestSessionManager("testuser")
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
 	handler := &PresenceHandler{
 		SessionManager: sessionMgr,
@@ -367,15 +368,15 @@ func TestPresenceHandler_SetState_InvalidState(t *testing.T) {
 	assert.NoError(t, err)
 
 	rr := httptest.NewRecorder()
-	handler.SetState(rr, req)
+	requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
 
-	// Web-only sessions (no OSCAR session) return 200 before checking state param
-	// because the handler returns early with success for web-only sessions
-	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "invalid state parameter")
 }
 
-func TestPresenceHandler_SetState_WebOnlySession(t *testing.T) {
-	// Web-only sessions (no OSCAR session) should return success
+func TestPresenceHandler_SetState_NoOSCARSession_Rejected(t *testing.T) {
+	// Anonymous (web-only, no OSCAR) sessions are rejected by the session
+	// middleware before the handler runs.
 	sessionMgr, aimsid := createTestSessionManager("testuser")
 
 	handler := &PresenceHandler{
@@ -387,10 +388,10 @@ func TestPresenceHandler_SetState_WebOnlySession(t *testing.T) {
 	assert.NoError(t, err)
 
 	rr := httptest.NewRecorder()
-	handler.SetState(rr, req)
+	requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
 
-	assert.Equal(t, http.StatusOK, rr.Code)
-	assert.Contains(t, rr.Body.String(), `"statusCode":200`)
+	assert.Equal(t, http.StatusUnauthorized, rr.Code)
+	assert.Contains(t, rr.Body.String(), "invalid or expired session")
 }
 
 func TestIsICQScreenName(t *testing.T) {
@@ -519,7 +520,7 @@ func TestPresenceHandler_SetProfile(t *testing.T) {
 
 			rr := httptest.NewRecorder()
 
-			handler.SetProfile(rr, req)
+			requireSession(handler.SessionManager, handler.SetProfile).ServeHTTP(rr, req)
 
 			assert.Equal(t, tt.expectedStatusCode, rr.Code)
 
@@ -560,7 +561,7 @@ func TestPresenceHandler_GetProfile(t *testing.T) {
 	assert.NoError(t, err)
 
 	rr := httptest.NewRecorder()
-	handler.GetProfile(rr, req)
+	requireSession(handler.SessionManager, handler.GetProfile).ServeHTTP(rr, req)
 
 	assert.Equal(t, http.StatusOK, rr.Code)
 	body := rr.Body.String()

+ 2 - 23
server/webapi/handlers/session.go

@@ -612,30 +612,9 @@ 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) {
+func (h *SessionHandler) EndSession(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
 	ctx := r.Context()
 
-	// Get session ID from parameters
-	aimsid := r.URL.Query().Get("aimsid")
-	if aimsid == "" {
-		h.sendError(w, r, http.StatusBadRequest, "missing aimsid parameter")
-		return
-	}
-
-	// Get session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
-	if err != nil {
-		switch err {
-		case state.ErrNoWebAPISession:
-			h.sendError(w, r, http.StatusNotFound, "session not found")
-		case state.ErrWebAPISessionExpired:
-			h.sendError(w, r, http.StatusGone, "session expired")
-		default:
-			h.sendError(w, r, http.StatusInternalServerError, "internal server error")
-		}
-		return
-	}
-
 	session.OSCARSession.CloseInstance()
 
 	// Send response
@@ -647,7 +626,7 @@ func (h *SessionHandler) EndSession(w http.ResponseWriter, r *http.Request) {
 	SendResponse(w, r, resp, h.Logger)
 
 	h.Logger.DebugContext(ctx, "session ended",
-		"aimsid", aimsid,
+		"aimsid", session.AimSID,
 		"screen_name", session.ScreenName,
 	)
 }

+ 42 - 0
server/webapi/middleware/auth.go

@@ -132,6 +132,48 @@ 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, expired, or anonymous (no bridged OSCAR
+// session) with an auth error, so downstream handlers can treat
+// session.OSCARSession as non-nil.
+func (m *AuthMiddleware) RequireSession(sm WebAPISessionResolver, next func(http.ResponseWriter, *http.Request, *state.WebAPISession)) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		aimsid := r.URL.Query().Get("aimsid")
+		if aimsid == "" {
+			m.sendSessionError(w, http.StatusBadRequest, "missing aimsid parameter")
+			return
+		}
+		session, err := sm.GetSession(r.Context(), aimsid)
+		if err != nil || session.OSCARSession == nil {
+			m.sendSessionError(w, http.StatusUnauthorized, "invalid or expired session")
+			return
+		}
+		_ = sm.TouchSession(r.Context(), aimsid)
+		next(w, r, session)
+	})
+}
+
+// sendSessionError writes a Web AIM API error envelope with the given HTTP status.
+func (m *AuthMiddleware) sendSessionError(w http.ResponseWriter, statusCode int, message string) {
+	body, err := json.Marshal(map[string]any{
+		"response": map[string]any{"statusCode": statusCode, "statusText": message},
+	})
+	if err != nil {
+		m.Logger.Error("failed to encode error response", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(statusCode)
+	_, _ = w.Write(body)
+}
+
 // 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) {

+ 36 - 34
server/webapi/server.go

@@ -17,10 +17,8 @@ import (
 func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyValidator middleware.APIKeyValidator, sessionManager *state.WebAPISessionManager) *Server {
 	servers := make([]*http.Server, 0, len(listeners))
 
-	// Create authentication middleware
 	authMiddleware := middleware.NewAuthMiddleware(apiKeyValidator, logger)
 
-	// Create handlers
 	authHandler := &handlers.AuthHandler{
 		AuthService: handler.AuthService,
 		CookieBaker: handler.CookieBaker,
@@ -55,28 +53,24 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		Logger:           logger,
 	}
 
-	buddyListHandler := handlers.NewBuddyListHandler(
-		sessionManager,
-		handler.BuddyListManager.(*handlers.BuddyListManager),
-		logger,
-		handler.FeedbagService,
-	)
+	buddyListHandler := &handlers.BuddyListHandler{
+		BuddyListManager: handler.BuddyListManager.(*handlers.BuddyListManager),
+		Logger:           logger,
+		FeedbagService:   handler.FeedbagService,
+	}
 
-	// Phase 2: Messaging handler
 	messagingHandler := &handlers.MessagingHandler{
 		SessionManager: sessionManager,
 		ICBMService:    handler.ICBMService,
 		Logger:         logger,
 	}
 
-	// Phase 3: Preference handler
 	preferenceHandler := &handlers.PreferenceHandler{
 		SessionManager: sessionManager,
 		FeedbagService: handler.FeedbagService,
 		Logger:         logger,
 	}
 
-	// Phase 4: OSCAR Bridge handler
 	oscarBridgeHandler := &handlers.OSCARBridgeHandler{
 		SessionManager:   sessionManager,
 		OSCARAuthService: handler.AuthService,
@@ -137,21 +131,21 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		// End session - uses aimsid for auth, no k required
 		mux.Handle("GET /aim/endSession", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(sessionHandler.EndSession))))
+				authMiddleware.RequireSession(sessionManager, sessionHandler.EndSession))))
 
 		// Event fetching - uses aimsid for auth, no k required
 		mux.Handle("GET /aim/fetchEvents", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(eventsHandler.FetchEvents))))
+				authMiddleware.RequireSession(sessionManager, eventsHandler.FetchEvents))))
 
 		// Add temp buddy - uses aimsid for auth
 		mux.Handle("GET /aim/addTempBuddy", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
-				buddyListHandler.SessionMiddleware(buddyListHandler.AddTempBuddy))))
+				authMiddleware.RequireSession(sessionManager, buddyListHandler.AddTempBuddy))))
 
 		mux.Handle("GET /aim/removeTempBuddy", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
-				buddyListHandler.SessionMiddleware(buddyListHandler.RemoveTempBuddy))))
+				authMiddleware.RequireSession(sessionManager, buddyListHandler.RemoveTempBuddy))))
 
 		aimStub := &handlers.AimStubHandler{Logger: logger}
 		aimRoute := func(h http.HandlerFunc) http.Handler {
@@ -168,71 +162,79 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		mux.Handle("GET /conversation/update", aimRoute(conversationStub.Update))
 		mux.Handle("GET /conversation/close", aimRoute(conversationStub.Close))
 		mux.Handle("GET /imlog/markRead", aimRoute(conversationStub.MarkRead))
-		mux.Handle("GET /imlog/fetchStoredIMs", aimRoute(conversationStub.FetchStoredIMs))
+		mux.Handle("GET /imlog/fetchStoredIMs", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				authMiddleware.RequireSession(sessionManager, conversationStub.FetchStoredIMs))))
 
 		// Presence and buddy list
 		// GetPresence supports aimsid-based auth, so we use flexible auth
 		mux.Handle("GET /presence/get", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(presenceHandler.GetPresence))))
+				authMiddleware.RequireSession(sessionManager, presenceHandler.GetPresence))))
 
-		mux.Handle("/buddylist/", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(buddyListHandler)))
+		buddyListRoute := func(h func(http.ResponseWriter, *http.Request, *state.WebAPISession)) http.Handler {
+			return authMiddleware.AuthenticateFlexible(
+				authMiddleware.CORSMiddleware(
+					authMiddleware.RequireSession(sessionManager, h)))
+		}
+		mux.Handle("GET /buddylist/addBuddy", buddyListRoute(buddyListHandler.AddBuddy))
+		mux.Handle("GET /buddylist/addGroup", buddyListRoute(buddyListHandler.AddGroup))
+		mux.Handle("GET /buddylist/removeBuddy", buddyListRoute(buddyListHandler.RemoveBuddy))
+		mux.Handle("GET /buddylist/removeGroup", buddyListRoute(buddyListHandler.RemoveGroup))
+		mux.Handle("GET /buddylist/renameGroup", buddyListRoute(buddyListHandler.RenameGroup))
+		mux.Handle("GET /buddylist/moveBuddy", buddyListRoute(buddyListHandler.MoveBuddy))
+		mux.Handle("GET /buddylist/setBuddyAttribute", buddyListRoute(buddyListHandler.SetBuddyAttribute))
+		mux.Handle("GET /buddylist/setGroupAttribute", buddyListRoute(buddyListHandler.SetGroupAttribute))
 
-		// Phase 2: Messaging endpoints
 		// sendIM supports aimsid-based auth, so we use flexible auth.
 		// The Web AIM client POSTs the message body (non-IE browsers); IE uses GET.
 		sendIMHandler := authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(messagingHandler.SendIM)))
+				authMiddleware.RequireSession(sessionManager, messagingHandler.SendIM)))
 		mux.Handle("GET /im/sendIM", sendIMHandler)
 		mux.Handle("POST /im/sendIM", sendIMHandler)
 
 		mux.Handle("GET /im/setTyping", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(messagingHandler.SetTyping))))
+				authMiddleware.RequireSession(sessionManager, messagingHandler.SetTyping))))
 
-		// Phase 2: Presence management endpoints
 		// SetState only requires aimsid, no k parameter needed
 		mux.Handle("GET /presence/setState", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(presenceHandler.SetState))))
+				authMiddleware.RequireSession(sessionManager, presenceHandler.SetState))))
 
 		// These presence endpoints support aimsid-based auth where k is not required
 		mux.Handle("GET /presence/setStatus", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(presenceHandler.SetStatus))))
+				authMiddleware.RequireSession(sessionManager, presenceHandler.SetStatus))))
 
 		mux.Handle("GET /presence/setProfile", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(presenceHandler.SetProfile))))
+				authMiddleware.RequireSession(sessionManager, presenceHandler.SetProfile))))
 
 		mux.Handle("GET /presence/getProfile", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(presenceHandler.GetProfile))))
+				authMiddleware.RequireSession(sessionManager, presenceHandler.GetProfile))))
 
-		// Phase 2: Presence icon endpoint (no auth required)
 		mux.HandleFunc("GET /presence/icon", presenceHandler.Icon)
 
-		// Phase 3: Preference management endpoints
 		// These endpoints support aimsid-based auth, so we use a flexible auth approach
 		mux.Handle("GET /preference/set", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(preferenceHandler.SetPreferences))))
+				authMiddleware.RequireSession(sessionManager, preferenceHandler.SetPreferences))))
 
 		mux.Handle("GET /preference/get", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(preferenceHandler.GetPreferences))))
+				authMiddleware.RequireSession(sessionManager, preferenceHandler.GetPreferences))))
 
 		mux.Handle("GET /preference/setPermitDeny", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(preferenceHandler.SetPermitDeny))))
+				authMiddleware.RequireSession(sessionManager, preferenceHandler.SetPermitDeny))))
 
 		mux.Handle("GET /preference/getPermitDeny", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(preferenceHandler.GetPermitDeny))))
+				authMiddleware.RequireSession(sessionManager, preferenceHandler.GetPermitDeny))))
 
-		// Phase 4: Advanced Features
 		// OSCAR Bridge endpoint
 		mux.Handle("GET /aim/startOSCARSession", authMiddleware.Authenticate(
 			authMiddleware.CORSMiddleware(

+ 0 - 2
server/webapi/types.go

@@ -87,8 +87,6 @@ type BuddyBroadcaster interface {
 	BroadcastBuddyDeparted(ctx context.Context, screenName state.IdentScreenName) error
 }
 
-// Phase 4: OSCAR Bridge interfaces
-
 // OSCARConfig provides configuration for OSCAR services.
 type OSCARConfig interface {
 	GetBOSAddress() (host string, port int)