瀏覽代碼

Merge branch 'main' into login-fix

siohaza 5 月之前
父節點
當前提交
0dbb1d6fa4

+ 6 - 0
.mockery.yaml

@@ -77,6 +77,9 @@ packages:
       BARTAssetManager:
         config:
           filename: "mock_bart_asset_manager_test.go"
+      BuddyBroadcaster:
+        config:
+          filename: "mock_buddy_broadcaster_test.go"
       ChatRoomCreator:
         config:
           filename: "mock_chat_room_creator_test.go"
@@ -201,6 +204,9 @@ packages:
       CookieBaker:
         config:
           filename: "mock_cookie_baker_test.go"
+      SessionRetriever:
+        config:
+          filename: "mock_session_retriever_test.go"
   github.com/mk6i/open-oscar-server/server/kerberos:
     interfaces:
       AuthService:

+ 9 - 0
README.md

@@ -9,6 +9,10 @@
 
 **Open OSCAR Server** is an open-source instant messaging server compatible with classic AIM and ICQ clients.
 
+<p align="center">
+<img width="816" alt="image" src="https://github.com/user-attachments/assets/4d76b06f-fd0c-4f0a-9e9f-d9516653cfb4" />
+</p>
+
 | Disclaimer                                                                                                                                                                                                                           |
 |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
 | This project is an independent, open-source initiative and is not affiliated with, endorsed by, or associated with AOL or Yahoo! Inc. This project is entirely non-commercial and does not generate any revenue or accept donations. |
@@ -60,6 +64,11 @@ This project is under active development. Contributions are welcome!
 
 Follow [this guide](./docs/BUILD.md) to learn how to compile and run Open OSCAR Server.
 
+## 🌍 Community
+
+Check out the Open OSCAR Server [Discord server](https://discord.gg/E5etJfRAqR) to get help or find out how to get
+involved.
+
 ## 👤 Management API
 
 The Management API provides functionality for administering the server (see [OpenAPI spec](./api.yml)). The following

+ 165 - 0
api.yml

@@ -1200,6 +1200,171 @@ paths:
               schema:
                 $ref: '#/components/schemas/MessageResponse'
 
+  /feedbag/{screen_name}/group:
+    get:
+      summary: Get buddy list for a user
+      description: Retrieve all buddies for a user, grouped by group ID.
+      parameters:
+        - name: screen_name
+          in: path
+          description: User's AIM screen name or ICQ UIN.
+          required: true
+          schema:
+            type: string
+      responses:
+        '200':
+          description: Successful response containing buddy list grouped by group ID.
+          content:
+            application/json:
+              schema:
+                type: array
+                description: List of buddy groups.
+                items:
+                  type: object
+                  properties:
+                    group_id:
+                      type: integer
+                      description: The group ID.
+                    group_name:
+                      type: string
+                      description: The name of the group.
+                    buddies:
+                      type: array
+                      description: List of buddies in this group.
+                      items:
+                        type: object
+                        properties:
+                          name:
+                            type: string
+                            description: The buddy's screen name.
+                          item_id:
+                            type: integer
+                            description: The feedbag item ID for this buddy.
+                  required:
+                    - group_id
+                    - group_name
+                    - buddies
+        '404':
+          description: User or feedbag not found.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '500':
+          description: Internal server error.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+
+  /feedbag/{screen_name}/group/{group_id}/buddy/{buddy_screen_name}:
+    put:
+      summary: Add a buddy to a group
+      description: Add a buddy to a specific group in the user's feedbag. This operation is idempotent - if the buddy already exists in the group, the request will succeed without error.
+      parameters:
+        - name: screen_name
+          in: path
+          description: User's AIM screen name or ICQ UIN.
+          required: true
+          schema:
+            type: string
+        - name: group_id
+          in: path
+          description: The group ID to add the buddy to.
+          required: true
+          schema:
+            type: integer
+        - name: buddy_screen_name
+          in: path
+          description: The screen name of the buddy to add.
+          required: true
+          schema:
+            type: string
+          example: "buddy123"
+      responses:
+        '200':
+          description: Buddy added or already exists in the group. Operation is idempotent.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  name:
+                    type: string
+                    description: The buddy's screen name.
+                  group_id:
+                    type: integer
+                    description: The group ID the buddy was added to.
+                  item_id:
+                    type: integer
+                    description: The feedbag item ID for this buddy.
+                required:
+                  - name
+                  - group_id
+                  - item_id
+        '400':
+          description: Bad request. Invalid input data.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '404':
+          description: User or group not found.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '500':
+          description: Internal server error.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+    delete:
+      summary: Delete a buddy from a group
+      description: Remove a buddy from a specific group in the user's feedbag.
+      parameters:
+        - name: screen_name
+          in: path
+          description: User's AIM screen name or ICQ UIN.
+          required: true
+          schema:
+            type: string
+        - name: group_id
+          in: path
+          description: The group ID to remove the buddy from.
+          required: true
+          schema:
+            type: integer
+        - name: buddy_screen_name
+          in: path
+          description: The screen name of the buddy to remove.
+          required: true
+          schema:
+            type: string
+          example: "buddy123"
+      responses:
+        '204':
+          description: Buddy deleted successfully.
+        '400':
+          description: Bad request. Invalid input data.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '404':
+          description: User, group, or buddy not found.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '500':
+          description: Internal server error.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+
 
 components:
   schemas:

+ 10 - 0
cmd/server/factory.go

@@ -351,11 +351,19 @@ func MgmtAPI(deps Container) *http.Server {
 		Date:    date,
 	}
 	logger := deps.logger.With("svc", "API")
+	buddyService := foodgroup.NewBuddyService(
+		deps.inMemorySessionManager,
+		deps.sqLiteUserStore,
+		deps.sqLiteUserStore,
+		deps.inMemorySessionManager,
+		deps.sqLiteUserStore,
+	)
 	return http.NewManagementAPI(
 		bld,
 		deps.cfg.APIListener,
 		deps.sqLiteUserStore,        // userManager
 		deps.inMemorySessionManager, // sessionRetriever
+		buddyService,
 		deps.sqLiteUserStore,        // chatRoomRetriever
 		deps.sqLiteUserStore,        // chatRoomCreator
 		deps.sqLiteUserStore,        // chatRoomDeleter
@@ -364,6 +372,7 @@ func MgmtAPI(deps Container) *http.Server {
 		deps.inMemorySessionManager, // messageRelayer
 		deps.sqLiteUserStore,        // bartAssetManager
 		deps.sqLiteUserStore,        // feedbagRetriever
+		deps.sqLiteUserStore,        // feedbagManager
 		deps.sqLiteUserStore,        // accountManager
 		deps.sqLiteUserStore,        // profileRetriever
 		deps.sqLiteUserStore,        // webAPIKeyManager
@@ -446,6 +455,7 @@ func TOC(deps Container) *toc.Server {
 			ChatNavService:    foodgroup.NewChatNavService(logger, deps.sqLiteUserStore),
 			SNACRateLimits:    deps.snacRateLimits,
 			HTTPIPRateLimiter: toc.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
+			SessionRetriever:  deps.inMemorySessionManager,
 		},
 		toc.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 		deps.icbmSvc.RestoreWarningLevel,

+ 11 - 0
foodgroup/buddy.go

@@ -132,6 +132,10 @@ func (s BuddyService) BroadcastBuddyDeparted(ctx context.Context, instance *stat
 	return s.buddyBroadcaster.BroadcastBuddyDeparted(ctx, instance)
 }
 
+func (s BuddyService) BroadcastVisibility(ctx context.Context, you *state.SessionInstance, filter []state.IdentScreenName, doSendDepartures bool) error {
+	return s.buddyBroadcaster.BroadcastVisibility(ctx, you, filter, doSendDepartures)
+}
+
 func newBuddyNotifier(
 	bartItemManager BARTItemManager,
 	relationshipFetcher RelationshipFetcher,
@@ -160,6 +164,10 @@ type buddyNotifier struct {
 // only used to indicate the user coming online. It can also notify changes to
 // buddy icons, warning levels, invisibility status, etc.
 func (s buddyNotifier) BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error {
+	if userInfo.IsInvisible() {
+		return nil
+	}
+
 	users, err := s.relationshipFetcher.AllRelationships(ctx, screenName, nil)
 	if err != nil {
 		return err
@@ -315,6 +323,9 @@ func (s buddyNotifier) unicastBuddyDeparted(ctx context.Context, from *state.Ses
 // only used to indicate the user coming online. It can also notify changes to
 // buddy icons, warning levels, invisibility status, etc.
 func (s buddyNotifier) unicastBuddyArrived(ctx context.Context, userInfo wire.TLVUserInfo, to state.IdentScreenName) {
+	if userInfo.IsInvisible() {
+		return
+	}
 	s.messageRelayer.RelayToScreenName(ctx, to, wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.Buddy,

+ 153 - 0
foodgroup/buddy_test.go

@@ -506,6 +506,26 @@ func TestBuddyNotifier_BroadcastBuddyArrived(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:       "user invisible, don't send notification",
+			screenName: state.NewIdentScreenName("me"),
+			userInfo: wire.TLVUserInfo{
+				ScreenName: "me",
+				TLVBlock: wire.TLVBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.OServiceUserInfoStatus, wire.OServiceUserStatusInvisible),
+					},
+				},
+			},
+			mockParams: mockParams{
+				relationshipFetcherParams: relationshipFetcherParams{
+					allRelationshipsParams: allRelationshipsParams{}, // don't look up relationships
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNamesParams: relayToScreenNamesParams{}, // don't send notification
+				},
+			},
+		},
 	}
 
 	for _, tc := range cases {
@@ -803,6 +823,139 @@ func Test_buddyNotifier_BroadcastVisibility(t *testing.T) {
 			},
 			doSendDepartures: true,
 		},
+		{
+			name:     "user invisible, don't send notification to buddies",
+			instance: newTestInstance("me", sessOptInvisible),
+			mockParams: mockParams{
+				relationshipFetcherParams: relationshipFetcherParams{
+					allRelationshipsParams: allRelationshipsParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							filter:     nil,
+							result: []state.Relationship{
+								{
+									User:          state.NewIdentScreenName("friend1-blocks-you"),
+									BlocksYou:     true,
+									YouBlock:      false,
+									IsOnYourList:  true,
+									IsOnTheirList: true,
+								},
+								{
+									User:          state.NewIdentScreenName("friend2-visible-on-their-list"),
+									BlocksYou:     false,
+									YouBlock:      false,
+									IsOnYourList:  false,
+									IsOnTheirList: true,
+								},
+								{
+									User:          state.NewIdentScreenName("friend3-visible-on-your-list"),
+									BlocksYou:     false,
+									YouBlock:      false,
+									IsOnYourList:  true,
+									IsOnTheirList: false,
+								},
+								{
+									User:          state.NewIdentScreenName("friend4-visible-on-both-lists"),
+									BlocksYou:     false,
+									YouBlock:      false,
+									IsOnYourList:  true,
+									IsOnTheirList: true,
+								},
+								{
+									User:          state.NewIdentScreenName("friend5-blocked-on-their-list"),
+									BlocksYou:     false,
+									YouBlock:      true,
+									IsOnYourList:  false,
+									IsOnTheirList: true,
+								},
+								{
+									User:          state.NewIdentScreenName("friend6-blocked-on-your-list"),
+									BlocksYou:     false,
+									YouBlock:      true,
+									IsOnYourList:  true,
+									IsOnTheirList: false,
+								},
+								{
+									User:          state.NewIdentScreenName("friend7-blocked-on-both-lists"),
+									BlocksYou:     false,
+									YouBlock:      true,
+									IsOnYourList:  true,
+									IsOnTheirList: true,
+								},
+								{
+									User:          state.NewIdentScreenName("friend7-visible-offline"),
+									BlocksYou:     false,
+									YouBlock:      false,
+									IsOnYourList:  true,
+									IsOnTheirList: true,
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							message:    newBuddyArrivedNotif("friend3-visible-on-your-list"),
+						},
+						{
+							screenName: state.NewIdentScreenName("me"),
+							message:    newBuddyArrivedNotif("friend4-visible-on-both-lists"),
+						},
+						{
+							screenName: state.NewIdentScreenName("friend5-blocked-on-their-list"),
+							message:    newBuddyDepartedNotif("me"),
+						},
+						{
+							screenName: state.NewIdentScreenName("me"),
+							message:    newBuddyDepartedNotif("friend6-blocked-on-your-list"),
+						},
+						{
+							screenName: state.NewIdentScreenName("me"),
+							message:    newBuddyDepartedNotif("friend7-blocked-on-both-lists"),
+						},
+						{
+							screenName: state.NewIdentScreenName("friend7-blocked-on-both-lists"),
+							message:    newBuddyDepartedNotif("me"),
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams: retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("friend2-visible-on-their-list"),
+							result:     newTestInstance("friend2-visible-on-their-list").Session(),
+						},
+						{
+							screenName: state.NewIdentScreenName("friend3-visible-on-your-list"),
+							result:     newTestInstance("friend3-visible-on-your-list").Session(),
+						},
+						{
+							screenName: state.NewIdentScreenName("friend4-visible-on-both-lists"),
+							result:     newTestInstance("friend4-visible-on-both-lists").Session(),
+						},
+						{
+							screenName: state.NewIdentScreenName("friend5-blocked-on-their-list"),
+							result:     newTestInstance("friend5-blocked-on-their-list").Session(),
+						},
+						{
+							screenName: state.NewIdentScreenName("friend6-blocked-on-your-list"),
+							result:     newTestInstance("friend6-blocked-on-your-list").Session(),
+						},
+						{
+							screenName: state.NewIdentScreenName("friend7-blocked-on-both-lists"),
+							result:     newTestInstance("friend7-blocked-on-both-lists").Session(),
+						},
+						{
+							screenName: state.NewIdentScreenName("friend7-visible-offline"),
+							result:     nil,
+						},
+					},
+				},
+			},
+			doSendDepartures: true,
+		},
 		{
 			name:     "don't send departure notifications",
 			instance: newTestInstance("me"),

+ 17 - 2
foodgroup/oservice_test.go

@@ -866,10 +866,12 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		mockParams mockParams
+		// checkSession validates the state of the session
+		checkSession func(*testing.T, *state.Session)
 	}{
 		{
 			name:     "set user status to visible aim < 6",
-			instance: newTestInstance("me"),
+			instance: newTestInstance("me", sessOptInvisible),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -909,6 +911,9 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 					},
 				},
 			},
+			checkSession: func(t *testing.T, session *state.Session) {
+				assert.False(t, session.Invisible())
+			},
 		},
 		{
 			name:     "set user status to invisible aim < 6",
@@ -952,10 +957,13 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 					},
 				},
 			},
+			checkSession: func(t *testing.T, session *state.Session) {
+				assert.True(t, session.Invisible())
+			},
 		},
 		{
 			name:     "set user status to visible aim >= 6",
-			instance: newTestInstance("me", sessOptSetFoodGroupVersion(wire.OService, 4)),
+			instance: newTestInstance("me", sessOptInvisible, sessOptSetFoodGroupVersion(wire.OService, 4)),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -995,6 +1003,9 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 					},
 				},
 			},
+			checkSession: func(t *testing.T, session *state.Session) {
+				assert.False(t, session.Invisible())
+			},
 		},
 		{
 			name:     "set user status to invisible aim >= 6",
@@ -1038,6 +1049,9 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 					},
 				},
 			},
+			checkSession: func(t *testing.T, session *state.Session) {
+				assert.True(t, session.Invisible())
+			},
 		},
 	}
 
@@ -1073,6 +1087,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 			} else {
 				assert.Equal(t, tc.expectOutput.Body, outputSNAC.Body)
 			}
+			tc.checkSession(t, tc.instance.Session())
 		})
 	}
 }

+ 63 - 0
server/http/helpers_test.go

@@ -13,11 +13,14 @@ import (
 type mockParams struct {
 	accountManagerParams
 	bartAssetManagerParams
+	buddyBroadcasterParams
 	chatRoomDeleterParams
 	chatRoomRetrieverParams
 	chatSessionRetrieverParams
 	directoryManagerParams
 	feedBagRetrieverParams
+	feedbagManagerParams
+	messageRelayerParams
 	profileRetrieverParams
 	sessionRetrieverParams
 	userManagerParams
@@ -114,6 +117,21 @@ type deleteBARTItemParams []struct {
 	err  error
 }
 
+// buddyBroadcasterParams is a helper struct that contains mock parameters for
+// BuddyBroadcaster methods
+type buddyBroadcasterParams struct {
+	broadcastVisibilityParams
+}
+
+// broadcastVisibilityParams is the list of parameters passed at the mock
+// BuddyBroadcaster.BroadcastVisibility call site
+type broadcastVisibilityParams []struct {
+	you            *state.SessionInstance
+	filter         []state.IdentScreenName
+	sendDepartures bool
+	err            error
+}
+
 // chatRoomRetrieverParams is a helper struct that contains mock parameters for
 // ChatRoomRetriever methods
 type chatRoomRetrieverParams struct {
@@ -224,6 +242,51 @@ type buddyIconMetadataParams []struct {
 	err        error
 }
 
+// feedbagManagerParams is a helper struct that contains mock parameters for
+// FeedbagManager methods
+type feedbagManagerParams struct {
+	feedbagParams
+	feedbagUpsertParams
+	feedbagDeleteParams
+}
+
+// feedbagParams is the list of parameters passed at the mock
+// FeedbagManager.Feedbag call site
+type feedbagParams []struct {
+	screenName state.IdentScreenName
+	result     []wire.FeedbagItem
+	err        error
+}
+
+// feedbagUpsertParams is the list of parameters passed at the mock
+// FeedbagManager.FeedbagUpsert call site
+type feedbagUpsertParams []struct {
+	screenName state.IdentScreenName
+	items      []wire.FeedbagItem
+	err        error
+}
+
+// feedbagDeleteParams is the list of parameters passed at the mock
+// FeedbagManager.FeedbagDelete call site
+type feedbagDeleteParams []struct {
+	screenName state.IdentScreenName
+	items      []wire.FeedbagItem
+	err        error
+}
+
+// messageRelayerParams is a helper struct that contains mock parameters for
+// MessageRelayer methods
+type messageRelayerParams struct {
+	relayToScreenNameParams
+}
+
+// relayToScreenNameParams is the list of parameters passed at the mock
+// MessageRelayer.RelayToScreenName call site
+type relayToScreenNameParams []struct {
+	screenName state.IdentScreenName
+	msg        wire.SNACMessage
+}
+
 // profileRetrieverParams is a helper struct that contains mock parameters for
 // ProfileRetriever methods
 type profileRetrieverParams struct {

+ 356 - 1
server/http/mgmt_api.go

@@ -10,6 +10,8 @@ import (
 	"fmt"
 	"io"
 	"log/slog"
+	"math"
+	"math/rand"
 	"net/http"
 	"strconv"
 	"strings"
@@ -22,7 +24,7 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-func NewManagementAPI(bld config.Build, listener string, userManager UserManager, sessionRetriever SessionRetriever, chatRoomRetriever ChatRoomRetriever, chatRoomCreator ChatRoomCreator, chatRoomDeleter ChatRoomDeleter, chatSessionRetriever ChatSessionRetriever, directoryManager DirectoryManager, messageRelayer MessageRelayer, bartAssetManager BARTAssetManager, feedbagRetriever FeedBagRetriever, accountManager AccountManager, profileRetriever ProfileRetriever, webAPIKeyManager WebAPIKeyManager, logger *slog.Logger) *Server {
+func NewManagementAPI(bld config.Build, listener string, userManager UserManager, sessionRetriever SessionRetriever, buddyBroadcaster BuddyBroadcaster, chatRoomRetriever ChatRoomRetriever, chatRoomCreator ChatRoomCreator, chatRoomDeleter ChatRoomDeleter, chatSessionRetriever ChatSessionRetriever, directoryManager DirectoryManager, messageRelayer MessageRelayer, bartAssetManager BARTAssetManager, feedbagRetriever FeedBagRetriever, feedbagManager FeedbagManager, accountManager AccountManager, profileRetriever ProfileRetriever, webAPIKeyManager WebAPIKeyManager, logger *slog.Logger) *Server {
 	mux := http.NewServeMux()
 
 	// Handlers for '/user' route
@@ -159,6 +161,19 @@ func NewManagementAPI(bld config.Build, listener string, userManager UserManager
 		deleteBARTHandler(w, r, bartAssetManager, logger)
 	})
 
+	// Handlers for '/feedbag/{screen_name}/group' route
+	mux.HandleFunc("GET /feedbag/{screen_name}/group", func(w http.ResponseWriter, r *http.Request) {
+		getFeedbagBuddyHandler(w, r, feedbagManager, logger)
+	})
+
+	// Handlers for '/feedbag/{screen_name}/group/{group_id}/buddy/{buddy_screen_name}' route
+	mux.HandleFunc("PUT /feedbag/{screen_name}/group/{group_id}/buddy/{buddy_screen_name}", func(w http.ResponseWriter, r *http.Request) {
+		putFeedbagBuddyHandler(w, r, buddyBroadcaster, feedbagManager, sessionRetriever, messageRelayer, logger, rand.Intn)
+	})
+	mux.HandleFunc("DELETE /feedbag/{screen_name}/group/{group_id}/buddy/{buddy_screen_name}", func(w http.ResponseWriter, r *http.Request) {
+		deleteFeedbagBuddyHandler(w, r, buddyBroadcaster, feedbagManager, sessionRetriever, messageRelayer, logger)
+	})
+
 	return &Server{
 		server: http.Server{
 			Addr:    listener,
@@ -1206,3 +1221,343 @@ func deleteBARTHandler(w http.ResponseWriter, r *http.Request, bartAssetManager
 	msg := messageBody{Message: "BART asset deleted successfully."}
 	json.NewEncoder(w).Encode(msg)
 }
+
+// getFeedbagBuddyHandler handles the GET /feedbag/{screen_name}/group endpoint.
+func getFeedbagBuddyHandler(w http.ResponseWriter, r *http.Request, feedbagManager FeedbagManager, logger *slog.Logger) {
+	w.Header().Set("Content-Type", "application/json")
+
+	screenName := r.PathValue("screen_name")
+	if screenName == "" {
+		errorMsg(w, "screen_name is required", http.StatusBadRequest)
+		return
+	}
+
+	items, err := feedbagManager.Feedbag(r.Context(), state.NewIdentScreenName(screenName))
+	if err != nil {
+		logger.Error("error retrieving feedbag", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	if len(items) == 0 {
+		errorMsg(w, "feedbag not found", http.StatusNotFound)
+		return
+	}
+
+	buddyMap := make(map[uint16][]*wire.FeedbagItem)
+
+	for _, item := range items {
+		switch item.ClassID {
+		case wire.FeedbagClassIdBuddy:
+			buddyMap[item.GroupID] = append(buddyMap[item.GroupID], &item)
+		}
+	}
+
+	type buddyItem struct {
+		Name   string `json:"name"`
+		ItemID uint16 `json:"item_id"`
+	}
+	type groupItem struct {
+		GroupID   uint16      `json:"group_id"`
+		GroupName string      `json:"group_name"`
+		Buddies   []buddyItem `json:"buddies"`
+	}
+
+	response := make([]groupItem, 0)
+
+	for _, item := range items {
+		switch item.ClassID {
+		case wire.FeedbagClassIdGroup:
+			if item.GroupID == 0 {
+				// can't add buddies to the root group
+				continue
+			}
+			group := groupItem{
+				GroupID:   item.GroupID,
+				GroupName: item.Name,
+				Buddies:   make([]buddyItem, 0, len(buddyMap[item.GroupID])),
+			}
+			for _, buddy := range buddyMap[item.GroupID] {
+				group.Buddies = append(group.Buddies, buddyItem{
+					Name:   buddy.Name,
+					ItemID: buddy.ItemID,
+				})
+			}
+			response = append(response, group)
+		}
+	}
+
+	if err := json.NewEncoder(w).Encode(response); err != nil {
+		logger.Error("error encoding response", "err", err.Error())
+	}
+}
+
+// putFeedbagBuddyHandler handles the PUT /feedbag/{screen_name}/group/{group_id}/buddy/{buddy_screen_name} endpoint.
+func putFeedbagBuddyHandler(w http.ResponseWriter, r *http.Request, buddyBroadcaster BuddyBroadcaster, feedbagManager FeedbagManager, sessionRetriever SessionRetriever, messageRelayer MessageRelayer, logger *slog.Logger, randInt func(n int) int) {
+	w.Header().Set("Content-Type", "application/json")
+
+	gid, err := strconv.ParseUint(r.PathValue("group_id"), 10, 16)
+	if err != nil {
+		errorMsg(w, "invalid group_id", http.StatusBadRequest)
+		return
+	}
+	groupID := uint16(gid)
+
+	if groupID == 0 {
+		errorMsg(w, "can't add buddies to root group", http.StatusBadRequest)
+		return
+	}
+
+	screenName := r.PathValue("screen_name")
+	if screenName == "" {
+		errorMsg(w, "screen_name is required", http.StatusBadRequest)
+		return
+	}
+	me := state.NewIdentScreenName(screenName)
+
+	buddyScreenName := r.PathValue("buddy_screen_name")
+	if buddyScreenName == "" {
+		errorMsg(w, "buddy_screen_name is required", http.StatusBadRequest)
+		return
+	}
+
+	newBuddy := state.DisplayScreenName(buddyScreenName)
+	if newBuddy.IsUIN() {
+		if err := newBuddy.ValidateUIN(); err != nil {
+			errorMsg(w, fmt.Sprintf("invalid uin: %s", err), http.StatusBadRequest)
+			return
+		}
+	} else {
+		if err := newBuddy.ValidateAIMHandle(); err != nil {
+			errorMsg(w, fmt.Sprintf("invalid screen name: %s", err), http.StatusBadRequest)
+			return
+		}
+	}
+
+	items, err := feedbagManager.Feedbag(r.Context(), me)
+	if err != nil {
+		logger.Error("error retrieving feedbag", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	var group *wire.FeedbagItem
+
+	count := 0
+	for _, item := range items {
+		switch {
+		case item.ClassID == wire.FeedbagClassIdGroup && item.GroupID == groupID:
+			group = &item
+		case item.ClassID == wire.FeedbagClassIdBuddy && item.GroupID == groupID:
+			count++
+			if item.Name == newBuddy.IdentScreenName().String() {
+				response := struct {
+					Name    string `json:"name"`
+					GroupID uint16 `json:"group_id"`
+					ItemID  uint16 `json:"item_id"`
+				}{
+					Name:    buddyScreenName,
+					GroupID: groupID,
+					ItemID:  item.ItemID,
+				}
+				w.WriteHeader(http.StatusOK)
+				if err := json.NewEncoder(w).Encode(response); err != nil {
+					logger.Error("error encoding response", "err", err.Error())
+				}
+				return
+			}
+		}
+	}
+
+	if count >= 30 {
+		errorMsg(w, "too many buddies in group. max: 30", http.StatusBadRequest)
+		return
+	}
+
+	if group == nil {
+		errorMsg(w, "group not found", http.StatusNotFound)
+		return
+	}
+
+	buddyItem := wire.FeedbagItem{
+		Name:    buddyScreenName,
+		GroupID: groupID,
+		ItemID:  randItemID(randInt, items),
+		ClassID: wire.FeedbagClassIdBuddy,
+	}
+
+	if buddyItem.ItemID == 0 {
+		errorMsg(w, "maximum items reached", http.StatusConflict)
+		return
+	}
+
+	if order, hasOrder := group.Bytes(wire.FeedbagAttributesOrder); hasOrder {
+		var memberIDs []uint16
+		if err := wire.UnmarshalBE(&memberIDs, bytes.NewReader(order)); err != nil {
+			logger.Error("error decoding order TLV", "err", err.Error())
+			errorMsg(w, "internal server error", http.StatusInternalServerError)
+			return
+		}
+		group.Replace(wire.NewTLVBE(wire.FeedbagAttributesOrder, append(memberIDs, buddyItem.ItemID)))
+	} else {
+		group.Append(wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{buddyItem.ItemID}))
+	}
+
+	updates := []wire.FeedbagItem{
+		buddyItem,
+		*group,
+	}
+	if err := feedbagManager.FeedbagUpsert(r.Context(), me, updates); err != nil {
+		logger.Error("error inserting feedbag item", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	session := sessionRetriever.RetrieveSession(me)
+	if session != nil {
+		messageRelayer.RelayToScreenName(r.Context(), me, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagInsertItem,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Items: []wire.FeedbagItem{buddyItem},
+			},
+		})
+		messageRelayer.RelayToScreenName(r.Context(), me, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagUpdateItem,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Items: []wire.FeedbagItem{*group},
+			},
+		})
+		instances := session.Instances()
+		if len(instances) > 0 {
+			if err := buddyBroadcaster.BroadcastVisibility(r.Context(), instances[0], []state.IdentScreenName{newBuddy.IdentScreenName()}, false); err != nil {
+				logger.Error("error broadcasting visibility", "err", err.Error())
+			}
+		}
+	}
+
+	response := struct {
+		Name    string `json:"name"`
+		GroupID uint16 `json:"group_id"`
+		ItemID  uint16 `json:"item_id"`
+	}{
+		Name:    buddyItem.Name,
+		GroupID: buddyItem.GroupID,
+		ItemID:  buddyItem.ItemID,
+	}
+
+	w.WriteHeader(http.StatusOK)
+	if err := json.NewEncoder(w).Encode(response); err != nil {
+		logger.Error("error encoding response", "err", err.Error())
+	}
+}
+
+func randItemID(randInt func(n int) int, items []wire.FeedbagItem) uint16 {
+	num := uint16(randInt(math.MaxUint16))
+	for itemID := num; itemID != num-1; itemID++ {
+		if itemID == 0 {
+			continue
+		}
+		exists := false
+		for _, item := range items {
+			if item.GroupID == itemID || item.ItemID == itemID {
+				exists = true
+				break
+			}
+		}
+		if !exists {
+			return itemID
+		}
+	}
+	return 0
+}
+
+// deleteFeedbagBuddyHandler handles the DELETE /feedbag/{screen_name}/group/{group_id}/buddy/{buddy_screen_name} endpoint.
+func deleteFeedbagBuddyHandler(w http.ResponseWriter, r *http.Request, buddyBroadcaster BuddyBroadcaster, feedbagManager FeedbagManager, sessionRetriever SessionRetriever, messageRelayer MessageRelayer, logger *slog.Logger) {
+	gid, err := strconv.ParseUint(r.PathValue("group_id"), 10, 16)
+	if err != nil {
+		errorMsg(w, "invalid group_id", http.StatusBadRequest)
+		return
+	}
+	groupID := uint16(gid)
+
+	if groupID == 0 {
+		errorMsg(w, "can't add buddies to root group", http.StatusBadRequest)
+		return
+	}
+
+	screenName := r.PathValue("screen_name")
+	if screenName == "" {
+		errorMsg(w, "screen_name is required", http.StatusBadRequest)
+		return
+	}
+	me := state.NewIdentScreenName(screenName)
+
+	buddyScreenName := r.PathValue("buddy_screen_name")
+	if buddyScreenName == "" {
+		errorMsg(w, "buddy_screen_name is required", http.StatusBadRequest)
+		return
+	}
+	deleteBuddy := state.NewIdentScreenName(buddyScreenName)
+
+	items, err := feedbagManager.Feedbag(r.Context(), me)
+	if err != nil {
+		logger.Error("error retrieving feedbag", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	var itemToDelete *wire.FeedbagItem
+	var groupFound bool
+	for _, item := range items {
+		switch {
+		case item.ClassID == wire.FeedbagClassIdGroup && item.GroupID == groupID:
+			groupFound = true
+		case item.ClassID == wire.FeedbagClassIdBuddy && item.Name == buddyScreenName && item.GroupID == groupID:
+			itemToDelete = &item
+		}
+	}
+
+	switch {
+	case !groupFound:
+		errorMsg(w, "group not found", http.StatusNotFound)
+		return
+	case itemToDelete == nil:
+		errorMsg(w, "buddy not found", http.StatusNotFound)
+		return
+	}
+
+	if err := feedbagManager.FeedbagDelete(r.Context(), me, []wire.FeedbagItem{*itemToDelete}); err != nil {
+		logger.Error("error deleting feedbag item", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	session := sessionRetriever.RetrieveSession(me)
+	if session != nil {
+		messageRelayer.RelayToScreenName(r.Context(), me, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagDeleteItem,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+				Items: []wire.FeedbagItem{*itemToDelete},
+			},
+		})
+		instances := session.Instances()
+		if len(instances) > 0 {
+			if err := buddyBroadcaster.BroadcastVisibility(r.Context(), instances[0], []state.IdentScreenName{deleteBuddy}, true); err != nil {
+				logger.Error("error broadcasting visibility", "err", err.Error())
+			}
+		}
+	}
+
+	w.WriteHeader(http.StatusNoContent)
+}

+ 1482 - 0
server/http/mgmt_api_test.go

@@ -1,10 +1,12 @@
 package http
 
 import (
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
 	"log/slog"
+	"math"
 	"net/http"
 	"net/http/httptest"
 	"net/mail"
@@ -3047,3 +3049,1483 @@ type errorReader struct{}
 func (er *errorReader) Read(p []byte) (n int, err error) {
 	return 0, errors.New("read error")
 }
+
+func TestFeedbagBuddyHandler_GET(t *testing.T) {
+	tt := []struct {
+		name           string
+		screenName     string
+		wantStatusCode int
+		wantResponse   string
+		mockParams     mockParams
+	}{
+		{
+			name:           "empty feedbag",
+			screenName:     "userA",
+			wantStatusCode: http.StatusNotFound,
+			wantResponse:   `{"message":"feedbag not found"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     []wire.FeedbagItem{},
+							err:        nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "feedbag with buddies in groups",
+			screenName:     "userA",
+			wantStatusCode: http.StatusOK,
+			wantResponse:   `[{"group_id":1,"group_name":"Friends","buddies":[{"name":"buddy1","item_id":10},{"name":"buddy2","item_id":11}]},{"group_id":2,"group_name":"Work","buddies":[{"name":"buddy3","item_id":20}]}]`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ItemID:  0,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "",
+									GroupID: 0,
+								},
+								{
+									ItemID:  1,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+								{
+									ItemID:  2,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Work",
+									GroupID: 2,
+								},
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+								{
+									ItemID:  11,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy2",
+									GroupID: 1,
+								},
+								{
+									ItemID:  20,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy3",
+									GroupID: 2,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "feedbag with no groups (besides root)",
+			screenName:     "userA",
+			wantStatusCode: http.StatusOK,
+			wantResponse:   `[]`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ItemID:  0,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "",
+									GroupID: 0,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+			},
+			// Note: Root groups (GroupID == 0) are skipped by the implementation,
+			// so the response will be an empty array []
+		},
+		{
+			name:           "missing screen_name",
+			screenName:     "",
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"screen_name is required"}`,
+		},
+		{
+			name:           "internal server error",
+			screenName:     "userA",
+			wantStatusCode: http.StatusInternalServerError,
+			wantResponse:   `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     nil,
+							err:        errors.New("database error"),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodGet, "/feedbag/"+tc.screenName+"/group", nil)
+			if tc.screenName != "" {
+				request.SetPathValue("screen_name", tc.screenName)
+			}
+			responseRecorder := httptest.NewRecorder()
+
+			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(matchContext(), params.screenName).
+					Return(params.result, params.err)
+			}
+
+			getFeedbagBuddyHandler(responseRecorder, request, feedbagManager, slog.Default())
+
+			assert.Equal(t, tc.wantStatusCode, responseRecorder.Code)
+			assert.JSONEq(t, tc.wantResponse, responseRecorder.Body.String())
+		})
+	}
+}
+
+func TestFeedbagBuddyHandler_PUT(t *testing.T) {
+	tt := []struct {
+		name           string
+		screenName     string
+		groupID        string
+		requestBody    string
+		wantStatusCode int
+		wantResponse   string
+		mockParams     mockParams
+	}{
+		{
+			name:           "add a buddy to an empty group, user not signed in",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"newbuddy"}`,
+			wantStatusCode: http.StatusOK,
+			wantResponse:   `{"name":"newbuddy","group_id":1,"item_id":1000}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									Name:    "newbuddy",
+									GroupID: 1,
+									ItemID:  1000,
+									ClassID: wire.FeedbagClassIdBuddy,
+								},
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1000}),
+										},
+									},
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionByNameParams: retrieveSessionByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     state.NewSession(),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagInsertItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+									Items: []wire.FeedbagItem{
+										{
+											Name:    "newbuddy",
+											GroupID: 1,
+											ItemID:  1000,
+											ClassID: wire.FeedbagClassIdBuddy,
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagUpdateItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+									Items: []wire.FeedbagItem{
+										{
+											ClassID: wire.FeedbagClassIdGroup,
+											Name:    "Friends",
+											GroupID: 1,
+											TLVLBlock: wire.TLVLBlock{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1000}),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "add a buddy to a non-empty group, user not signed in",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"newbuddy2"}`,
+			wantStatusCode: http.StatusOK,
+			wantResponse:   `{"name":"newbuddy2","group_id":1,"item_id":1000}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{12345}),
+										},
+									},
+								},
+								{
+									ItemID:  12345,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "existingbuddy",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									Name:    "newbuddy2",
+									GroupID: 1,
+									ItemID:  1000,
+									ClassID: wire.FeedbagClassIdBuddy,
+								},
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{12345, 1000}),
+										},
+									},
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionByNameParams: retrieveSessionByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     state.NewSession(),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagInsertItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+									Items: []wire.FeedbagItem{
+										{
+											Name:    "newbuddy2",
+											GroupID: 1,
+											ItemID:  1000,
+											ClassID: wire.FeedbagClassIdBuddy,
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagUpdateItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+									Items: []wire.FeedbagItem{
+										{
+											ClassID: wire.FeedbagClassIdGroup,
+											Name:    "Friends",
+											GroupID: 1,
+											TLVLBlock: wire.TLVLBlock{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{12345, 1000}),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "add a buddy that already exists in a group",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"ExistingBuddy"}`,
+			wantStatusCode: http.StatusOK,
+			wantResponse:   `{"name":"ExistingBuddy","group_id":1,"item_id":12345}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{12345}),
+										},
+									},
+								},
+								{
+									ItemID:  12345,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "existingbuddy",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					// No FeedbagUpsert should be called when buddy already exists
+				},
+				// No session retrieval or message relaying should occur
+			},
+		},
+		{
+			name:           "add a buddy to an empty group, user signed in",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"newbuddy"}`,
+			wantStatusCode: http.StatusOK,
+			wantResponse:   `{"name":"newbuddy","group_id":1,"item_id":1000}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									Name:    "newbuddy",
+									GroupID: 1,
+									ItemID:  1000,
+									ClassID: wire.FeedbagClassIdBuddy,
+								},
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1000}),
+										},
+									},
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionByNameParams: retrieveSessionByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: func() *state.Session {
+								sess := state.NewSession()
+								sess.SetIdentScreenName(state.NewIdentScreenName("userA"))
+								inst := sess.AddInstance()
+								inst.SetSignonComplete()
+								return sess
+							}(),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagInsertItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+									Items: []wire.FeedbagItem{
+										{
+											Name:    "newbuddy",
+											GroupID: 1,
+											ItemID:  1000,
+											ClassID: wire.FeedbagClassIdBuddy,
+										},
+									},
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagUpdateItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+									Items: []wire.FeedbagItem{
+										{
+											ClassID: wire.FeedbagClassIdGroup,
+											Name:    "Friends",
+											GroupID: 1,
+											TLVLBlock: wire.TLVLBlock{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1000}),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							you:            nil, // Not used in expectation, matched with mock.AnythingOfType
+							filter:         []state.IdentScreenName{state.NewIdentScreenName("newbuddy")},
+							sendDepartures: false,
+							err:            nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "invalid group_id - non-numeric",
+			screenName:     "userA",
+			groupID:        "invalid",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"invalid group_id"}`,
+		},
+		{
+			name:           "invalid group_id - out of range",
+			screenName:     "userA",
+			groupID:        "99999",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"invalid group_id"}`,
+		},
+		{
+			name:           "can't add buddies to root group",
+			screenName:     "userA",
+			groupID:        "0",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"can't add buddies to root group"}`,
+		},
+		{
+			name:           "empty screen_name",
+			screenName:     "",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"screen_name is required"}`,
+		},
+		{
+			name:           "malformed JSON input",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `invalid json`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"buddy_screen_name is required"}`,
+		},
+		{
+			name:           "empty name in request body",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":""}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"buddy_screen_name is required"}`,
+		},
+		{
+			name:           "missing name in request body",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"buddy_screen_name is required"}`,
+		},
+		{
+			name:           "invalid UIN - too low",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"9999"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"invalid uin: uin must be a number in the range 10000-2147483646"}`,
+		},
+		{
+			name:           "invalid UIN - too high",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"2147483647"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"invalid uin: uin must be a number in the range 10000-2147483646"}`,
+		},
+		{
+			name:           "invalid AIM handle - too short",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"Us"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"invalid screen name: screen name must be between 3 and 16 characters"}`,
+		},
+		{
+			name:           "invalid AIM handle - too long",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"ThisIsAReallyLongScreenName"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"invalid screen name: screen name must be between 3 and 16 characters"}`,
+		},
+		{
+			name:           "error retrieving feedbag",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusInternalServerError,
+			wantResponse:   `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     nil,
+							err:        errors.New("database error"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "too many buddies in group - max 30",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"newbuddy"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"too many buddies in group. max: 30"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: func() []wire.FeedbagItem {
+								items := []wire.FeedbagItem{
+									{
+										ClassID: wire.FeedbagClassIdGroup,
+										Name:    "Friends",
+										GroupID: 1,
+									},
+								}
+								// Add 30 buddies to the group
+								for i := 1; i <= 30; i++ {
+									items = append(items, wire.FeedbagItem{
+										ItemID:  uint16(i),
+										ClassID: wire.FeedbagClassIdBuddy,
+										Name:    fmt.Sprintf("buddy%d", i),
+										GroupID: 1,
+									})
+								}
+								return items
+							}(),
+							err: nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "group not found",
+			screenName:     "userA",
+			groupID:        "999",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusNotFound,
+			wantResponse:   `{"message":"group not found"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+			},
+		},
+
+		{
+			name:           "error inserting feedbag item",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusInternalServerError,
+			wantResponse:   `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									Name:    "buddy",
+									GroupID: 1,
+									ItemID:  1000,
+									ClassID: wire.FeedbagClassIdBuddy,
+								},
+								{
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1000}),
+										},
+									},
+								},
+							},
+							err: errors.New("database error"),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			// Extract buddy name from requestBody JSON
+			var buddyName string
+			if tc.requestBody != "" {
+				var input struct {
+					Name string `json:"name"`
+				}
+				if err := json.Unmarshal([]byte(tc.requestBody), &input); err == nil {
+					buddyName = input.Name
+				}
+			}
+			request := httptest.NewRequest(http.MethodPut, "/feedbag/"+tc.screenName+"/group/"+tc.groupID+"/buddy/"+buddyName, nil)
+			if tc.screenName != "" {
+				request.SetPathValue("screen_name", tc.screenName)
+			}
+			if tc.groupID != "" {
+				request.SetPathValue("group_id", tc.groupID)
+			}
+			request.SetPathValue("buddy_screen_name", buddyName)
+			responseRecorder := httptest.NewRecorder()
+
+			feedbagManager := newMockFeedbagManager(t)
+			sessionRetriever := newMockSessionRetriever(t)
+			messageRelayer := newMockMessageRelayer(t)
+			buddyBroadcaster := newMockBuddyBroadcaster(t)
+
+			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(matchContext(), params.screenName).
+					Return(params.result, params.err)
+			}
+			for _, params := range tc.mockParams.feedbagManagerParams.feedbagUpsertParams {
+				feedbagManager.EXPECT().
+					FeedbagUpsert(matchContext(), params.screenName, params.items).
+					Return(params.err)
+			}
+			for _, params := range tc.mockParams.sessionRetrieverParams.retrieveSessionByNameParams {
+				sessionRetriever.EXPECT().
+					RetrieveSession(params.screenName).
+					Return(params.result)
+			}
+			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().
+					RelayToScreenName(matchContext(), params.screenName, params.msg)
+			}
+			for _, params := range tc.mockParams.buddyBroadcasterParams.broadcastVisibilityParams {
+				// Use mock.MatchedBy to match any SessionInstance, since we're mainly verifying filter and sendDepartures
+				buddyBroadcaster.EXPECT().
+					BroadcastVisibility(
+						matchContext(),
+						mock.AnythingOfType("*state.SessionInstance"),
+						params.filter,
+						params.sendDepartures,
+					).
+					Return(params.err)
+			}
+
+			deterministicItemID := func(n int) int {
+				return 1000
+			}
+			putFeedbagBuddyHandler(responseRecorder, request, buddyBroadcaster, feedbagManager, sessionRetriever, messageRelayer, slog.Default(), deterministicItemID)
+
+			assert.Equal(t, tc.wantStatusCode, responseRecorder.Code)
+			if tc.wantStatusCode == http.StatusOK {
+				assert.JSONEq(t, tc.wantResponse, responseRecorder.Body.String())
+			} else {
+				assert.JSONEq(t, tc.wantResponse, responseRecorder.Body.String())
+			}
+		})
+	}
+}
+
+func TestFeedbagBuddyHandler_DELETE(t *testing.T) {
+	tt := []struct {
+		name           string
+		screenName     string
+		groupID        string
+		requestBody    string
+		wantStatusCode int
+		wantResponse   string
+		mockParams     mockParams
+	}{
+		{
+			name:           "delete existing buddy, user not signed in",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy1"}`,
+			wantStatusCode: http.StatusNoContent,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ItemID:  1,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagDeleteParams: feedbagDeleteParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionByNameParams: retrieveSessionByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     state.NewSession(),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagDeleteItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+									Items: []wire.FeedbagItem{
+										{
+											ItemID:  10,
+											ClassID: wire.FeedbagClassIdBuddy,
+											Name:    "buddy1",
+											GroupID: 1,
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "delete existing buddy, user signed in",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy1"}`,
+			wantStatusCode: http.StatusNoContent,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ItemID:  1,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagDeleteParams: feedbagDeleteParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionByNameParams: retrieveSessionByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: func() *state.Session {
+								sess := state.NewSession()
+								sess.SetIdentScreenName(state.NewIdentScreenName("userA"))
+								inst := sess.AddInstance()
+								inst.SetSignonComplete()
+								return sess
+							}(),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagDeleteItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+									Items: []wire.FeedbagItem{
+										{
+											ItemID:  10,
+											ClassID: wire.FeedbagClassIdBuddy,
+											Name:    "buddy1",
+											GroupID: 1,
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							you:            nil, // Not used in expectation, matched with mock.AnythingOfType
+							filter:         []state.IdentScreenName{state.NewIdentScreenName("buddy1")},
+							sendDepartures: true,
+							err:            nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "delete buddy from a group with multiple buddies, user not signed in",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy1"}`,
+			wantStatusCode: http.StatusNoContent,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ItemID:  1,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{10, 20}),
+										},
+									},
+								},
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+								{
+									ItemID:  20,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy2",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagDeleteParams: feedbagDeleteParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionByNameParams: retrieveSessionByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     state.NewSession(),
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagDeleteItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+									Items: []wire.FeedbagItem{
+										{
+											ItemID:  10,
+											ClassID: wire.FeedbagClassIdBuddy,
+											Name:    "buddy1",
+											GroupID: 1,
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "can't delete buddy from root group",
+			screenName:     "userA",
+			groupID:        "0",
+			requestBody:    `{"name":"rootbuddy"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"can't add buddies to root group"}`,
+		},
+		{
+			name:           "missing screen_name",
+			screenName:     "",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"screen_name is required"}`,
+		},
+		{
+			name:           "group not found",
+			screenName:     "userA",
+			groupID:        "999",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusNotFound,
+			wantResponse:   `{"message":"group not found"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ItemID:  1,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 0,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "invalid group_id",
+			screenName:     "userA",
+			groupID:        "invalid",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"invalid group_id"}`,
+		},
+		{
+			name:           "malformed request body",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `invalid json`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"buddy_screen_name is required"}`,
+		},
+		{
+			name:           "missing name in request body",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{}`,
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"buddy_screen_name is required"}`,
+		},
+		{
+			name:           "buddy not found",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"nonexistent"}`,
+			wantStatusCode: http.StatusNotFound,
+			wantResponse:   `{"message":"buddy not found"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ItemID:  1,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "otherbuddy",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "internal server error on feedbag retrieval",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy"}`,
+			wantStatusCode: http.StatusInternalServerError,
+			wantResponse:   `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     nil,
+							err:        errors.New("database error"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "internal server error on delete",
+			screenName:     "userA",
+			groupID:        "1",
+			requestBody:    `{"name":"buddy1"}`,
+			wantStatusCode: http.StatusInternalServerError,
+			wantResponse:   `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: []wire.FeedbagItem{
+								{
+									ItemID:  1,
+									ClassID: wire.FeedbagClassIdGroup,
+									Name:    "Friends",
+									GroupID: 1,
+								},
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+							},
+							err: nil,
+						},
+					},
+					feedbagDeleteParams: feedbagDeleteParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							items: []wire.FeedbagItem{
+								{
+									ItemID:  10,
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddy1",
+									GroupID: 1,
+								},
+							},
+							err: errors.New("database error"),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			// Extract buddy name from requestBody JSON
+			var buddyName string
+			if tc.requestBody != "" {
+				var input struct {
+					Name string `json:"name"`
+				}
+				if err := json.Unmarshal([]byte(tc.requestBody), &input); err == nil {
+					buddyName = input.Name
+				}
+			}
+			request := httptest.NewRequest(http.MethodDelete, "/feedbag/"+tc.screenName+"/group/"+tc.groupID+"/buddy/"+buddyName, nil)
+			if tc.screenName != "" {
+				request.SetPathValue("screen_name", tc.screenName)
+			}
+			if tc.groupID != "" {
+				request.SetPathValue("group_id", tc.groupID)
+			}
+			request.SetPathValue("buddy_screen_name", buddyName)
+			responseRecorder := httptest.NewRecorder()
+
+			feedbagManager := newMockFeedbagManager(t)
+			sessionRetriever := newMockSessionRetriever(t)
+			messageRelayer := newMockMessageRelayer(t)
+			buddyBroadcaster := newMockBuddyBroadcaster(t)
+
+			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(matchContext(), params.screenName).
+					Return(params.result, params.err)
+			}
+			for _, params := range tc.mockParams.feedbagManagerParams.feedbagDeleteParams {
+				feedbagManager.EXPECT().
+					FeedbagDelete(matchContext(), params.screenName, params.items).
+					Return(params.err)
+			}
+			for _, params := range tc.mockParams.sessionRetrieverParams.retrieveSessionByNameParams {
+				sessionRetriever.EXPECT().
+					RetrieveSession(params.screenName).
+					Return(params.result)
+			}
+			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().
+					RelayToScreenName(matchContext(), params.screenName, params.msg)
+			}
+			for _, params := range tc.mockParams.buddyBroadcasterParams.broadcastVisibilityParams {
+				buddyBroadcaster.EXPECT().
+					BroadcastVisibility(
+						matchContext(),
+						mock.AnythingOfType("*state.SessionInstance"),
+						params.filter,
+						params.sendDepartures,
+					).
+					Return(params.err)
+			}
+
+			deleteFeedbagBuddyHandler(responseRecorder, request, buddyBroadcaster, feedbagManager, sessionRetriever, messageRelayer, slog.Default())
+
+			assert.Equal(t, tc.wantStatusCode, responseRecorder.Code)
+			if tc.wantResponse != "" {
+				assert.JSONEq(t, tc.wantResponse, responseRecorder.Body.String())
+			}
+		})
+	}
+}
+
+func TestRandItemID(t *testing.T) {
+	tt := []struct {
+		name        string
+		randInt     func(n int) int
+		items       []wire.FeedbagItem
+		want        uint16
+		description string
+	}{
+		{
+			name: "empty items list returns random ID",
+			randInt: func(n int) int {
+				return 1000
+			},
+			items:       []wire.FeedbagItem{},
+			want:        1000,
+			description: "When no items exist, should return the random number generated",
+		},
+		{
+			name: "finds next available ID when starting ID conflicts with ItemID",
+			randInt: func(n int) int {
+				return 100
+			},
+			items: []wire.FeedbagItem{
+				{ItemID: 100, GroupID: 1},
+				{ItemID: 101, GroupID: 1},
+			},
+			want:        102,
+			description: "Should skip 100 and 101, return 102",
+		},
+		{
+			name: "finds next available ID when starting ID conflicts with GroupID",
+			randInt: func(n int) int {
+				return 50
+			},
+			items: []wire.FeedbagItem{
+				{ItemID: 1, GroupID: 50},
+				{ItemID: 2, GroupID: 51},
+			},
+			want:        52,
+			description: "Should skip 50 (GroupID) and 51 (GroupID), return 52",
+		},
+		{
+			name: "wraps around and skips 0 to find next available ID",
+			randInt: func(n int) int {
+				return math.MaxUint16 - 2
+			},
+			items: []wire.FeedbagItem{
+				{ItemID: math.MaxUint16 - 2, GroupID: 1},
+				{ItemID: math.MaxUint16 - 1, GroupID: 1},
+				{ItemID: math.MaxUint16, GroupID: 1},
+			},
+			want:        2,
+			description: "When wrapping around, skips 0 (always skipped) and 1 (if conflicts), returns 2",
+		},
+		{
+			name: "skips 0 when starting from 0 and finds next available",
+			randInt: func(n int) int {
+				return 0
+			},
+			items:       []wire.FeedbagItem{},
+			want:        1,
+			description: "When starting from 0, skips 0 (always skipped) and returns 1",
+		},
+		{
+			name: "returns 0 when all IDs are taken",
+			randInt: func(n int) int {
+				return 100
+			},
+			items: func() []wire.FeedbagItem {
+				// Create items that cover all possible IDs
+				items := make([]wire.FeedbagItem, 0, math.MaxUint16+1)
+				for i := 0; i <= math.MaxUint16; i++ {
+					items = append(items, wire.FeedbagItem{
+						ItemID:  uint16(i),
+						GroupID: uint16(i),
+					})
+				}
+				return items
+			}(),
+			want:        0,
+			description: "When all IDs are taken, should return 0",
+		},
+		{
+			name: "finds ID that conflicts with both ItemID and GroupID",
+			randInt: func(n int) int {
+				return 200
+			},
+			items: []wire.FeedbagItem{
+				{ItemID: 200, GroupID: 201},
+				{ItemID: 201, GroupID: 200},
+			},
+			want:        202,
+			description: "Should skip 200 (ItemID) and 201 (both ItemID and GroupID), return 202",
+		},
+		{
+			name: "finds available ID immediately when no conflicts",
+			randInt: func(n int) int {
+				return 500
+			},
+			items: []wire.FeedbagItem{
+				{ItemID: 100, GroupID: 1},
+				{ItemID: 200, GroupID: 2},
+				{ItemID: 300, GroupID: 3},
+			},
+			want:        500,
+			description: "When random ID has no conflicts, should return it immediately",
+		},
+		{
+			name: "handles single conflict and finds next",
+			randInt: func(n int) int {
+				return 42
+			},
+			items: []wire.FeedbagItem{
+				{ItemID: 42, GroupID: 1},
+			},
+			want:        43,
+			description: "Should skip 42 and return 43",
+		},
+		{
+			name: "finds ID before starting point when wrapping",
+			randInt: func(n int) int {
+				return 5
+			},
+			items: []wire.FeedbagItem{
+				{ItemID: 5, GroupID: 1},
+				{ItemID: 6, GroupID: 1},
+				{ItemID: 7, GroupID: 1},
+			},
+			want:        8,
+			description: "Should skip 5, 6, 7 and return 8",
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			got := randItemID(tc.randInt, tc.items)
+			if got != tc.want {
+				t.Errorf("randItemID() = %d, want %d. %s", got, tc.want, tc.description)
+			}
+		})
+	}
+}

+ 108 - 0
server/http/mock_buddy_broadcaster_test.go

@@ -0,0 +1,108 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package http
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/state"
+	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}
+}
+
+// BroadcastVisibility provides a mock function for the type mockBuddyBroadcaster
+func (_mock *mockBuddyBroadcaster) BroadcastVisibility(ctx context.Context, you *state.SessionInstance, filter []state.IdentScreenName, sendDepartures bool) error {
+	ret := _mock.Called(ctx, you, filter, sendDepartures)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BroadcastVisibility")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, []state.IdentScreenName, bool) error); ok {
+		r0 = returnFunc(ctx, you, filter, sendDepartures)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockBuddyBroadcaster_BroadcastVisibility_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BroadcastVisibility'
+type mockBuddyBroadcaster_BroadcastVisibility_Call struct {
+	*mock.Call
+}
+
+// BroadcastVisibility is a helper method to define mock.On call
+//   - ctx context.Context
+//   - you *state.SessionInstance
+//   - filter []state.IdentScreenName
+//   - sendDepartures bool
+func (_e *mockBuddyBroadcaster_Expecter) BroadcastVisibility(ctx interface{}, you interface{}, filter interface{}, sendDepartures interface{}) *mockBuddyBroadcaster_BroadcastVisibility_Call {
+	return &mockBuddyBroadcaster_BroadcastVisibility_Call{Call: _e.mock.On("BroadcastVisibility", ctx, you, filter, sendDepartures)}
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastVisibility_Call) Run(run func(ctx context.Context, you *state.SessionInstance, filter []state.IdentScreenName, sendDepartures bool)) *mockBuddyBroadcaster_BroadcastVisibility_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 []state.IdentScreenName
+		if args[2] != nil {
+			arg2 = args[2].([]state.IdentScreenName)
+		}
+		var arg3 bool
+		if args[3] != nil {
+			arg3 = args[3].(bool)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastVisibility_Call) Return(err error) *mockBuddyBroadcaster_BroadcastVisibility_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastVisibility_Call) RunAndReturn(run func(ctx context.Context, you *state.SessionInstance, filter []state.IdentScreenName, sendDepartures bool) error) *mockBuddyBroadcaster_BroadcastVisibility_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 178 - 0
server/http/mock_feedbag_manager_test.go

@@ -0,0 +1,178 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package http
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockFeedbagManager creates a new instance of mockFeedbagManager. 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 newMockFeedbagManager(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockFeedbagManager {
+	mock := &mockFeedbagManager{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockFeedbagManager is an autogenerated mock type for the FeedbagManager type
+type mockFeedbagManager struct {
+	mock.Mock
+}
+
+type mockFeedbagManager_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockFeedbagManager) EXPECT() *mockFeedbagManager_Expecter {
+	return &mockFeedbagManager_Expecter{mock: &_m.Mock}
+}
+
+// Feedbag provides a mock function for the type mockFeedbagManager
+func (_mock *mockFeedbagManager) Feedbag(ctx context.Context, screenName state.IdentScreenName) ([]wire.FeedbagItem, error) {
+	ret := _mock.Called(ctx, screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Feedbag")
+	}
+
+	var r0 []wire.FeedbagItem
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) ([]wire.FeedbagItem, error)); ok {
+		return returnFunc(ctx, screenName)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) []wire.FeedbagItem); ok {
+		r0 = returnFunc(ctx, screenName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]wire.FeedbagItem)
+		}
+	}
+	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
+}
+
+// mockFeedbagManager_Feedbag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Feedbag'
+type mockFeedbagManager_Feedbag_Call struct {
+	*mock.Call
+}
+
+// Feedbag is a helper method to define mock.On call
+//   - ctx context.Context
+//   - screenName state.IdentScreenName
+func (_e *mockFeedbagManager_Expecter) Feedbag(ctx interface{}, screenName interface{}) *mockFeedbagManager_Feedbag_Call {
+	return &mockFeedbagManager_Feedbag_Call{Call: _e.mock.On("Feedbag", ctx, screenName)}
+}
+
+func (_c *mockFeedbagManager_Feedbag_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName)) *mockFeedbagManager_Feedbag_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockFeedbagManager_Feedbag_Call) Return(result []wire.FeedbagItem, err error) *mockFeedbagManager_Feedbag_Call {
+	_c.Call.Return(result, err)
+	return _c
+}
+
+// FeedbagUpsert provides a mock function for the type mockFeedbagManager
+func (_mock *mockFeedbagManager) FeedbagUpsert(ctx context.Context, screenName state.IdentScreenName, items []wire.FeedbagItem) error {
+	ret := _mock.Called(ctx, screenName, items)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FeedbagUpsert")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, []wire.FeedbagItem) error); ok {
+		return returnFunc(ctx, screenName, items)
+	}
+	if ret.Get(0) != nil {
+		r0 = ret.Get(0).(error)
+	}
+	return r0
+}
+
+// mockFeedbagManager_FeedbagUpsert_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FeedbagUpsert'
+type mockFeedbagManager_FeedbagUpsert_Call struct {
+	*mock.Call
+}
+
+// FeedbagUpsert is a helper method to define mock.On call
+//   - ctx context.Context
+//   - screenName state.IdentScreenName
+//   - items []wire.FeedbagItem
+func (_e *mockFeedbagManager_Expecter) FeedbagUpsert(ctx interface{}, screenName interface{}, items interface{}) *mockFeedbagManager_FeedbagUpsert_Call {
+	return &mockFeedbagManager_FeedbagUpsert_Call{Call: _e.mock.On("FeedbagUpsert", ctx, screenName, items)}
+}
+
+func (_c *mockFeedbagManager_FeedbagUpsert_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, items []wire.FeedbagItem)) *mockFeedbagManager_FeedbagUpsert_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(state.IdentScreenName), args[2].([]wire.FeedbagItem))
+	})
+	return _c
+}
+
+func (_c *mockFeedbagManager_FeedbagUpsert_Call) Return(err error) *mockFeedbagManager_FeedbagUpsert_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+// FeedbagDelete provides a mock function for the type mockFeedbagManager
+func (_mock *mockFeedbagManager) FeedbagDelete(ctx context.Context, screenName state.IdentScreenName, items []wire.FeedbagItem) error {
+	ret := _mock.Called(ctx, screenName, items)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FeedbagDelete")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, []wire.FeedbagItem) error); ok {
+		return returnFunc(ctx, screenName, items)
+	}
+	if ret.Get(0) != nil {
+		r0 = ret.Get(0).(error)
+	}
+	return r0
+}
+
+// mockFeedbagManager_FeedbagDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FeedbagDelete'
+type mockFeedbagManager_FeedbagDelete_Call struct {
+	*mock.Call
+}
+
+// FeedbagDelete is a helper method to define mock.On call
+//   - ctx context.Context
+//   - screenName state.IdentScreenName
+//   - items []wire.FeedbagItem
+func (_e *mockFeedbagManager_Expecter) FeedbagDelete(ctx interface{}, screenName interface{}, items interface{}) *mockFeedbagManager_FeedbagDelete_Call {
+	return &mockFeedbagManager_FeedbagDelete_Call{Call: _e.mock.On("FeedbagDelete", ctx, screenName, items)}
+}
+
+func (_c *mockFeedbagManager_FeedbagDelete_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, items []wire.FeedbagItem)) *mockFeedbagManager_FeedbagDelete_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(state.IdentScreenName), args[2].([]wire.FeedbagItem))
+	})
+	return _c
+}
+
+func (_c *mockFeedbagManager_FeedbagDelete_Call) Return(err error) *mockFeedbagManager_FeedbagDelete_Call {
+	_c.Call.Return(err)
+	return _c
+}

+ 20 - 0
server/http/types.go

@@ -47,6 +47,13 @@ type BARTAssetManager interface {
 	DeleteBARTItem(ctx context.Context, hash []byte) error
 }
 
+// BuddyBroadcaster defines a method for broadcasting presence updates.
+type BuddyBroadcaster interface {
+	// BroadcastVisibility sends presence updates to the specified filter list.
+	// If sendDepartures is true, departure events are sent as well.
+	BroadcastVisibility(ctx context.Context, you *state.SessionInstance, filter []state.IdentScreenName, sendDepartures bool) error
+}
+
 // ChatRoomCreator defines a method for creating a new chat room.
 type ChatRoomCreator interface {
 	// CreateChatRoom creates a new chat room.
@@ -102,6 +109,19 @@ type FeedBagRetriever interface {
 	BuddyIconMetadata(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error)
 }
 
+// FeedbagManager defines methods for managing feedbag (buddy list) entries.
+// This interface matches foodgroup.FeedbagManager and is implemented by state.SQLiteUserStore.
+type FeedbagManager interface {
+	// Feedbag retrieves all feedbag items for a user.
+	Feedbag(ctx context.Context, screenName state.IdentScreenName) ([]wire.FeedbagItem, error)
+
+	// FeedbagUpsert inserts or updates feedbag items.
+	FeedbagUpsert(ctx context.Context, screenName state.IdentScreenName, items []wire.FeedbagItem) error
+
+	// FeedbagDelete deletes feedbag items.
+	FeedbagDelete(ctx context.Context, screenName state.IdentScreenName, items []wire.FeedbagItem) error
+}
+
 // MessageRelayer defines a method for sending a SNAC message to a specific screen name.
 type MessageRelayer interface {
 	// RelayToScreenName sends the given SNAC message to the specified screen name.

+ 1 - 0
server/oscar/server.go

@@ -252,6 +252,7 @@ func (s oscarServer) connectToOSCARService(
 		instance, err = s.AuthService.RegisterBOSSession(ctx, cookie)
 		if err != nil {
 			if errors.Is(err, state.ErrMaxConcurrentSessionsReached) {
+				s.Logger.Debug("session registration failed", "err", err.Error())
 				block := wire.TLVRestBlock{}
 				// error code indicating the signon is blocked. i can't find a
 				// more appropriate error code to indicate the maximum session limit is reached

+ 4 - 4
server/oscar/server_test.go

@@ -396,7 +396,6 @@ func TestOscarServer_RouteConnection_BOS(t *testing.T) {
 			SubGroup:  wire.OServiceClientOnline,
 		}
 		assert.NoError(t, flapc.SendSNAC(frame, struct{}{}))
-		assert.NoError(t, clientConn.Close())
 	}()
 
 	wg := &sync.WaitGroup{}
@@ -447,6 +446,7 @@ func TestOscarServer_RouteConnection_BOS(t *testing.T) {
 	wg.Add(2)
 	handler := func(ctx context.Context, serverType uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, listener config.Listener) error {
 		defer wg.Done()
+		assert.NoError(t, clientConn.Close())
 		return nil
 	}
 
@@ -520,7 +520,6 @@ func TestOscarServer_RouteConnection_BOS_MultiSessionSignoff(t *testing.T) {
 			SubGroup:  wire.OServiceClientOnline,
 		}
 		assert.NoError(t, flapc.SendSNAC(frame, struct{}{}))
-		assert.NoError(t, clientConn.Close())
 	}()
 
 	wg := &sync.WaitGroup{}
@@ -560,6 +559,7 @@ func TestOscarServer_RouteConnection_BOS_MultiSessionSignoff(t *testing.T) {
 	wg.Add(2)
 	handler := func(ctx context.Context, serverType uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, listener config.Listener) error {
 		defer wg.Done()
+		assert.NoError(t, clientConn.Close())
 		return nil
 	}
 
@@ -692,7 +692,6 @@ func TestOscarServer_RouteConnection_Chat(t *testing.T) {
 			SubGroup:  wire.OServiceClientOnline,
 		}
 		assert.NoError(t, flapc.SendSNAC(frame, struct{}{}))
-		assert.NoError(t, clientConn.Close())
 	}()
 
 	wg := &sync.WaitGroup{}
@@ -730,6 +729,7 @@ func TestOscarServer_RouteConnection_Chat(t *testing.T) {
 	wg.Add(1)
 	handler := func(ctx context.Context, serverType uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, listener config.Listener) error {
 		defer wg.Done()
+		assert.NoError(t, clientConn.Close())
 		return nil
 	}
 
@@ -794,7 +794,6 @@ func TestOscarServer_RouteConnection_Admin(t *testing.T) {
 			SubGroup:  wire.OServiceClientOnline,
 		}
 		assert.NoError(t, flapc.SendSNAC(frame, struct{}{}))
-		assert.NoError(t, clientConn.Close())
 	}()
 
 	wg := &sync.WaitGroup{}
@@ -825,6 +824,7 @@ func TestOscarServer_RouteConnection_Admin(t *testing.T) {
 	wg.Add(1)
 	handler := func(ctx context.Context, serverType uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, listener config.Listener) error {
 		defer wg.Done()
+		assert.NoError(t, clientConn.Close())
 		return nil
 	}
 

+ 1 - 0
server/toc/cmd_client.go

@@ -129,6 +129,7 @@ type OSCARProxy struct {
 	OServiceService   OServiceService
 	PermitDenyService PermitDenyService
 	TOCConfigStore    TOCConfigStore
+	SessionRetriever  SessionRetriever
 	SNACRateLimits    wire.SNACRateLimits
 	HTTPIPRateLimiter *IPRateLimiter
 }

+ 10 - 0
server/toc/helpers_test.go

@@ -247,6 +247,15 @@ type userParams []struct {
 	err          error
 }
 
+type retrieveSessionParams []struct {
+	screenName      state.IdentScreenName
+	returnedSession *state.Session
+}
+
+type sessionRetrieverParams struct {
+	retrieveSessionParams
+}
+
 type tocConfigParams struct {
 	setTOCConfigParams
 	userParams
@@ -265,6 +274,7 @@ type mockParams struct {
 	locateParams
 	oServiceParams
 	permitDenyParams
+	sessionRetrieverParams
 	tocConfigParams
 }
 

+ 16 - 3
server/toc/http.go

@@ -31,6 +31,7 @@ const directoryTpl = `
 <TABLE>
 {{- range .Results -}}
 <TR><TD>
+<B>Screen Name:</B> {{.ScreenName}}<BR>
 {{- if .FirstName}}<B>First Name:</B> {{.FirstName}}<BR>{{- end -}}
 {{- if .MiddleName}}<B>Middle Name:</B> {{.MiddleName}}<BR>{{- end -}}
 {{- if .LastName}}<B>Last Name:</B> {{.LastName}}<BR>{{- end -}}
@@ -151,15 +152,25 @@ func (s OSCARProxy) ProfileHandler(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	sess := state.NewSession().AddInstance()
-	sess.Session().SetIdentScreenName(state.NewIdentScreenName(from))
+	sess := s.SessionRetriever.RetrieveSession(state.NewIdentScreenName(from))
+	if sess == nil {
+		http.Error(w, "invalid session", http.StatusForbidden)
+		return
+	}
+
+	instances := sess.Instances()
+	if len(instances) == 0 {
+		http.Error(w, "invalid session", http.StatusForbidden)
+		return
+	}
+
 	inBody := wire.SNAC_0x02_0x05_LocateUserInfoQuery{
 		Type:       uint16(wire.LocateTypeSig),
 		ScreenName: user,
 	}
 
 	ctx := r.Context()
-	info, err := s.LocateService.UserInfoQuery(ctx, sess, wire.SNACFrame{}, inBody)
+	info, err := s.LocateService.UserInfoQuery(ctx, instances[0], wire.SNACFrame{}, inBody)
 	if err != nil {
 		s.logAndReturn500(ctx, w, fmt.Errorf("LocateService.UserInfoQuery: %w", err))
 		return
@@ -319,6 +330,7 @@ func (s OSCARProxy) outputSearchResults(ctx context.Context, w http.ResponseWrit
 		NickName   string
 		ZIP        string
 		Address    string
+		ScreenName string
 	}
 	type PageData struct {
 		Results []DirSearchResult
@@ -327,6 +339,7 @@ func (s OSCARProxy) outputSearchResults(ctx context.Context, w http.ResponseWrit
 	results := make([]DirSearchResult, 0, len(users))
 	for _, result := range users {
 		rec := DirSearchResult{}
+		rec.ScreenName, _ = result.String(wire.ODirTLVScreenName)
 		rec.FirstName, _ = result.String(wire.ODirTLVFirstName)
 		rec.MiddleName, _ = result.String(wire.ODirTLVMiddleName)
 		rec.LastName, _ = result.String(wire.ODirTLVLastName)

+ 55 - 0
server/toc/http_test.go

@@ -72,6 +72,14 @@ func TestOSCARProxy_NewServeMux(t *testing.T) {
 						},
 					},
 				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams: retrieveSessionParams{
+						{
+							screenName:      state.NewIdentScreenName("me"),
+							returnedSession: newTestSession("me").Session(),
+						},
+					},
+				},
 			},
 		},
 		{
@@ -105,6 +113,14 @@ func TestOSCARProxy_NewServeMux(t *testing.T) {
 						},
 					},
 				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams: retrieveSessionParams{
+						{
+							screenName:      state.NewIdentScreenName("me"),
+							returnedSession: newTestSession("me").Session(),
+						},
+					},
+				},
 			},
 		},
 		{
@@ -165,6 +181,14 @@ func TestOSCARProxy_NewServeMux(t *testing.T) {
 						},
 					},
 				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams: retrieveSessionParams{
+						{
+							screenName:      state.NewIdentScreenName("me"),
+							returnedSession: newTestSession("me").Session(),
+						},
+					},
+				},
 			},
 		},
 		{
@@ -191,6 +215,14 @@ func TestOSCARProxy_NewServeMux(t *testing.T) {
 						},
 					},
 				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams: retrieveSessionParams{
+						{
+							screenName:      state.NewIdentScreenName("me"),
+							returnedSession: newTestSession("me").Session(),
+						},
+					},
+				},
 			},
 		},
 		{
@@ -219,6 +251,14 @@ func TestOSCARProxy_NewServeMux(t *testing.T) {
 						},
 					},
 				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams: retrieveSessionParams{
+						{
+							screenName:      state.NewIdentScreenName("me"),
+							returnedSession: newTestSession("me").Session(),
+						},
+					},
+				},
 			},
 		},
 		{
@@ -247,6 +287,14 @@ func TestOSCARProxy_NewServeMux(t *testing.T) {
 						},
 					},
 				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams: retrieveSessionParams{
+						{
+							screenName:      state.NewIdentScreenName("me"),
+							returnedSession: newTestSession("me").Session(),
+						},
+					},
+				},
 			},
 		},
 		{
@@ -622,12 +670,19 @@ func TestOSCARProxy_NewServeMux(t *testing.T) {
 					InfoQuery(mock.Anything, wire.SNACFrame{}, params.inBody).
 					Return(params.msg, params.err)
 			}
+			sessionRetriever := newMockSessionRetriever(t)
+			for _, params := range tc.mockParams.retrieveSessionParams {
+				sessionRetriever.EXPECT().
+					RetrieveSession(params.screenName).
+					Return(params.returnedSession)
+			}
 
 			svc := OSCARProxy{
 				CookieBaker:       cookieBaker,
 				DirSearchService:  dirSearchSvc,
 				LocateService:     locateSvc,
 				Logger:            slog.Default(),
+				SessionRetriever:  sessionRetriever,
 				HTTPIPRateLimiter: NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 				SNACRateLimits:    wire.DefaultSNACRateLimits(),
 			}

+ 90 - 0
server/toc/mock_session_retriever_test.go

@@ -0,0 +1,90 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package toc
+
+import (
+	"github.com/mk6i/open-oscar-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockSessionRetriever creates a new instance of mockSessionRetriever. 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 newMockSessionRetriever(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockSessionRetriever {
+	mock := &mockSessionRetriever{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockSessionRetriever is an autogenerated mock type for the SessionRetriever type
+type mockSessionRetriever struct {
+	mock.Mock
+}
+
+type mockSessionRetriever_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockSessionRetriever) EXPECT() *mockSessionRetriever_Expecter {
+	return &mockSessionRetriever_Expecter{mock: &_m.Mock}
+}
+
+// RetrieveSession provides a mock function for the type mockSessionRetriever
+func (_mock *mockSessionRetriever) RetrieveSession(screenName state.IdentScreenName) *state.Session {
+	ret := _mock.Called(screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RetrieveSession")
+	}
+
+	var r0 *state.Session
+	if returnFunc, ok := ret.Get(0).(func(state.IdentScreenName) *state.Session); ok {
+		r0 = returnFunc(screenName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*state.Session)
+		}
+	}
+	return r0
+}
+
+// mockSessionRetriever_RetrieveSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveSession'
+type mockSessionRetriever_RetrieveSession_Call struct {
+	*mock.Call
+}
+
+// RetrieveSession is a helper method to define mock.On call
+//   - screenName state.IdentScreenName
+func (_e *mockSessionRetriever_Expecter) RetrieveSession(screenName interface{}) *mockSessionRetriever_RetrieveSession_Call {
+	return &mockSessionRetriever_RetrieveSession_Call{Call: _e.mock.On("RetrieveSession", screenName)}
+}
+
+func (_c *mockSessionRetriever_RetrieveSession_Call) Run(run func(screenName state.IdentScreenName)) *mockSessionRetriever_RetrieveSession_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 state.IdentScreenName
+		if args[0] != nil {
+			arg0 = args[0].(state.IdentScreenName)
+		}
+		run(
+			arg0,
+		)
+	})
+	return _c
+}
+
+func (_c *mockSessionRetriever_RetrieveSession_Call) Return(session *state.Session) *mockSessionRetriever_RetrieveSession_Call {
+	_c.Call.Return(session)
+	return _c
+}
+
+func (_c *mockSessionRetriever_RetrieveSession_Call) RunAndReturn(run func(screenName state.IdentScreenName) *state.Session) *mockSessionRetriever_RetrieveSession_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 1 - 1
server/toc/server.go

@@ -76,7 +76,7 @@ func (l *channelListener) Accept() (net.Conn, error) {
 	case <-l.ctx.Done():
 		return nil, io.EOF
 	case ch := <-l.ch:
-		return ch, io.EOF
+		return ch, nil
 	}
 }
 

+ 9 - 0
server/toc/types.go

@@ -104,3 +104,12 @@ type CookieBaker interface {
 type AdminService interface {
 	InfoChangeRequest(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x07_0x04_AdminInfoChangeRequest) (wire.SNACMessage, error)
 }
+
+// SessionRetriever defines a method for retrieving an active session
+// associated with a given screen name.
+type SessionRetriever interface {
+	// RetrieveSession returns the session associated with the given screen name,
+	// or nil if no active session exists. Returns the Session object if there
+	// are active instances with complete signon.
+	RetrieveSession(screenName state.IdentScreenName) *state.Session
+}

+ 2 - 3
state/session.go

@@ -1215,15 +1215,14 @@ func (s *SessionInstance) SetRemoteAddr(remoteAddr *netip.AddrPort) {
 	s.remoteAddr = remoteAddr
 }
 
-// SetUserInfoFlag sets a flag in the user info bitmask.
-func (s *SessionInstance) SetUserInfoFlag(flag uint16) (flags uint16) {
+// SetUserInfoFlag sets a flag on the user info bitmask.
+func (s *SessionInstance) SetUserInfoFlag(flag uint16) {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
 	if flag == wire.OServiceUserFlagUnavailable {
 		s.awayTime = s.session.nowFn()
 	}
 	s.userInfoBitmask |= flag
-	return s.userInfoBitmask
 }
 
 // SetUserStatusBitmask sets the user status bitmask.

+ 1 - 1
state/session_manager.go

@@ -194,7 +194,7 @@ func (s *InMemorySessionManager) AddSession(ctx context.Context, screenName Disp
 
 			// Check if we've reached the maximum number of concurrent sessions
 			if active.session.InstanceCount() >= s.maxConcurrentSessions {
-				return nil, ErrMaxConcurrentSessionsReached
+				return nil, fmt.Errorf("%w: max instance(s) = %d", ErrMaxConcurrentSessionsReached, s.maxConcurrentSessions)
 			}
 
 			instance := active.session.AddInstance()

+ 5 - 0
wire/snacs.go

@@ -2677,6 +2677,11 @@ func (t TLVUserInfo) IsAway() bool {
 	return flags&OServiceUserFlagUnavailable == OServiceUserFlagUnavailable
 }
 
+func (t TLVUserInfo) IsInvisible() bool {
+	mask, _ := t.Uint32BE(OServiceUserInfoStatus)
+	return mask&OServiceUserStatusInvisible == OServiceUserStatusInvisible
+}
+
 type FeedbagItem struct {
 	Name    string `oscar:"len_prefix=uint16"`
 	GroupID uint16

+ 1 - 0
wire/snacs_string.go

@@ -1,6 +1,7 @@
 package wire
 
 var foodGroupName = map[uint16]string{
+	BOS:         "BOS",
 	OService:    "OService",
 	Locate:      "Locate",
 	Buddy:       "Buddy",

+ 86 - 0
wire/snacs_test.go

@@ -274,6 +274,92 @@ func TestStripHTML(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			result := StripHTML(tt.input)
 			assert.Equal(t, tt.expected, result)
+func TestTLVUserInfo_IsAway(t *testing.T) {
+	tests := []struct {
+		name     string
+		userInfo TLVUserInfo
+		want     bool
+	}{
+		{
+			name: "flag is set",
+			userInfo: TLVUserInfo{
+				TLVBlock: TLVBlock{
+					TLVList: TLVList{
+						NewTLVBE(OServiceUserInfoUserFlags, OServiceUserFlagUnavailable),
+					},
+				},
+			},
+			want: true,
+		},
+		{
+			name: "flag is not set",
+			userInfo: TLVUserInfo{
+				TLVBlock: TLVBlock{
+					TLVList: TLVList{
+						NewTLVBE(OServiceUserInfoUserFlags, OServiceUserFlagOSCARFree),
+					},
+				},
+			},
+			want: false,
+		},
+		{
+			name: "TLV is missing",
+			userInfo: TLVUserInfo{
+				TLVBlock: TLVBlock{
+					TLVList: TLVList{},
+				},
+			},
+			want: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.want, tt.userInfo.IsAway())
+		})
+	}
+}
+
+func TestTLVUserInfo_IsInvisible(t *testing.T) {
+	tests := []struct {
+		name     string
+		userInfo TLVUserInfo
+		want     bool
+	}{
+		{
+			name: "status mask has invisible bit set",
+			userInfo: TLVUserInfo{
+				TLVBlock: TLVBlock{
+					TLVList: TLVList{
+						NewTLVBE(OServiceUserInfoStatus, OServiceUserStatusInvisible),
+					},
+				},
+			},
+			want: true,
+		},
+		{
+			name: "status mask does not have invisible bit set",
+			userInfo: TLVUserInfo{
+				TLVBlock: TLVBlock{
+					TLVList: TLVList{
+						NewTLVBE(OServiceUserInfoStatus, OServiceUserStatusAvailable),
+					},
+				},
+			},
+			want: false,
+		},
+		{
+			name: "TLV is missing",
+			userInfo: TLVUserInfo{
+				TLVBlock: TLVBlock{
+					TLVList: TLVList{},
+				},
+			},
+			want: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.want, tt.userInfo.IsInvisible())
 		})
 	}
 }