Selaa lähdekoodia

implement SNAC(0x13,0x14) FEEDBAG__PRE_AUTHORIZE_BUDDY with authorization checks

Mike 2 kuukautta sitten
vanhempi
commit
fbf027633e

+ 3 - 0
.mockery.yaml

@@ -145,6 +145,9 @@ packages:
       ClientSideBuddyListManager:
         config:
           filename: "mock_client_side_buddy_list_manager_test.go"
+      ContactPreAuthorizer:
+        config:
+          filename: "mock_contact_pre_authorizer_test.go"
       MessageRelayer:
         config:
           filename: "mock_message_relayer_test.go"

+ 26 - 15
cmd/server/factory.go

@@ -40,7 +40,8 @@ type Container struct {
 	sqLiteUserStore        *state.SQLiteUserStore
 	webAPISessionManager   *state.WebAPISessionManager
 	Listeners              []config.Listener
-	feedbagSvc             foodgroup.FeedbagService
+	feedbagSvc             *foodgroup.FeedbagService
+	icqService             *foodgroup.ICQService
 }
 
 // MakeCommonDeps creates common dependencies used by the food group services.
@@ -82,6 +83,16 @@ func MakeCommonDeps() (Container, error) {
 	c.rateLimitClasses = wire.DefaultRateLimitClasses()
 	c.snacRateLimits = wire.DefaultSNACRateLimits()
 
+	c.feedbagSvc = foodgroup.NewFeedbagService(
+		c.logger,
+		c.inMemorySessionManager,
+		c.sqLiteUserStore,
+		c.sqLiteUserStore,
+		c.sqLiteUserStore,
+		c.inMemorySessionManager,
+		c.sqLiteUserStore,
+	)
+
 	c.icbmSvc = foodgroup.NewICBMService(
 		c.sqLiteUserStore,
 		c.inMemorySessionManager,
@@ -90,21 +101,24 @@ func MakeCommonDeps() (Container, error) {
 		c.inMemorySessionManager,
 		c.sqLiteUserStore,
 		c.sqLiteUserStore,
+		c.sqLiteUserStore,
 		c.snacRateLimits,
 		c.logger,
 	)
 
-	c.feedbagSvc = foodgroup.NewFeedbagService(
-		c.logger,
+	c.icqService = foodgroup.NewICQService(
 		c.inMemorySessionManager,
 		c.sqLiteUserStore,
 		c.sqLiteUserStore,
-		c.sqLiteUserStore,
+		c.logger,
 		c.inMemorySessionManager,
 		c.sqLiteUserStore,
-		c.icbmSvc.ChannelMsgToHost,
 	)
 
+	c.feedbagSvc.BridgeICBMService(c.icbmSvc)
+	c.icbmSvc.BridgeFeedbagService(c.feedbagSvc)
+	c.icqService.BridgeFeedbagService(c.feedbagSvc)
+
 	return c, nil
 }
 
@@ -265,6 +279,7 @@ func OSCAR(deps Container) *oscar.Server {
 		deps.sqLiteUserStore,
 		deps.inMemorySessionManager,
 		deps.sqLiteUserStore,
+		deps.sqLiteUserStore,
 	)
 	chatService := foodgroup.NewChatService(deps.chatSessionManager)
 	chatNavService := foodgroup.NewChatNavService(logger, deps.sqLiteUserStore)
@@ -275,15 +290,6 @@ func OSCAR(deps Container) *oscar.Server {
 		deps.inMemorySessionManager,
 		deps.inMemorySessionManager,
 	)
-	icqService := foodgroup.NewICQService(
-		deps.inMemorySessionManager,
-		deps.sqLiteUserStore,
-		deps.sqLiteUserStore,
-		logger,
-		deps.inMemorySessionManager,
-		deps.sqLiteUserStore,
-		deps.icbmSvc.ChannelMsgToHost,
-	)
 	locateService := foodgroup.NewLocateService(
 		deps.sqLiteUserStore,
 		deps.inMemorySessionManager,
@@ -329,7 +335,7 @@ func OSCAR(deps Container) *oscar.Server {
 			ChatService:       chatService,
 			FeedbagService:    deps.feedbagSvc,
 			ICBMService:       deps.icbmSvc,
-			ICQService:        icqService,
+			ICQService:        deps.icqService,
 			LocateService:     locateService,
 			ODirService:       oDirService,
 			OServiceService:   oServiceService,
@@ -383,6 +389,7 @@ func MgmtAPI(deps Container) *http.Server {
 		deps.sqLiteUserStore,
 		deps.inMemorySessionManager,
 		deps.sqLiteUserStore,
+		deps.sqLiteUserStore,
 	)
 	return http.NewManagementAPI(
 		bld,
@@ -445,6 +452,7 @@ func TOC(deps Container) *toc.Server {
 				deps.sqLiteUserStore,
 				deps.inMemorySessionManager,
 				deps.sqLiteUserStore,
+				deps.sqLiteUserStore,
 			),
 			ChatSessionManager: deps.chatSessionManager,
 			CookieBaker:        deps.hmacCookieBaker,
@@ -519,6 +527,7 @@ func WebAPI(deps Container) *webapi.Server {
 		deps.sqLiteUserStore,
 		deps.inMemorySessionManager,
 		deps.sqLiteUserStore,
+		deps.sqLiteUserStore,
 	)
 
 	handler := webapi.Handler{
@@ -551,6 +560,7 @@ func WebAPI(deps Container) *webapi.Server {
 			deps.sqLiteUserStore,
 			deps.inMemorySessionManager,
 			deps.sqLiteUserStore,
+			deps.sqLiteUserStore,
 		),
 		CookieBaker:      deps.hmacCookieBaker,
 		DirSearchService: foodgroup.NewODirService(logger, deps.sqLiteUserStore),
@@ -640,6 +650,7 @@ func ICQLegacy(deps Container) *icq_legacy.LegacyServer {
 			deps.sqLiteUserStore,
 			deps.inMemorySessionManager,
 			deps.sqLiteUserStore,
+			deps.sqLiteUserStore,
 		),
 		deps.sqLiteUserStore, // offlineMessageManager
 		deps.sqLiteUserStore, // userFinder

+ 44 - 14
foodgroup/buddy.go

@@ -15,10 +15,12 @@ func NewBuddyService(
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
 	bartItemManager BARTItemManager,
+	contactPreAuthorizer ContactPreAuthorizer,
 ) *BuddyService {
 	return &BuddyService{
 		buddyBroadcaster:           newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		clientSideBuddyListManager: clientSideBuddyListManager,
+		contactPreAuthorizer:       contactPreAuthorizer,
 	}
 }
 
@@ -26,6 +28,7 @@ func NewBuddyService(
 type BuddyService struct {
 	clientSideBuddyListManager ClientSideBuddyListManager
 	buddyBroadcaster           buddyBroadcaster
+	contactPreAuthorizer       ContactPreAuthorizer
 }
 
 // RightsQuery returns buddy list service parameters.
@@ -50,35 +53,62 @@ func (s BuddyService) RightsQuery(_ context.Context, frameIn wire.SNACFrame) wir
 }
 
 // AddBuddies adds buddies to my client-side buddy list.
-func (s BuddyService) AddBuddies(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) error {
+// It returns a single SNAC(03,0x0A) listing buddies rejected for lack of
+// pre-authorization (ICQ UIN contacts only), or nil when none were rejected.
+func (s BuddyService) AddBuddies(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) (*wire.SNACMessage, error) {
+	var rejected []struct {
+		ScreenName string `oscar:"len_prefix=uint8"`
+	}
+	var added []state.IdentScreenName
 
 	for _, entry := range inBody.Buddies {
-		sn := state.NewIdentScreenName(entry.ScreenName)
-		if err := s.clientSideBuddyListManager.AddBuddy(ctx, instance.IdentScreenName(), sn); err != nil {
-			return err
+		them := state.NewIdentScreenName(entry.ScreenName)
+		if them.UIN() != 0 {
+			blocked, err := s.contactPreAuthorizer.RequiresAuthorization(ctx, them, instance.IdentScreenName())
+			if err != nil {
+				return nil, err
+			}
+			if blocked {
+				rejected = append(rejected, struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{ScreenName: entry.ScreenName})
+				continue
+			}
+		}
+		if err := s.clientSideBuddyListManager.AddBuddy(ctx, instance.IdentScreenName(), them); err != nil {
+			return nil, err
 		}
+		added = append(added, them)
 	}
 
 	if !instance.SignonComplete() {
 		// client has not completed sign-on sequence, so any arrival
 		// messages sent at this point would be ignored by the client.
-		return nil
+		return nil, nil
 	}
 
-	var toNotify []state.IdentScreenName
-	for _, entry := range inBody.Buddies {
-		toNotify = append(toNotify, state.NewIdentScreenName(entry.ScreenName))
+	if len(added) > 0 {
+		if err := s.buddyBroadcaster.BroadcastVisibility(ctx, instance, added, true); err != nil {
+			return nil, fmt.Errorf("buddyBroadcaster.BroadcastVisibility: %w", err)
+		}
 	}
-	if err := s.buddyBroadcaster.BroadcastVisibility(ctx, instance, toNotify, true); err != nil {
-		return fmt.Errorf("buddyBroadcaster.BroadcastVisibility: %w", err)
+
+	if len(rejected) > 0 {
+		return &wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Buddy,
+				SubGroup:  wire.BuddyRejectNotification,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNAC_0x03_0x0A_BuddyRejectNotification{Buddies: rejected},
+		}, nil
 	}
 
-	return nil
+	return nil, nil
 }
 
 // DelBuddies deletes buddies from my client-side buddy list.
 func (s BuddyService) DelBuddies(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x05_BuddyDelBuddies) error {
-
 	var toNotify []state.IdentScreenName
 
 	for _, entry := range inBody.Buddies {
@@ -98,7 +128,7 @@ func (s BuddyService) DelBuddies(ctx context.Context, instance *state.SessionIns
 
 // AddTempBuddies adds temporary buddies to the user's buddy list that persist
 // for the duration of the user's session.
-func (s BuddyService) AddTempBuddies(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x0F_BuddyAddTempBuddies) error {
+func (s BuddyService) AddTempBuddies(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x0F_BuddyAddTempBuddies) (*wire.SNACMessage, error) {
 	var b wire.SNAC_0x03_0x04_BuddyAddBuddies
 
 	for _, buddy := range inBody.Buddies {
@@ -107,7 +137,7 @@ func (s BuddyService) AddTempBuddies(ctx context.Context, instance *state.Sessio
 		}{ScreenName: buddy.ScreenName})
 	}
 
-	return s.AddBuddies(ctx, instance, b)
+	return s.AddBuddies(ctx, instance, inFrame, b)
 }
 
 // DelTempBuddies deletes temporary buddies from the user's buddy list.

+ 142 - 5
foodgroup/buddy_test.go

@@ -12,7 +12,7 @@ import (
 )
 
 func TestBuddyService_RightsQuery(t *testing.T) {
-	svc := NewBuddyService(nil, nil, nil, nil, nil)
+	svc := NewBuddyService(nil, nil, nil, nil, nil, newMockContactPreAuthorizer(t))
 
 	want := wire.SNACMessage{
 		Frame: wire.SNACFrame{
@@ -47,6 +47,8 @@ func TestBuddyService_AddBuddies(t *testing.T) {
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		mockParams mockParams
+		// wantSNAC is the expected SNAC returned when buddies are rejected
+		wantSNAC *wire.SNACMessage
 		// wantErr is the expected error
 		wantErr error
 	}{
@@ -121,6 +123,129 @@ func TestBuddyService_AddBuddies(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:     "UIN blocked by pre-authorization",
+			instance: newTestInstance("100002", sessOptSignonComplete),
+			bodyIn: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+				Buddies: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{ScreenName: "100001"},
+				},
+			},
+			mockParams: mockParams{
+				contactPreAuthorizerParams: contactPreAuthorizerParams{
+					requiresAuthorizationParams: requiresAuthorizationParams{
+						{
+							owner:     state.NewIdentScreenName("100001"),
+							requester: state.NewIdentScreenName("100002"),
+							result:    true,
+						},
+					},
+				},
+			},
+			wantSNAC: &wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Buddy,
+					SubGroup:  wire.BuddyRejectNotification,
+					RequestID: 42,
+				},
+				Body: wire.SNAC_0x03_0x0A_BuddyRejectNotification{
+					Buddies: []struct {
+						ScreenName string `oscar:"len_prefix=uint8"`
+					}{
+						{ScreenName: "100001"},
+					},
+				},
+			},
+		},
+		{
+			name:     "non-UIN skips pre-auth check",
+			instance: newTestInstance("100002", sessOptSignonComplete),
+			bodyIn: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+				Buddies: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{ScreenName: "somebody"},
+				},
+			},
+			mockParams: mockParams{
+				clientSideBuddyListManagerParams: clientSideBuddyListManagerParams{
+					addBuddyParams: addBuddyParams{
+						{
+							me:   state.NewIdentScreenName("100002"),
+							them: state.NewIdentScreenName("somebody"),
+						},
+					},
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							from:   state.NewIdentScreenName("100002"),
+							filter: []state.IdentScreenName{state.NewIdentScreenName("somebody")},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "partial batch, UIN blocked",
+			instance: newTestInstance("100002", sessOptSignonComplete),
+			bodyIn: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+				Buddies: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{ScreenName: "100001"},
+					{ScreenName: "100003"},
+				},
+			},
+			mockParams: mockParams{
+				contactPreAuthorizerParams: contactPreAuthorizerParams{
+					requiresAuthorizationParams: requiresAuthorizationParams{
+						{
+							owner:     state.NewIdentScreenName("100001"),
+							requester: state.NewIdentScreenName("100002"),
+							result:    true,
+						},
+						{
+							owner:     state.NewIdentScreenName("100003"),
+							requester: state.NewIdentScreenName("100002"),
+							result:    false,
+						},
+					},
+				},
+				clientSideBuddyListManagerParams: clientSideBuddyListManagerParams{
+					addBuddyParams: addBuddyParams{
+						{
+							me:   state.NewIdentScreenName("100002"),
+							them: state.NewIdentScreenName("100003"),
+						},
+					},
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							from:   state.NewIdentScreenName("100002"),
+							filter: []state.IdentScreenName{state.NewIdentScreenName("100003")},
+						},
+					},
+				},
+			},
+			wantSNAC: &wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Buddy,
+					SubGroup:  wire.BuddyRejectNotification,
+					RequestID: 42,
+				},
+				Body: wire.SNAC_0x03_0x0A_BuddyRejectNotification{
+					Buddies: []struct {
+						ScreenName string `oscar:"len_prefix=uint8"`
+					}{
+						{ScreenName: "100001"},
+					},
+				},
+			},
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -136,14 +261,20 @@ func TestBuddyService_AddBuddies(t *testing.T) {
 					BroadcastVisibility(matchContext(), matchSession(params.from), params.filter, true).
 					Return(params.err)
 			}
+			contactPreAuth := newMockContactPreAuthorizer(t)
+			for _, params := range tt.mockParams.requiresAuthorizationParams {
+				contactPreAuth.EXPECT().RequiresAuthorization(matchContext(), params.owner, params.requester).Return(params.result, params.err)
+			}
 
 			svc := BuddyService{
 				clientSideBuddyListManager: clientSideBuddyListManager,
 				buddyBroadcaster:           mockBuddyBroadcaster,
+				contactPreAuthorizer:       contactPreAuth,
 			}
 
-			haveErr := svc.AddBuddies(context.Background(), tt.instance, tt.bodyIn)
-			assert.ErrorIs(t, tt.wantErr, haveErr)
+			rejectSNAC, haveErr := svc.AddBuddies(context.Background(), tt.instance, wire.SNACFrame{RequestID: 42}, tt.bodyIn)
+			assert.Equal(t, tt.wantSNAC, rejectSNAC)
+			assert.ErrorIs(t, haveErr, tt.wantErr)
 		})
 	}
 }
@@ -329,14 +460,20 @@ func TestBuddyService_AddTempBuddies(t *testing.T) {
 					BroadcastVisibility(matchContext(), matchSession(params.from), params.filter, true).
 					Return(params.err)
 			}
+			contactPreAuth := newMockContactPreAuthorizer(t)
+			for _, params := range tt.mockParams.requiresAuthorizationParams {
+				contactPreAuth.EXPECT().RequiresAuthorization(matchContext(), params.owner, params.requester).Return(params.result, params.err)
+			}
 
 			svc := BuddyService{
 				clientSideBuddyListManager: clientSideBuddyListManager,
 				buddyBroadcaster:           mockBuddyBroadcaster,
+				contactPreAuthorizer:       contactPreAuth,
 			}
 
-			haveErr := svc.AddTempBuddies(context.Background(), tt.instance, tt.bodyIn)
-			assert.ErrorIs(t, tt.wantErr, haveErr)
+			rejectSNAC, haveErr := svc.AddTempBuddies(context.Background(), tt.instance, wire.SNACFrame{RequestID: 42}, tt.bodyIn)
+			assert.Nil(t, rejectSNAC)
+			assert.ErrorIs(t, haveErr, tt.wantErr)
 		})
 	}
 }

+ 421 - 94
foodgroup/feedbag.go

@@ -6,6 +6,7 @@ import (
 	"errors"
 	"fmt"
 	"log/slog"
+	"slices"
 	"strconv"
 	"time"
 
@@ -21,38 +22,47 @@ func NewFeedbagService(
 	bartItemManager BARTItemManager,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
-	userManager UserManager,
-	icbmSender func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error),
-) FeedbagService {
-	return FeedbagService{
-		bartItemManager:  bartItemManager,
-		buddyBroadcaster: newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
-		feedbagManager:   feedbagManager,
-		logger:           logger,
-		messageRelayer:   messageRelayer,
-		sessionRetriever: sessionRetriever,
-		userManager:      userManager,
-		icbmSender:       icbmSender,
+	contactPreAuthorizer ContactPreAuthorizer,
+) *FeedbagService {
+	return &FeedbagService{
+		bartItemManager:      bartItemManager,
+		buddyBroadcaster:     newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
+		feedbagManager:       feedbagManager,
+		logger:               logger,
+		messageRelayer:       messageRelayer,
+		relationshipFetcher:  relationshipFetcher,
+		sessionRetriever:     sessionRetriever,
+		contactPreAuthorizer: contactPreAuthorizer,
+		icbmSender: func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
+			return nil, errors.New("icbmSender not implemented")
+		},
 	}
 }
 
 // FeedbagService provides functionality for the Feedbag food group, which
 // handles buddy list management.
 type FeedbagService struct {
-	bartItemManager  BARTItemManager
-	buddyBroadcaster buddyBroadcaster
-	feedbagManager   FeedbagManager
-	logger           *slog.Logger
-	messageRelayer   MessageRelayer
-	sessionRetriever SessionRetriever
-	userManager      UserManager
-	icbmSender       func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
+	bartItemManager      BARTItemManager
+	buddyBroadcaster     buddyBroadcaster
+	feedbagManager       FeedbagManager
+	logger               *slog.Logger
+	messageRelayer       MessageRelayer
+	relationshipFetcher  RelationshipFetcher
+	sessionRetriever     SessionRetriever
+	contactPreAuthorizer ContactPreAuthorizer
+	icbmSender           func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
+}
+
+// BridgeICBMService enables the FeedbagService to send instant messages via the
+// ICBM service.
+func (s *FeedbagService) BridgeICBMService(service *ICBMService) {
+	s.icbmSender = service.ChannelMsgToHost
 }
 
 // RightsQuery returns SNAC wire.FeedbagRightsReply, which contains Feedbag
 // food group settings for the current user. The values within the SNAC are not
 // well understood but seem to make the AIM client happy.
-func (s FeedbagService) RightsQuery(_ context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+func (s *FeedbagService) RightsQuery(_ context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
 	// maxItemsByClass defines per-type item limits. Types not listed here are
 	// 0 by default. The slice size is equal to the maximum "enum" value+1.
 	maxItemsByClass := make([]uint16, 21)
@@ -100,7 +110,7 @@ func (s FeedbagService) RightsQuery(_ context.Context, inFrame wire.SNACFrame) w
 
 // Query fetches the user's feedbag (aka buddy list). It returns
 // wire.FeedbagReply, which contains feedbag entries.
-func (s FeedbagService) Query(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
+func (s *FeedbagService) Query(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
 	fb, err := s.feedbagManager.Feedbag(ctx, instance.IdentScreenName())
 	if err != nil {
 		return wire.SNACMessage{}, err
@@ -133,7 +143,7 @@ func (s FeedbagService) Query(ctx context.Context, instance *state.SessionInstan
 // wire.FeedbagReplyNotModified if the feedbag was last modified before
 // inBody.LastUpdate, else return wire.FeedbagReply, which contains feedbag
 // entries.
-func (s FeedbagService) QueryIfModified(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error) {
+func (s *FeedbagService) QueryIfModified(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error) {
 	fb, err := s.feedbagManager.Feedbag(ctx, instance.IdentScreenName())
 	if err != nil {
 		return wire.SNACMessage{}, err
@@ -183,7 +193,7 @@ func (s FeedbagService) QueryIfModified(ctx context.Context, instance *state.Ses
 // UpdateItem updates items in the user's feedbag (aka buddy list). Sends user
 // buddy arrival notifications for each online & visible buddy added to the
 // feedbag. It returns wire.FeedbagStatus, which contains update confirmation.
-func (s FeedbagService) UpsertItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) (*wire.SNACMessage, error) {
+func (s *FeedbagService) UpsertItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) (*wire.SNACMessage, error) {
 	for _, item := range items {
 		// don't let users block themselves, it causes the AIM client to go
 		// into a weird state.
@@ -210,25 +220,18 @@ func (s FeedbagService) UpsertItem(ctx context.Context, instance *state.SessionI
 	for _, item := range items {
 		if item.ClassID == wire.FeedbagClassIdBuddy && !item.HasTag(wire.FeedbagAttributesPending) {
 			sn := state.NewIdentScreenName(item.Name)
-			if sn.UIN() == 0 {
-				continue
-			}
-			user, err := s.userManager.User(ctx, sn)
-			if err != nil {
-				return nil, fmt.Errorf("userManager.User: %w", err)
-			}
-			if user == nil {
-				s.logger.DebugContext(ctx, "user not found", "name", item.Name)
-				continue
-			}
-			if user.ICQPermissions.AuthRequired {
-				authRequired[item.Name] = true
-			} else {
-				toUpsert = append(toUpsert, item)
+			if sn.UIN() != 0 {
+				blocked, err := s.contactPreAuthorizer.RequiresAuthorization(ctx, sn, instance.IdentScreenName())
+				if err != nil {
+					return nil, fmt.Errorf("contactPreAuthorizer.RequiresAuthorization: %w", err)
+				}
+				if blocked {
+					authRequired[item.Name] = true
+					continue
+				}
 			}
-		} else {
-			toUpsert = append(toUpsert, item)
 		}
+		toUpsert = append(toUpsert, item)
 	}
 
 	if len(toUpsert) > 0 {
@@ -283,6 +286,32 @@ func (s FeedbagService) UpsertItem(ctx context.Context, instance *state.SessionI
 		}
 	}
 
+	// send ICQ "you were added" messages
+	for _, item := range toUpsert {
+		if item.ClassID == wire.FeedbagClassIdBuddy && !item.HasTag(wire.FeedbagAttributesPending) {
+			user := state.NewIdentScreenName(item.Name)
+			if user.UIN() != 0 {
+				if fromSess := s.sessionRetriever.RetrieveSession(user); fromSess != nil && fromSess.UsesFeedbag() {
+					s.messageRelayer.RelayToScreenName(ctx, user, wire.SNACMessage{
+						Frame: wire.SNACFrame{
+							FoodGroup: wire.Feedbag,
+							SubGroup:  wire.FeedbagBuddyAdded,
+							Flags:     0x8000,
+						},
+						Body: wire.SNAC_0x13_0x1C_FeedbagBuddyAdded{
+							TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
+							ScreenName: instance.DisplayScreenName().String(),
+						},
+					})
+				} else {
+					if err := s.sendLegacyBuddyAddedMsg(ctx, instance, user); err != nil {
+						return nil, fmt.Errorf("sendLegacyBuddyAddedMsg: %w", err)
+					}
+				}
+			}
+		}
+	}
+
 	if alertAll || len(filter) > 0 {
 		if err := s.buddyBroadcaster.BroadcastVisibility(ctx, instance, filter, true); err != nil {
 			return nil, err
@@ -295,7 +324,7 @@ func (s FeedbagService) UpsertItem(ctx context.Context, instance *state.SessionI
 // setBARTItem informs clients about buddy icon update. If the BART
 // store doesn't have the icon, then tell the client to upload the buddy icon.
 // If the icon already exists, tell the user's buddies about the icon change.
-func (s FeedbagService) setBARTItem(ctx context.Context, instance *state.SessionInstance, item wire.FeedbagItem) error {
+func (s *FeedbagService) setBARTItem(ctx context.Context, instance *state.SessionInstance, item wire.FeedbagItem) error {
 	b, hasBuf := item.Bytes(wire.FeedbagAttributesBartInfo)
 	if !hasBuf {
 		return errors.New("unable to extract icon payload")
@@ -374,7 +403,7 @@ func (s FeedbagService) setBARTItem(ctx context.Context, instance *state.Session
 // arrival notifications for each online & visible buddy added to the feedbag.
 // Sends buddy arrival notifications to each unblocked buddy if current user is
 // visible. It returns wire.FeedbagStatus, which contains update confirmation.
-func (s FeedbagService) DeleteItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (*wire.SNACMessage, error) {
+func (s *FeedbagService) DeleteItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (*wire.SNACMessage, error) {
 	if err := s.feedbagManager.FeedbagDelete(ctx, instance.IdentScreenName(), inBody.Items); err != nil {
 		return nil, err
 	}
@@ -421,7 +450,7 @@ func (s FeedbagService) DeleteItem(ctx context.Context, instance *state.SessionI
 // StartCluster signals the beginning of a batch of feedbag operations that clients should
 // process together to prevent UI flicker during rapid updates. It transmits the start message
 // to other session instances.
-func (s FeedbagService) StartCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster) {
+func (s *FeedbagService) StartCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster) {
 	s.messageRelayer.RelayToOtherInstances(ctx, instance, wire.SNACMessage{
 		Frame: inFrame,
 		Body:  inBody,
@@ -430,7 +459,7 @@ func (s FeedbagService) StartCluster(ctx context.Context, instance *state.Sessio
 
 // EndCluster signals the completion of a batched feedbag operation group. It transmits the end
 // message to other session instances.
-func (s FeedbagService) EndCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) {
+func (s *FeedbagService) EndCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) {
 	s.messageRelayer.RelayToOtherInstances(ctx, instance, wire.SNACMessage{
 		Frame: inFrame,
 		Body:  wire.SNAC_0x13_0x12_FeedbagEndCluster{},
@@ -440,7 +469,7 @@ func (s FeedbagService) EndCluster(ctx context.Context, instance *state.SessionI
 // Use sends a user the contents of their buddy list. It's invoked at sign-on
 // by AIM clients that use the feedbag food group for buddy list management (as
 // opposed to client-side management).
-func (s FeedbagService) Use(ctx context.Context, instance *state.SessionInstance) error {
+func (s *FeedbagService) Use(ctx context.Context, instance *state.SessionInstance) error {
 	if err := s.feedbagManager.UseFeedbag(ctx, instance.IdentScreenName()); err != nil {
 		return fmt.Errorf("could not use feedbag: %w", err)
 	}
@@ -458,7 +487,7 @@ func (s FeedbagService) Use(ctx context.Context, instance *state.SessionInstance
 // uses feedbag this sign-on, they receive SNAC(0x13,0x19); otherwise a
 // synthetic SNAC(0x04,0x06) ICBM channel message to host (ICQ ch4 auth req)
 // is relayed to the recipient via messageRelayer.
-func (s FeedbagService) RequestAuthorizeToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x18_FeedbagRequestAuthorizationToHost) error {
+func (s *FeedbagService) RequestAuthorizeToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x18_FeedbagRequestAuthorizationToHost) error {
 	recipient := state.NewIdentScreenName(inBody.ScreenName)
 	recipSess := s.sessionRetriever.RetrieveSession(recipient)
 	useFeedbag := recipSess != nil && recipSess.UsesFeedbag()
@@ -477,42 +506,70 @@ func (s FeedbagService) RequestAuthorizeToHost(ctx context.Context, instance *st
 		return nil
 	}
 
-	// send offline or to old client
+	// send an offline authorization or request to legacy client
 	frame := wire.SNACFrame{
 		FoodGroup: wire.ICBM,
 		SubGroup:  wire.ICBMChannelMsgToHost,
+		RequestID: inFrame.RequestID,
 	}
-	hostBody := buildAuthRequestICBMChannelMsgToHost(instance, inBody.ScreenName, inBody.Reason)
-	if _, err := s.icbmSender(ctx, instance, frame, hostBody); err != nil {
-		return err
-	}
-
-	return nil
-}
 
-// buildAuthRequestICBMChannelMsgToHost builds SNAC(0x04,0x06) for an ICQ authorization
-// request.
-func buildAuthRequestICBMChannelMsgToHost(instance *state.SessionInstance, recipientScreenName, reason string) wire.SNAC_0x04_0x06_ICBMChannelMsgToHost {
-	fromUIN := instance.UIN()
-	reasonLatin := utf8ToLatin1(reason)
-	text := fmt.Sprintf("%d\xFE%s\xFE%s\xFE%s\xFE1\xFE%s", fromUIN, "", "", "", reasonLatin)
-
-	ch4 := wire.ICBMCh4Message{
-		UIN:         fromUIN,
-		MessageType: wire.ICBMMsgTypeAuthReq,
-		Message:     text,
+	// if authorized, the recipient can reciprocate without requesting authorization
+	authorized := 1
+	blocked, err := s.contactPreAuthorizer.RequiresAuthorization(ctx, instance.IdentScreenName(), recipient)
+	if err != nil {
+		return fmt.Errorf("contactPreAuthorizer.RequiresAuthorization: %w", err)
 	}
-	return wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
-		Cookie:     uint64(time.Now().UnixNano()),
+	if blocked {
+		authorized = 0
+	}
+
+	snac := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 		ChannelID:  wire.ICBMChannelICQ,
-		ScreenName: recipientScreenName,
+		ScreenName: inBody.ScreenName,
 		TLVRestBlock: wire.TLVRestBlock{
 			TLVList: wire.TLVList{
-				wire.NewTLVLE(wire.ICBMTLVData, ch4),
+				wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
+					UIN:         instance.UIN(),
+					MessageType: wire.ICBMMsgTypeAuthReq,
+					Message:     fmt.Sprintf("%d\xFE\xFE\xFE\xFE%d\xFE%s", instance.UIN(), authorized, utf8ToLatin1(inBody.Reason)),
+				}),
 				wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
 			},
 		},
 	}
+
+	if _, err := s.icbmSender(ctx, instance, frame, snac); err != nil {
+		return fmt.Errorf("icbmSender: %w", err)
+	}
+
+	return nil
+}
+
+// PreAuthorizeBuddy handles SNAC(0x13,0x14) FEEDBAG__PRE_AUTHORIZE_BUDDY. The client
+// pre-authorizes a buddy they just added so that user may add them later without
+// an authorization prompt. Persists a contactPreauth row and may notify the buddy:
+// SNAC(0x13,0x15) for online feedbag clients who do not block the sender; otherwise
+// an ICQ channel-4 ICBM (auth granted) for offline or non-feedbag clients.
+func (s *FeedbagService) PreAuthorizeBuddy(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy) (*wire.SNACMessage, error) {
+	buddy := state.NewIdentScreenName(inBody.ScreenName)
+	if buddy == instance.IdentScreenName() {
+		return &wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagErr,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNACError{
+				Code: wire.ErrorCodeNotSupportedByHost,
+			},
+		}, nil
+	}
+
+	if err := s.authorizeContact(ctx, instance.IdentScreenName(), state.NewIdentScreenName(inBody.ScreenName), inBody.Message); err != nil {
+		return nil, fmt.Errorf("s.authorizeContact: %w", err)
+	}
+
+	return nil, nil
 }
 
 func utf8ToLatin1(s string) string {
@@ -539,44 +596,264 @@ func isASCII(s string) bool {
 	return true
 }
 
+func (s *FeedbagService) authorizeContact(ctx context.Context, from state.IdentScreenName, to state.IdentScreenName, message string) error {
+	recipSess := s.sessionRetriever.RetrieveSession(to)
+
+	if err := s.contactPreAuthorizer.RecordPreAuth(ctx, from, to); err != nil {
+		if errors.Is(err, state.ErrNoUser) {
+			s.logger.DebugContext(ctx, "user not found", "name", to.String())
+			return nil
+		}
+		return fmt.Errorf("RecordPreAuth: %w", err)
+	}
+
+	if recipSess == nil {
+		if err := s.sendLegacyAuthMsg(ctx, from, to); err != nil {
+			return fmt.Errorf("sendLegacyAuthMsg: %w", err)
+		}
+		return nil
+	}
+
+	if cleared, err := s.clearPendingAuth(ctx, from, to); err != nil {
+		return fmt.Errorf("clearPendingAuth: %w", err)
+	} else if cleared {
+		return nil
+	}
+
+	if recipSess.UsesFeedbag() {
+		rel, err := s.relationshipFetcher.Relationship(ctx, from, to)
+		if err != nil {
+			return fmt.Errorf("relationshipFetcher.Relationship: %w", err)
+		}
+		if rel.BlocksYou {
+			return nil
+		}
+		s.messageRelayer.RelayToScreenName(ctx, to, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagPreAuthorizedBuddy,
+			},
+			Body: wire.SNAC_0x13_0x15_FeedbagPreAuthorizedBuddy{
+				ScreenName: from.String(),
+				Message:    message,
+				Flags:      0,
+			},
+		})
+		return nil
+	}
+
+	// send legacy ICQ authorization message
+	if err := s.sendLegacyAuthMsg(ctx, from, to); err != nil {
+		return fmt.Errorf("sendLegacyAuthMsg: %w", err)
+	}
+
+	return nil
+}
+
+func (s *FeedbagService) sendLegacyAuthMsg(ctx context.Context, from state.IdentScreenName, to state.IdentScreenName) error {
+	// send offline "auth granted" message
+	frame := wire.SNACFrame{FoodGroup: wire.ICBM, SubGroup: wire.ICBMChannelMsgToHost}
+	body := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+		Cookie:     uint64(time.Now().UnixNano()),
+		ChannelID:  wire.ICBMChannelICQ,
+		ScreenName: to.String(),
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
+					UIN:         from.UIN(),
+					MessageType: wire.ICBMMsgTypeAuthOK,
+				}),
+				wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+			},
+		},
+	}
+	fromSess := state.NewSession()
+	fromSess.SetIdentScreenName(from)
+	fromSess.SetDisplayScreenName(state.DisplayScreenName(from.String()))
+	if _, err := s.icbmSender(ctx, fromSess.AddInstance(), frame, body); err != nil {
+		return err
+	}
+	return nil
+}
+
+// sendLegacyBuddyAddedMsg notifies from via a legacy ICQ channel-4 message
+// that to has added them to their contact list.
+func (s *FeedbagService) sendLegacyBuddyAddedMsg(ctx context.Context, from *state.SessionInstance, to state.IdentScreenName) error {
+	frame := wire.SNACFrame{FoodGroup: wire.ICBM, SubGroup: wire.ICBMChannelMsgToHost}
+	body := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+		Cookie:     uint64(time.Now().UnixNano()),
+		ChannelID:  wire.ICBMChannelICQ,
+		ScreenName: to.String(),
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
+					UIN:         from.UIN(),
+					MessageType: wire.ICBMMsgTypeAdded,
+				}),
+				wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+			},
+		},
+	}
+	if _, err := s.icbmSender(ctx, from, frame, body); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (s *FeedbagService) clearPendingAuth(ctx context.Context, from state.IdentScreenName, to state.IdentScreenName) (bool, error) {
+	items, err := s.feedbagManager.Feedbag(ctx, to)
+	if err != nil {
+		return false, fmt.Errorf("failed to fetch feedbag items: %w", err)
+	}
+
+	// look for the pending buddy authorization
+	var buddyItem *wire.FeedbagItem
+	for _, item := range items {
+		if item.ClassID == wire.FeedbagClassIdBuddy && item.Name == from.String() {
+			buddyItem = &item
+			break
+		}
+	}
+
+	// pending buddy authorization is not found, nothing to do
+	if buddyItem == nil || !buddyItem.HasTag(wire.FeedbagAttributesPending) {
+		return false, nil
+	}
+	// remove the pending buddy authorization tag
+	buddyItem.TLVList = slices.DeleteFunc(buddyItem.TLVList, func(tlv wire.TLV) bool {
+		return tlv.Tag == wire.FeedbagAttributesPending
+	})
+
+	updates := []wire.FeedbagItem{*buddyItem}
+	if err = s.feedbagManager.FeedbagUpsert(ctx, to, updates); err != nil {
+		return false, fmt.Errorf("failed to update feedbag: %w", err)
+	}
+
+	fromSess := s.sessionRetriever.RetrieveSession(from)
+	if fromSess != nil && fromSess.UsesFeedbag() {
+		s.messageRelayer.RelayToScreenName(ctx, from, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagBuddyAdded,
+				Flags:     0x8000,
+			},
+			Body: wire.SNAC_0x13_0x1C_FeedbagBuddyAdded{
+				TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
+				ScreenName: to.String(),
+			},
+		})
+	} else {
+		toSess := state.NewSession()
+		toSess.SetIdentScreenName(to)
+		toSess.SetDisplayScreenName(state.DisplayScreenName(to.String()))
+		if err := s.sendLegacyBuddyAddedMsg(ctx, toSess.AddInstance(), from); err != nil {
+			return false, fmt.Errorf("sendLegacyBuddyAddedMsg: %w", err)
+		}
+	}
+
+	// clear the pending flag on the recipient's buddy entry
+	s.messageRelayer.RelayToScreenName(ctx, to, wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagUpdateItem,
+			Flags:     0x8000,
+		},
+		Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+			Items: updates,
+		},
+	})
+
+	// tell the recipient that we're friends
+	s.messageRelayer.RelayToScreenName(ctx, to, wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagRespondAuthorizeToClient,
+			Flags:     0x8000,
+		},
+		Body: wire.SNAC_0x13_0x1B_FeedbagRespondAuthorizeToClient{
+			TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
+			ScreenName: from.String(),
+			Accepted:   1,
+		},
+	})
+
+	if fromSess != nil {
+		instances := fromSess.Instances()
+		if len(instances) > 0 {
+			// tell the recipient that we're online
+			if err := s.buddyBroadcaster.BroadcastVisibility(ctx, instances[0], []state.IdentScreenName{to}, false); err != nil {
+				s.logger.ErrorContext(ctx, "broadcastBuddyArrived failed", "err", err)
+			}
+		}
+	}
+
+	return true, nil
+}
+
 // RespondAuthorizeToHost forwards an authorization response from the user
 // whose authorization was requested to the user who made the authorization
 // request.
 // Right now we send an ICBM request so that responses can work for both ICQ
 // 2000b and ICQ 2001a. This function should eventually only send an ICBM
 // message to non-feedbag clients and SNAC(0x0013,0x001B) to feedbag clients.
-func (s FeedbagService) RespondAuthorizeToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error {
-	response := wire.ICBMCh4Message{
-		UIN:     instance.UIN(),
-		Message: inBody.Reason,
-	}
-
+func (s *FeedbagService) RespondAuthorizeToHost(ctx context.Context, instance state.IdentScreenName, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error {
 	switch inBody.Accepted {
 	case 0:
-		response.MessageType = wire.ICBMMsgTypeAuthDeny
+		err := s.rejectContact(ctx, instance, state.NewIdentScreenName(inBody.ScreenName), inBody.Reason)
+		if err != nil {
+			return err
+		}
 	case 1:
-		response.MessageType = wire.ICBMMsgTypeAuthOK
+		if err := s.authorizeContact(ctx, instance, state.NewIdentScreenName(inBody.ScreenName), inBody.Reason); err != nil {
+			return fmt.Errorf("s.authorizeContact: %w", err)
+		}
 	default:
 		return fmt.Errorf("invalid accepted flag %d", inBody.Accepted)
 	}
 
-	frame := wire.SNACFrame{
-		FoodGroup: wire.ICBM,
-		SubGroup:  wire.ICBMChannelMsgToHost,
-	}
-	snac := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
-		ChannelID:  wire.ICBMChannelICQ,
-		ScreenName: inBody.ScreenName,
-		TLVRestBlock: wire.TLVRestBlock{
-			TLVList: wire.TLVList{
-				wire.NewTLVLE(wire.ICBMTLVData, response),
-				wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
-			},
-		},
-	}
+	return nil
+}
 
-	if _, err := s.icbmSender(ctx, instance, frame, snac); err != nil {
-		return fmt.Errorf("could not send ICBM message: %w", err)
+func (s *FeedbagService) rejectContact(ctx context.Context, from state.IdentScreenName, to state.IdentScreenName, reason string) error {
+	if toSess := s.sessionRetriever.RetrieveSession(to); toSess != nil && toSess.UsesFeedbag() {
+		s.messageRelayer.RelayToScreenName(ctx, to, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagRespondAuthorizeToClient,
+				Flags:     0x8000,
+			},
+			Body: wire.SNAC_0x13_0x1B_FeedbagRespondAuthorizeToClient{
+				TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
+				ScreenName: from.String(),
+				Accepted:   0,
+				Reason:     reason,
+			},
+		})
+	} else {
+		frame := wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMChannelMsgToHost,
+		}
+		snac := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+			ChannelID:  wire.ICBMChannelICQ,
+			ScreenName: to.String(),
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
+						UIN:         from.UIN(),
+						MessageType: wire.ICBMMsgTypeAuthDeny,
+						Message:     reason,
+					}),
+					wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+				},
+			},
+		}
+		fromSess := state.NewSession()
+		fromSess.SetIdentScreenName(from)
+		fromSess.SetDisplayScreenName(state.DisplayScreenName(from.String()))
+		if _, err := s.icbmSender(ctx, fromSess.AddInstance(), frame, snac); err != nil {
+			return fmt.Errorf("could not send ICBM message: %w", err)
+		}
 	}
 
 	return nil
@@ -712,3 +989,53 @@ func feedbagBuddyPref(prefNum uint16, list wire.TLVList) (valid bool, value bool
 
 	return valid, value
 }
+
+// ForwardICQAuthEvents converts ICQ channel-4 payloads to feedbag SNACs and
+// sends them to feedbag-enabled recipient.
+func (s *FeedbagService) ForwardICQAuthEvents(ctx context.Context, sender state.IdentScreenName, recipient state.IdentScreenName, authMsg wire.ICBMCh4Message) error {
+	switch authMsg.MessageType {
+	case wire.ICBMMsgTypeAuthOK:
+		msg := wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost{
+			ScreenName: recipient.String(),
+			Accepted:   1,
+			Reason:     authMsg.Message,
+		}
+		return s.RespondAuthorizeToHost(ctx, sender, wire.SNACFrame{}, msg)
+	case wire.ICBMMsgTypeAuthDeny:
+		msg := wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost{
+			ScreenName: recipient.String(),
+			Accepted:   0,
+			Reason:     authMsg.Message,
+		}
+		return s.RespondAuthorizeToHost(ctx, sender, wire.SNACFrame{}, msg)
+	case wire.ICBMMsgTypeAdded:
+		s.messageRelayer.RelayToScreenName(ctx, recipient, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagBuddyAdded,
+				Flags:     0x8000,
+			},
+			Body: wire.SNAC_0x13_0x1C_FeedbagBuddyAdded{
+				TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
+				ScreenName: sender.String(),
+			},
+		})
+	case wire.ICBMMsgTypeAuthReq:
+		s.messageRelayer.RelayToScreenName(ctx, recipient, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Feedbag,
+				SubGroup:  wire.FeedbagRequestAuthorizeToClient,
+				Flags:     0x8000,
+			},
+			Body: wire.SNAC_0x13_0x19_FeedbagRequestAuthorizeToClient{
+				TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
+				ScreenName: sender.String(),
+				Reason:     authMsg.Message,
+			},
+		})
+	default:
+		s.logger.WarnContext(ctx, "unknown authMsg ICBM message type", "type", authMsg.MessageType)
+	}
+
+	return nil
+}

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 1709 - 283
foodgroup/feedbag_test.go


+ 25 - 0
foodgroup/helpers_test.go

@@ -18,6 +18,7 @@ type mockParams struct {
 	accountManagerParams
 	bartItemManagerParams
 	buddyBroadcasterParams
+	contactPreAuthorizerParams
 	relationshipFetcherParams
 	chatMessageRelayerParams
 	chatRoomRegistryParams
@@ -34,6 +35,30 @@ type mockParams struct {
 	userManagerParams
 }
 
+// contactPreAuthorizerParams is a helper struct that contains mock parameters for
+// ContactPreAuthorizer methods
+type contactPreAuthorizerParams struct {
+	recordPreAuthParams
+	requiresAuthorizationParams
+}
+
+// recordPreAuthParams is the list of parameters passed at the mock
+// ContactPreAuthorizer.RecordPreAuth call site
+type recordPreAuthParams []struct {
+	owner state.IdentScreenName
+	buddy state.IdentScreenName
+	err   error
+}
+
+// requiresAuthorizationParams is the list of parameters passed at the mock
+// ContactPreAuthorizer.RequiresAuthorization call site
+type requiresAuthorizationParams []struct {
+	owner     state.IdentScreenName
+	requester state.IdentScreenName
+	result    bool
+	err       error
+}
+
 // relationshipFetcherParams is a helper struct that contains mock parameters
 // for RelationshipFetcher methods
 type relationshipFetcherParams struct {

+ 31 - 141
foodgroup/icbm.go

@@ -7,7 +7,6 @@ import (
 	"fmt"
 	"io"
 	"log/slog"
-	"slices"
 	"strings"
 	"sync"
 	"time"
@@ -27,17 +26,7 @@ const (
 )
 
 // NewICBMService returns a new instance of ICBMService.
-func NewICBMService(
-	bartItemManager BARTItemManager,
-	messageRelayer MessageRelayer,
-	offlineMessageSaver OfflineMessageManager,
-	relationshipFetcher RelationshipFetcher,
-	sessionRetriever SessionRetriever,
-	userManager UserManager,
-	feedbagManager FeedbagManager,
-	snacRateLimits wire.SNACRateLimits,
-	logger *slog.Logger,
-) *ICBMService {
+func NewICBMService(bartItemManager BARTItemManager, messageRelayer MessageRelayer, offlineMessageSaver OfflineMessageManager, relationshipFetcher RelationshipFetcher, sessionRetriever SessionRetriever, userManager UserManager, feedbagManager FeedbagManager, contactPreAuthorizer ContactPreAuthorizer, snacRateLimits wire.SNACRateLimits, logger *slog.Logger) *ICBMService {
 	return &ICBMService{
 		relationshipFetcher:   relationshipFetcher,
 		buddyBroadcaster:      newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
@@ -46,12 +35,16 @@ func NewICBMService(
 		offlineMessageManager: offlineMessageSaver,
 		userManager:           userManager,
 		feedbagManager:        feedbagManager,
+		contactPreAuthorizer:  contactPreAuthorizer,
 		timeNow:               time.Now,
 		sessionRetriever:      sessionRetriever,
 		snacRateLimits:        snacRateLimits,
 		convoTracker:          newConvoTracker(),
 		logger:                logger,
 		interval:              rateDecayInterval,
+		forwardICQAuthEvents: func(ctx context.Context, sender state.IdentScreenName, recipient state.IdentScreenName, authMsg wire.ICBMCh4Message) error {
+			return errors.New("forwardICQAuthEvents not implemented")
+		},
 	}
 }
 
@@ -65,6 +58,7 @@ type ICBMService struct {
 	offlineMessageSaver   OfflineMessageManager
 	userManager           UserManager
 	feedbagManager        FeedbagManager
+	contactPreAuthorizer  ContactPreAuthorizer
 	timeNow               func() time.Time
 	sessionRetriever      SessionRetriever
 	snacRateLimits        wire.SNACRateLimits
@@ -72,10 +66,17 @@ type ICBMService struct {
 	logger                *slog.Logger
 	interval              time.Duration
 	offlineMessageManager OfflineMessageManager
+	forwardICQAuthEvents  func(ctx context.Context, sender state.IdentScreenName, recipient state.IdentScreenName, authMsg wire.ICBMCh4Message) error
+}
+
+// BridgeFeedbagService enables the ICBMService to forward legacy ICQ events to
+// the ICBM service.
+func (s *ICBMService) BridgeFeedbagService(service *FeedbagService) {
+	s.forwardICQAuthEvents = service.ForwardICQAuthEvents
 }
 
 // ParameterQuery returns ICBM service parameters.
-func (s ICBMService) ParameterQuery(_ context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+func (s *ICBMService) ParameterQuery(_ context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
 	return wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.ICBM,
@@ -97,7 +98,7 @@ func (s ICBMService) ParameterQuery(_ context.Context, inFrame wire.SNACFrame) w
 // from the sender to the intended recipient. It returns wire.ICBMHostAck if
 // the wire.ICBMChannelMsgToHost message contains a request acknowledgement
 // flag.
-func (s ICBMService) ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
+func (s *ICBMService) ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
 	recip := state.NewIdentScreenName(inBody.ScreenName)
 
 	rel, err := s.relationshipFetcher.Relationship(ctx, instance.IdentScreenName(), recip)
@@ -134,13 +135,19 @@ func (s ICBMService) ChannelMsgToHost(ctx context.Context, instance *state.Sessi
 		return msg, err
 	}
 
-	if inBody.ChannelID == wire.ICBMChannelICQ && recipSess.UsesFeedbag() {
+	if inBody.ChannelID == wire.ICBMChannelICQ {
 		if b, ok := inBody.Bytes(wire.ICBMTLVData); ok {
 			authMsg := wire.ICBMCh4Message{}
 			if err = wire.UnmarshalLE(&authMsg, bytes.NewReader(b)); err != nil {
 				return nil, fmt.Errorf("failed to unmarshal ICBM authMsg: %w", err)
 			}
-			return nil, s.forwardICQAuthEvents(ctx, instance, recipSess.IdentScreenName(), authMsg)
+			if recipSess.UsesFeedbag() {
+				return nil, s.forwardICQAuthEvents(ctx, instance.IdentScreenName(), recipSess.IdentScreenName(), authMsg)
+			} else if authMsg.MessageType == wire.ICBMMsgTypeAuthOK {
+				if err := s.contactPreAuthorizer.RecordPreAuth(ctx, instance.IdentScreenName(), recipSess.IdentScreenName()); err != nil {
+					return nil, fmt.Errorf("RecordPreAuth: %w", err)
+				}
+			}
 		}
 	}
 
@@ -223,130 +230,13 @@ func (s ICBMService) ChannelMsgToHost(ctx context.Context, instance *state.Sessi
 	}, nil
 }
 
-// forwardICQAuthEvents converts ICQ channel-4 payloads to feedbag SNACs and
-// sends them to feedbag-enabled recipient.
-func (s ICBMService) forwardICQAuthEvents(ctx context.Context, sender *state.SessionInstance, recipient state.IdentScreenName, authMsg wire.ICBMCh4Message) error {
-	switch authMsg.MessageType {
-	case wire.ICBMMsgTypeAuthOK:
-		items, err := s.feedbagManager.Feedbag(ctx, recipient)
-		if err != nil {
-			return fmt.Errorf("failed to fetch feedbag items: %w", err)
-		}
-
-		// look for the pending buddy authorization
-		var buddyItem *wire.FeedbagItem
-		for _, item := range items {
-			if item.ClassID == wire.FeedbagClassIdBuddy && item.Name == sender.IdentScreenName().String() {
-				buddyItem = &item
-				break
-			}
-		}
-
-		// pending buddy authorization is not found, nothing to do
-		if buddyItem == nil || !buddyItem.HasTag(wire.FeedbagAttributesPending) {
-			return fmt.Errorf("no pending buddy authorization found for %s", sender.IdentScreenName().String())
-		}
-
-		// remove the pending buddy authorization tag
-		buddyItem.TLVList = slices.DeleteFunc(buddyItem.TLVList, func(tlv wire.TLV) bool {
-			return tlv.Tag == wire.FeedbagAttributesPending
-		})
-
-		updates := []wire.FeedbagItem{*buddyItem}
-		if err = s.feedbagManager.FeedbagUpsert(ctx, recipient, updates); err != nil {
-			return fmt.Errorf("failed to update feedbag: %w", err)
-		}
-
-		// clear the pending flag on the recipient's buddy entry
-		s.messageRelayer.RelayToScreenName(ctx, recipient, wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.Feedbag,
-				SubGroup:  wire.FeedbagUpdateItem,
-				Flags:     0x8000,
-			},
-			Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
-				Items: updates,
-			},
-		})
-
-		// tell the recipient that we're friends
-		snac := wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.Feedbag,
-				SubGroup:  wire.FeedbagRespondAuthorizeToClient,
-				Flags:     0x8000,
-			},
-			Body: wire.SNAC_0x13_0x1B_FeedbagRespondAuthorizeToClient{
-				TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
-				ScreenName: sender.IdentScreenName().String(),
-				Accepted:   1,
-			},
-		}
-		s.messageRelayer.RelayToScreenName(ctx, recipient, snac)
-
-		// tell the recipient that we're online
-		if err := s.buddyBroadcaster.BroadcastVisibility(ctx, sender, []state.IdentScreenName{recipient}, false); err != nil {
-			s.logger.ErrorContext(ctx, "broadcastBuddyArrived failed", "err", err)
-		}
-		return nil
-	case wire.ICBMMsgTypeAuthDeny:
-		snac := wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.Feedbag,
-				SubGroup:  wire.FeedbagRespondAuthorizeToClient,
-				Flags:     0x8000,
-			},
-			Body: wire.SNAC_0x13_0x1B_FeedbagRespondAuthorizeToClient{
-				TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
-				ScreenName: sender.IdentScreenName().String(),
-				Accepted:   0,
-				Reason:     authMsg.Message,
-			},
-		}
-		s.messageRelayer.RelayToScreenName(ctx, recipient, snac)
-		return nil
-	case wire.ICBMMsgTypeAdded:
-		snac := wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.Feedbag,
-				SubGroup:  wire.FeedbagBuddyAdded,
-				Flags:     0x8000,
-			},
-			Body: wire.SNAC_0x13_0x1C_FeedbagBuddyAdded{
-				TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
-				ScreenName: sender.IdentScreenName().String(),
-			},
-		}
-		s.messageRelayer.RelayToScreenName(ctx, recipient, snac)
-		return nil
-	case wire.ICBMMsgTypeAuthReq:
-		snac := wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.Feedbag,
-				SubGroup:  wire.FeedbagRequestAuthorizeToClient,
-				Flags:     0x8000,
-			},
-			Body: wire.SNAC_0x13_0x19_FeedbagRequestAuthorizeToClient{
-				TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
-				ScreenName: sender.IdentScreenName().String(),
-				Reason:     authMsg.Message,
-			},
-		}
-		s.messageRelayer.RelayToScreenName(ctx, recipient, snac)
-		return nil
-	default:
-		s.logger.WarnContext(ctx, "unknown authMsg ICBM message type", "type", authMsg.MessageType)
-		return nil
-	}
-}
-
 // canSendOfflineMessage returns true if the user can send an offline message.
 //
 //	For ICQ users, always return true. Todo: Check ICQ recipient's preferences.
 //
 //	For AIM users, only return false if the recipient has specifically opted out
 //	of receiving offline messages or they do not have a stored buddy list.
-func (s ICBMService) canSendOfflineMessage(ctx context.Context, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (bool, error) {
+func (s *ICBMService) canSendOfflineMessage(ctx context.Context, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (bool, error) {
 	bag, err := s.feedbagManager.Feedbag(ctx, state.NewIdentScreenName(inBody.ScreenName))
 	if err != nil {
 		return false, fmt.Errorf("get feedbag failed: %w", err)
@@ -370,7 +260,7 @@ func (s ICBMService) canSendOfflineMessage(ctx context.Context, inBody wire.SNAC
 	return true, nil
 }
 
-func (s ICBMService) sendOfflineMessage(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
+func (s *ICBMService) sendOfflineMessage(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
 	recip := state.NewIdentScreenName(inBody.ScreenName)
 
 	offlineMsg := state.OfflineMessage{
@@ -516,7 +406,7 @@ func stripHTMLFromICBMTLV(tlv wire.TLV) (wire.TLV, error) {
 
 // ClientEvent relays SNAC wire.ICBMClientEvent typing events from the
 // sender to the recipient.
-func (s ICBMService) ClientEvent(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error {
+func (s *ICBMService) ClientEvent(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error {
 	blocked, err := s.relationshipFetcher.Relationship(ctx, instance.IdentScreenName(), state.NewIdentScreenName(inBody.ScreenName))
 
 	switch {
@@ -544,7 +434,7 @@ func (s ICBMService) ClientEvent(ctx context.Context, instance *state.SessionIns
 	}
 }
 
-func (s ICBMService) ClientErr(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x0B_ICBMClientErr) error {
+func (s *ICBMService) ClientErr(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x0B_ICBMClientErr) error {
 	s.messageRelayer.RelayToScreenName(ctx, state.NewIdentScreenName(inBody.ScreenName), wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.ICBM,
@@ -569,7 +459,7 @@ func (s ICBMService) ClientErr(ctx context.Context, instance *state.SessionInsta
 // non-anonymously. It returns SNAC wire.ICBMEvilReply to confirm that the
 // warning was sent. Users may not warn themselves or warn users they have
 // blocked or are blocked by.
-func (s ICBMService) EvilRequest(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error) {
+func (s *ICBMService) EvilRequest(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error) {
 	identScreenName := state.NewIdentScreenName(inBody.ScreenName)
 
 	// don't let users warn themselves, it causes the AIM client to go into a
@@ -659,7 +549,7 @@ func (s ICBMService) EvilRequest(ctx context.Context, instance *state.SessionIns
 	}, nil
 }
 
-func (s ICBMService) OfflineRetrieve(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
+func (s *ICBMService) OfflineRetrieve(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
 	msgList, err := s.offlineMessageManager.RetrieveMessages(ctx, instance.IdentScreenName())
 	if err != nil {
 		return wire.SNACMessage{}, fmt.Errorf("retrieving messages: %w", err)
@@ -708,7 +598,7 @@ func (s ICBMService) OfflineRetrieve(ctx context.Context, instance *state.Sessio
 
 // RestoreWarningLevel restores the warning level from the last stored value at login time,
 // accounting for time passed between logins.
-func (s ICBMService) RestoreWarningLevel(ctx context.Context, instance *state.SessionInstance) error {
+func (s *ICBMService) RestoreWarningLevel(ctx context.Context, instance *state.SessionInstance) error {
 	u, err := s.userManager.User(ctx, instance.IdentScreenName())
 	if err != nil {
 		return fmt.Errorf("failed to get user: %w", err)
@@ -755,7 +645,7 @@ func (s ICBMService) RestoreWarningLevel(ctx context.Context, instance *state.Se
 
 // UpdateWarnLevel periodically updates the warning level relative to time
 // elapsed between warnings.
-func (s ICBMService) UpdateWarnLevel(ctx context.Context, instance *state.SessionInstance) {
+func (s *ICBMService) UpdateWarnLevel(ctx context.Context, instance *state.SessionInstance) {
 	var inProgress bool
 	var ticker *time.Ticker
 	var tickC <-chan time.Time // nil when idle, enables/disables the select case

+ 117 - 126
foodgroup/icbm_test.go

@@ -34,6 +34,15 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		mockParams mockParams
+		// expectForwardICQAuthEvents indicates whether ChannelMsgToHost should
+		// delegate ICQ channel-4 auth events to forwardICQAuthEvents.
+		expectForwardICQAuthEvents bool
+		// expectForwardRecipient is the expected recipient passed to
+		// forwardICQAuthEvents when expectForwardICQAuthEvents is true.
+		expectForwardRecipient state.IdentScreenName
+		// expectForwardAuthMsg is the expected auth message passed to
+		// forwardICQAuthEvents when expectForwardICQAuthEvents is true.
+		expectForwardAuthMsg wire.ICBMCh4Message
 		// timeNow returns the current time
 		timeNow func() time.Time
 	}{
@@ -1784,84 +1793,87 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 						},
 					},
 				},
-				feedbagManagerParams: feedbagManagerParams{
-					feedbagParams: feedbagParams{
+			},
+			expectForwardICQAuthEvents: true,
+			expectForwardRecipient:     state.NewIdentScreenName("200002"),
+			expectForwardAuthMsg: wire.ICBMCh4Message{
+				UIN:         100001,
+				MessageType: wire.ICBMMsgTypeAuthOK,
+			},
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{},
+				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					ChannelID:  wire.ICBMChannelICQ,
+					ScreenName: "200002",
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
+								UIN:         100001,
+								MessageType: wire.ICBMMsgTypeAuthOK,
+							}),
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+			wantErr:      nil,
+		},
+		{
+			name:     "ICQ channel non-feedbag ch4: auth_ok",
+			instance: newTestInstance("100001", sessOptUIN(100001)),
+			mockParams: mockParams{
+				relationshipFetcherParams: relationshipFetcherParams{
+					relationshipParams: relationshipParams{
 						{
-							screenName: state.NewIdentScreenName("200002"),
-							results: []wire.FeedbagItem{
-								{
-									Name:    "100001",
-									ClassID: wire.FeedbagClassIdBuddy,
-									TLVLBlock: wire.TLVLBlock{
-										TLVList: wire.TLVList{{Tag: wire.FeedbagAttributesPending, Value: []byte{1}}},
-									},
-								},
+							me:   state.NewIdentScreenName("100001"),
+							them: state.NewIdentScreenName("200002"),
+							result: state.Relationship{
+								User:          state.NewIdentScreenName("200002"),
+								BlocksYou:     false,
+								YouBlock:      false,
+								IsOnTheirList: false,
+								IsOnYourList:  false,
 							},
 						},
 					},
-					feedbagUpsertParams: feedbagUpsertParams{
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
 						{
 							screenName: state.NewIdentScreenName("200002"),
-							items: []wire.FeedbagItem{
-								{
-									Name:    "100001",
-									ClassID: wire.FeedbagClassIdBuddy,
-									TLVLBlock: wire.TLVLBlock{
-										TLVList: wire.TLVList{},
-									},
-								},
-							},
+							result:     newTestInstance("200002", sessOptSignonComplete).Session(),
 						},
 					},
 				},
+				contactPreAuthorizerParams: contactPreAuthorizerParams{
+					recordPreAuthParams: recordPreAuthParams{
+						{owner: state.NewIdentScreenName("100001"), buddy: state.NewIdentScreenName("200002")},
+					},
+				},
 				messageRelayerParams: messageRelayerParams{
-					relayToScreenNameParams: relayToScreenNameParams{
+					relayToScreenNameActiveOnlyParams: relayToScreenNameActiveOnlyParams{
 						{
 							screenName: state.NewIdentScreenName("200002"),
 							message: wire.SNACMessage{
 								Frame: wire.SNACFrame{
-									FoodGroup: wire.Feedbag,
-									SubGroup:  wire.FeedbagUpdateItem,
-									Flags:     0x8000,
+									FoodGroup: wire.ICBM,
+									SubGroup:  wire.ICBMChannelMsgToClient,
+									RequestID: wire.ReqIDFromServer,
 								},
-								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
-									Items: []wire.FeedbagItem{
-										{
-											Name:    "100001",
-											ClassID: wire.FeedbagClassIdBuddy,
-											TLVLBlock: wire.TLVLBlock{
-												TLVList: wire.TLVList{},
-											},
+								Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+									ChannelID:   wire.ICBMChannelICQ,
+									TLVUserInfo: newTestInstance("100001", sessOptUIN(100001)).Session().TLVUserInfo(),
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
+												UIN:         100001,
+												MessageType: wire.ICBMMsgTypeAuthOK,
+											}),
 										},
 									},
 								},
 							},
 						},
-						{
-							screenName: state.NewIdentScreenName("200002"),
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Feedbag,
-									SubGroup:  wire.FeedbagRespondAuthorizeToClient,
-									Flags:     0x8000,
-								},
-								Body: wire.SNAC_0x13_0x1B_FeedbagRespondAuthorizeToClient{
-									TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
-									ScreenName: "100001",
-									Accepted:   1,
-								},
-							},
-						},
-					},
-				},
-				buddyBroadcasterParams: buddyBroadcasterParams{
-					broadcastVisibilityParams: broadcastVisibilityParams{
-						{
-							from:             state.NewIdentScreenName("100001"),
-							filter:           []state.IdentScreenName{state.NewIdentScreenName("200002")},
-							doSendDepartures: false,
-							err:              nil,
-						},
 					},
 				},
 			},
@@ -1910,26 +1922,13 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 						},
 					},
 				},
-				messageRelayerParams: messageRelayerParams{
-					relayToScreenNameParams: relayToScreenNameParams{
-						{
-							screenName: state.NewIdentScreenName("200002"),
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Feedbag,
-									SubGroup:  wire.FeedbagRespondAuthorizeToClient,
-									Flags:     0x8000,
-								},
-								Body: wire.SNAC_0x13_0x1B_FeedbagRespondAuthorizeToClient{
-									TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
-									ScreenName: "100001",
-									Accepted:   0,
-									Reason:     "no thanks",
-								},
-							},
-						},
-					},
-				},
+			},
+			expectForwardICQAuthEvents: true,
+			expectForwardRecipient:     state.NewIdentScreenName("200002"),
+			expectForwardAuthMsg: wire.ICBMCh4Message{
+				UIN:         100001,
+				MessageType: wire.ICBMMsgTypeAuthDeny,
+				Message:     "no thanks",
 			},
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{},
@@ -1977,24 +1976,12 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 						},
 					},
 				},
-				messageRelayerParams: messageRelayerParams{
-					relayToScreenNameParams: relayToScreenNameParams{
-						{
-							screenName: state.NewIdentScreenName("200002"),
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Feedbag,
-									SubGroup:  wire.FeedbagBuddyAdded,
-									Flags:     0x8000,
-								},
-								Body: wire.SNAC_0x13_0x1C_FeedbagBuddyAdded{
-									TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
-									ScreenName: "100001",
-								},
-							},
-						},
-					},
-				},
+			},
+			expectForwardICQAuthEvents: true,
+			expectForwardRecipient:     state.NewIdentScreenName("200002"),
+			expectForwardAuthMsg: wire.ICBMCh4Message{
+				UIN:         100001,
+				MessageType: wire.ICBMMsgTypeAdded,
 			},
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{},
@@ -2041,25 +2028,13 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 						},
 					},
 				},
-				messageRelayerParams: messageRelayerParams{
-					relayToScreenNameParams: relayToScreenNameParams{
-						{
-							screenName: state.NewIdentScreenName("200002"),
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Feedbag,
-									SubGroup:  wire.FeedbagRequestAuthorizeToClient,
-									Flags:     0x8000,
-								},
-								Body: wire.SNAC_0x13_0x19_FeedbagRequestAuthorizeToClient{
-									TLV:        wire.NewTLVBE(6, uint32(0x00020004)),
-									ScreenName: "100001",
-									Reason:     "hi",
-								},
-							},
-						},
-					},
-				},
+			},
+			expectForwardICQAuthEvents: true,
+			expectForwardRecipient:     state.NewIdentScreenName("200002"),
+			expectForwardAuthMsg: wire.ICBMCh4Message{
+				UIN:         100001,
+				MessageType: wire.ICBMMsgTypeAuthReq,
+				Message:     "hi",
 			},
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{},
@@ -2134,23 +2109,39 @@ func TestICBMService_ChannelMsgToHost(t *testing.T) {
 					BroadcastVisibility(mock.Anything, matchSession(params.from), params.filter, params.doSendDepartures).
 					Return(params.err)
 			}
+			contactPreAuth := newMockContactPreAuthorizer(t)
+			for _, params := range tc.mockParams.recordPreAuthParams {
+				contactPreAuth.EXPECT().
+					RecordPreAuth(matchContext(), params.owner, params.buddy).
+					Return(params.err)
+			}
 
 			svc := ICBMService{
-				relationshipFetcher: relationshipFetcher,
-				messageRelayer:      messageRelayer,
-				offlineMessageSaver: offlineMessageManager,
-				sessionRetriever:    sessionRetriever,
-				timeNow:             tc.timeNow,
-				convoTracker:        newConvoTracker(),
-				feedbagManager:      feedbagManager,
-				buddyBroadcaster:    buddyBroadcaster,
-				logger:              discardLogger,
+				relationshipFetcher:  relationshipFetcher,
+				messageRelayer:       messageRelayer,
+				offlineMessageSaver:  offlineMessageManager,
+				sessionRetriever:     sessionRetriever,
+				timeNow:              tc.timeNow,
+				convoTracker:         newConvoTracker(),
+				feedbagManager:       feedbagManager,
+				contactPreAuthorizer: contactPreAuth,
+				buddyBroadcaster:     buddyBroadcaster,
+				logger:               discardLogger,
+			}
+			var forwardCalled bool
+			svc.forwardICQAuthEvents = func(ctx context.Context, sender state.IdentScreenName, recipient state.IdentScreenName, authMsg wire.ICBMCh4Message) error {
+				forwardCalled = true
+				assert.Equal(t, tc.instance.IdentScreenName(), sender)
+				assert.Equal(t, tc.expectForwardRecipient, recipient)
+				assert.Equal(t, tc.expectForwardAuthMsg, authMsg)
+				return nil
 			}
 
 			outputSNAC, err := svc.ChannelMsgToHost(context.Background(), tc.instance, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x04_0x06_ICBMChannelMsgToHost))
 			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
+			assert.Equal(t, tc.expectForwardICQAuthEvents, forwardCalled)
 		})
 	}
 }
@@ -2940,7 +2931,7 @@ func TestICBMService_EvilRequest(t *testing.T) {
 }
 
 func TestICBMService_ParameterQuery(t *testing.T) {
-	svc := NewICBMService(nil, nil, nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
+	svc := NewICBMService(nil, nil, nil, nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
 
 	have := svc.ParameterQuery(nil, wire.SNACFrame{RequestID: 1234})
 	want := wire.SNACMessage{
@@ -2992,7 +2983,7 @@ func TestICBMService_ClientErr(t *testing.T) {
 	messageRelayer.EXPECT().
 		RelayToScreenName(mock.Anything, state.NewIdentScreenName("recipientScreenName"), expect)
 
-	svc := NewICBMService(nil, messageRelayer, nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
+	svc := NewICBMService(nil, messageRelayer, nil, nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
 
 	err := svc.ClientErr(context.Background(), instance, wire.SNACFrame{RequestID: 1234}, inBody)
 	assert.NoError(t, err)

+ 49 - 46
foodgroup/icq.go

@@ -24,17 +24,18 @@ func NewICQService(
 	logger *slog.Logger,
 	sessionRetriever SessionRetriever,
 	offlineMessageManager OfflineMessageManager,
-	icbmSender func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error),
-) ICQService {
-	return ICQService{
+) *ICQService {
+	return &ICQService{
 		messageRelayer:        messageRelayer,
 		userFinder:            finder,
 		userUpdater:           userUpdater,
 		logger:                logger,
 		sessionRetriever:      sessionRetriever,
 		offlineMessageManager: offlineMessageManager,
-		icbmSender:            icbmSender,
 		timeNow:               time.Now,
+		forwardICQAuthEvents: func(ctx context.Context, sender state.IdentScreenName, recipient state.IdentScreenName, authMsg wire.ICBMCh4Message) error {
+			return fmt.Errorf("no ICBMService available")
+		},
 	}
 }
 
@@ -47,17 +48,23 @@ type ICQService struct {
 	userUpdater           ICQUserUpdater
 	timeNow               func() time.Time
 	offlineMessageManager OfflineMessageManager
-	icbmSender            func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
+	forwardICQAuthEvents  func(ctx context.Context, sender state.IdentScreenName, recipient state.IdentScreenName, authMsg wire.ICBMCh4Message) error
+}
+
+// BridgeFeedbagService enables the ICBMService to forward legacy ICQ events to
+// the ICBM service.
+func (s *ICQService) BridgeFeedbagService(service *FeedbagService) {
+	s.forwardICQAuthEvents = service.ForwardICQAuthEvents
 }
 
-func (s ICQService) DeleteMsgReq(ctx context.Context, instance *state.SessionInstance, seq uint16) error {
+func (s *ICQService) DeleteMsgReq(ctx context.Context, instance *state.SessionInstance, seq uint16) error {
 	if err := s.offlineMessageManager.DeleteMessages(ctx, instance.IdentScreenName()); err != nil {
 		return fmt.Errorf("deleting messages: %w", err)
 	}
 	return nil
 }
 
-func (s ICQService) FindByICQName(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error {
+func (s *ICQService) FindByICQName(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error {
 	resp := wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
 		ICQMetadata: wire.ICQMetadata{
 			UIN:     instance.UIN(),
@@ -101,7 +108,7 @@ func (s ICQService) FindByICQName(ctx context.Context, instance *state.SessionIn
 	return nil
 }
 
-func (s ICQService) FindByICQEmail(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16) error {
+func (s *ICQService) FindByICQEmail(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16) error {
 	resp := wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
 		ICQMetadata: wire.ICQMetadata{
 			UIN:     instance.UIN(),
@@ -131,7 +138,7 @@ func (s ICQService) FindByICQEmail(ctx context.Context, instance *state.SessionI
 	})
 }
 
-func (s ICQService) FindByEmail3(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3, seq uint16) error {
+func (s *ICQService) FindByEmail3(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3, seq uint16) error {
 	b, hasEmail := inBody.Bytes(wire.ICQTLVTagsEmail)
 	if !hasEmail {
 		return errors.New("unable to get email from request")
@@ -171,7 +178,7 @@ func (s ICQService) FindByEmail3(ctx context.Context, instance *state.SessionIns
 	})
 }
 
-func (s ICQService) FindByICQInterests(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16) error {
+func (s *ICQService) FindByICQInterests(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16) error {
 	resp := wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
 		ICQMetadata: wire.ICQMetadata{
 			UIN:     instance.UIN(),
@@ -216,7 +223,7 @@ func (s ICQService) FindByICQInterests(ctx context.Context, instance *state.Sess
 	return nil
 }
 
-func (s ICQService) FindByWhitePages2(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2, seq uint16) error {
+func (s *ICQService) FindByWhitePages2(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2, seq uint16) error {
 
 	users, err := func() ([]state.User, error) {
 		if keyword, hasKeyword := inBody.ICQString(wire.ICQTLVTagsWhitepagesSearchKeywords); hasKeyword {
@@ -284,7 +291,7 @@ func (s ICQService) FindByWhitePages2(ctx context.Context, instance *state.Sessi
 	return nil
 }
 
-func (s ICQService) FindByUIN(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error {
+func (s *ICQService) FindByUIN(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error {
 	resp := wire.ICQ_0x07DA_0x01AE_DBQueryMetaReplyLastUserFound{
 		ICQMetadata: wire.ICQMetadata{
 			UIN:     instance.UIN(),
@@ -314,7 +321,7 @@ func (s ICQService) FindByUIN(ctx context.Context, instance *state.SessionInstan
 	})
 }
 
-func (s ICQService) FindByUIN2(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2, seq uint16) error {
+func (s *ICQService) FindByUIN2(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2, seq uint16) error {
 	UIN, hasUIN := inBody.Uint32LE(wire.ICQTLVTagsUIN)
 	if !hasUIN {
 		return errors.New("unable to get UIN from request")
@@ -349,7 +356,7 @@ func (s ICQService) FindByUIN2(ctx context.Context, instance *state.SessionInsta
 	})
 }
 
-func (s ICQService) FullUserInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error {
+func (s *ICQService) FullUserInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error {
 
 	user, err := s.userFinder.FindByUIN(ctx, inBody.UIN)
 	if err != nil {
@@ -390,7 +397,7 @@ func (s ICQService) FullUserInfo(ctx context.Context, instance *state.SessionIns
 	return nil
 }
 
-func (s ICQService) OfflineMsgReq(ctx context.Context, instance *state.SessionInstance, seq uint16) error {
+func (s *ICQService) OfflineMsgReq(ctx context.Context, instance *state.SessionInstance, seq uint16) error {
 	messages, err := s.offlineMessageManager.RetrieveMessages(ctx, instance.IdentScreenName())
 	if err != nil {
 		return fmt.Errorf("retrieving messages: %w", err)
@@ -432,15 +439,8 @@ func (s ICQService) OfflineMsgReq(ctx context.Context, instance *state.SessionIn
 				if instance.Session().UsesFeedbag() {
 					// send auth grant/deny/request SNACs instead of the legacy MSG_TYPE_*
 					// ICQ messages.
-					frame := wire.SNACFrame{
-						FoodGroup: wire.ICBM,
-						SubGroup:  wire.ICBMChannelMsgToHost,
-					}
-					// fake a session since the sender may be offline
-					sender := state.NewSession()
-					sender.SetIdentScreenName(msgIn.Sender)
-					if _, err := s.icbmSender(ctx, sender.AddInstance(), frame, msgIn.Message); err != nil {
-						return fmt.Errorf("s.icbmSender: %w", err)
+					if err := s.forwardICQAuthEvents(ctx, msgIn.Sender, msgIn.Recipient, msg); err != nil {
+						return fmt.Errorf("s.forwardICQAuthEvents: %w", err)
 					}
 					continue // do not send these messages in response
 				}
@@ -481,7 +481,7 @@ func (s ICQService) OfflineMsgReq(ctx context.Context, instance *state.SessionIn
 	return nil
 }
 
-func (s ICQService) SetAffiliations(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations, seq uint16) error {
+func (s *ICQService) SetAffiliations(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations, seq uint16) error {
 	if len(inBody.PastAffiliations) != 3 || len(inBody.Affiliations) != 3 {
 		return fmt.Errorf("%w: expected 3 past affiliations and 3 affiliations", errICQBadRequest)
 	}
@@ -507,7 +507,7 @@ func (s ICQService) SetAffiliations(ctx context.Context, instance *state.Session
 	return s.reqAck(ctx, instance, seq, wire.ICQDBQueryMetaReplySetAffiliations)
 }
 
-func (s ICQService) SetBasicInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo, seq uint16) error {
+func (s *ICQService) SetBasicInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo, seq uint16) error {
 	u := state.ICQBasicInfo{
 		CellPhone:    inBody.CellPhone,
 		CountryCode:  inBody.CountryCode,
@@ -532,19 +532,19 @@ func (s ICQService) SetBasicInfo(ctx context.Context, instance *state.SessionIns
 	return s.reqAck(ctx, instance, seq, wire.ICQDBQueryMetaReplySetBasicInfo)
 }
 
-func (s ICQService) SetEmails(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails, seq uint16) error {
+func (s *ICQService) SetEmails(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails, seq uint16) error {
 	if len(inBody.Emails) > 0 {
 		s.logger.Debug("adding additional emails is not yet supported")
 	}
 	return s.reqAck(ctx, instance, seq, wire.ICQDBQueryMetaReplySetEmails)
 }
 
-func (s ICQService) SetICQPhone(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0654_DBQueryMetaReqSetICQPhone, seq uint16) error {
+func (s *ICQService) SetICQPhone(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0654_DBQueryMetaReqSetICQPhone, seq uint16) error {
 	s.logger.Debug("received SetICQPhone request")
 	return s.reqAck(ctx, instance, seq, wire.ICQDBQueryMetaReplySetICQPhone)
 }
 
-func (s ICQService) SetInterests(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests, seq uint16) error {
+func (s *ICQService) SetInterests(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests, seq uint16) error {
 	if len(inBody.Interests) != 4 {
 		return fmt.Errorf("%w: expected 4 interests", errICQBadRequest)
 	}
@@ -566,7 +566,7 @@ func (s ICQService) SetInterests(ctx context.Context, instance *state.SessionIns
 	return s.reqAck(ctx, instance, seq, wire.ICQDBQueryMetaReplySetInterests)
 }
 
-func (s ICQService) SetMoreInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo, seq uint16) error {
+func (s *ICQService) SetMoreInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo, seq uint16) error {
 	u := state.ICQMoreInfo{
 		Gender:       inBody.Gender,
 		HomePageAddr: inBody.HomePageAddr,
@@ -585,7 +585,10 @@ func (s ICQService) SetMoreInfo(ctx context.Context, instance *state.SessionInst
 	return s.reqAck(ctx, instance, seq, wire.ICQDBQueryMetaReplySetMoreInfo)
 }
 
-func (s ICQService) SetPermissions(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions, seq uint16) error {
+// SetPermissions persists ICQ privacy flags. AuthRequired controls whether other
+// users need authorization to add this user; when true, contact pre-authorization
+// can allow specific users to add without a new prompt.
+func (s *ICQService) SetPermissions(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions, seq uint16) error {
 	u := state.ICQPermissions{
 		AuthRequired: inBody.Authorization == 0,
 		WebAware:     inBody.WebAware == 1,
@@ -598,7 +601,7 @@ func (s ICQService) SetPermissions(ctx context.Context, instance *state.SessionI
 	return s.reqAck(ctx, instance, seq, wire.ICQDBQueryMetaReplySetPermissions)
 }
 
-func (s ICQService) SetUserNotes(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes, seq uint16) error {
+func (s *ICQService) SetUserNotes(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes, seq uint16) error {
 	u := state.ICQUserNotes{
 		Notes: inBody.Notes,
 	}
@@ -610,7 +613,7 @@ func (s ICQService) SetUserNotes(ctx context.Context, instance *state.SessionIns
 	return s.reqAck(ctx, instance, seq, wire.ICQDBQueryMetaReplySetNotes)
 }
 
-func (s ICQService) SetWorkInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo, seq uint16) error {
+func (s *ICQService) SetWorkInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo, seq uint16) error {
 	icqWorkInfo := state.ICQWorkInfo{
 		Company:        inBody.Company,
 		Department:     inBody.Department,
@@ -633,7 +636,7 @@ func (s ICQService) SetWorkInfo(ctx context.Context, instance *state.SessionInst
 	return s.reqAck(ctx, instance, seq, wire.ICQDBQueryMetaReplySetWorkInfo)
 }
 
-func (s ICQService) ShortUserInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x04BA_DBQueryMetaReqShortInfo, seq uint16) error {
+func (s *ICQService) ShortUserInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x04BA_DBQueryMetaReqShortInfo, seq uint16) error {
 	user, err := s.userFinder.FindByUIN(ctx, inBody.UIN)
 	if err != nil {
 		return err
@@ -664,7 +667,7 @@ func (s ICQService) ShortUserInfo(ctx context.Context, instance *state.SessionIn
 	return s.reply(ctx, instance, msg)
 }
 
-func (s ICQService) XMLReqData(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq, seq uint16) error {
+func (s *ICQService) XMLReqData(ctx context.Context, instance *state.SessionInstance, inBody wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq, seq uint16) error {
 	msg := wire.ICQMessageReplyEnvelope{
 		Message: wire.ICQ_0x07DA_0x08A2_DBQueryMetaReplyXMLData{
 			ICQMetadata: wire.ICQMetadata{
@@ -679,7 +682,7 @@ func (s ICQService) XMLReqData(ctx context.Context, instance *state.SessionInsta
 	return s.reply(ctx, instance, msg)
 }
 
-func (s ICQService) affiliations(ctx context.Context, instance *state.SessionInstance, user state.User, seq uint16) error {
+func (s *ICQService) affiliations(ctx context.Context, instance *state.SessionInstance, user state.User, seq uint16) error {
 	msg := wire.ICQMessageReplyEnvelope{
 		Message: wire.ICQ_0x07DA_0x00FA_DBQueryMetaReplyAffiliations{
 			ICQMetadata: wire.ICQMetadata{
@@ -731,7 +734,7 @@ func (s ICQService) affiliations(ctx context.Context, instance *state.SessionIns
 	return s.reply(ctx, instance, msg)
 }
 
-func (s ICQService) createResult(res state.User) wire.ICQUserSearchRecord {
+func (s *ICQService) createResult(res state.User) wire.ICQUserSearchRecord {
 	uin, _ := strconv.Atoi(res.IdentScreenName.String())
 
 	searchRecord := wire.ICQUserSearchRecord{
@@ -754,7 +757,7 @@ func (s ICQService) createResult(res state.User) wire.ICQUserSearchRecord {
 	return searchRecord
 }
 
-func (s ICQService) extraEmails(ctx context.Context, instance *state.SessionInstance, user state.User, seq uint16) error {
+func (s *ICQService) extraEmails(ctx context.Context, instance *state.SessionInstance, user state.User, seq uint16) error {
 	msg := wire.ICQMessageReplyEnvelope{
 		Message: wire.ICQ_0x07DA_0x00EB_DBQueryMetaReplyExtEmailInfo{
 			ICQMetadata: wire.ICQMetadata{
@@ -770,7 +773,7 @@ func (s ICQService) extraEmails(ctx context.Context, instance *state.SessionInst
 	return s.reply(ctx, instance, msg)
 }
 
-func (s ICQService) homepageCat(ctx context.Context, instance *state.SessionInstance, user state.User, seq uint16) error {
+func (s *ICQService) homepageCat(ctx context.Context, instance *state.SessionInstance, user state.User, seq uint16) error {
 	msg := wire.ICQMessageReplyEnvelope{
 		Message: wire.ICQ_0x07DA_0x010E_DBQueryMetaReplyHomePageCat{
 			ICQMetadata: wire.ICQMetadata{
@@ -786,7 +789,7 @@ func (s ICQService) homepageCat(ctx context.Context, instance *state.SessionInst
 	return s.reply(ctx, instance, msg)
 }
 
-func (s ICQService) interests(ctx context.Context, instance *state.SessionInstance, user state.User, seq uint16) error {
+func (s *ICQService) interests(ctx context.Context, instance *state.SessionInstance, user state.User, seq uint16) error {
 	msg := wire.ICQMessageReplyEnvelope{
 		Message: wire.ICQ_0x07DA_0x00F0_DBQueryMetaReplyInterests{
 			ICQMetadata: wire.ICQMetadata{
@@ -823,7 +826,7 @@ func (s ICQService) interests(ctx context.Context, instance *state.SessionInstan
 	return s.reply(ctx, instance, msg)
 }
 
-func (s ICQService) moreUserInfo(ctx context.Context, instance *state.SessionInstance, user state.User, seq uint16) error {
+func (s *ICQService) moreUserInfo(ctx context.Context, instance *state.SessionInstance, user state.User, seq uint16) error {
 	msg := wire.ICQMessageReplyEnvelope{
 		Message: wire.ICQ_0x07DA_0x00DC_DBQueryMetaReplyMoreInfo{
 			ICQMetadata: wire.ICQMetadata{
@@ -854,7 +857,7 @@ func (s ICQService) moreUserInfo(ctx context.Context, instance *state.SessionIns
 	return s.reply(ctx, instance, msg)
 }
 
-func (s ICQService) notes(ctx context.Context, instance *state.SessionInstance, user state.User, seq uint16) error {
+func (s *ICQService) notes(ctx context.Context, instance *state.SessionInstance, user state.User, seq uint16) error {
 	msg := wire.ICQMessageReplyEnvelope{
 		Message: wire.ICQ_0x07DA_0x00E6_DBQueryMetaReplyNotes{
 			ICQMetadata: wire.ICQMetadata{
@@ -873,7 +876,7 @@ func (s ICQService) notes(ctx context.Context, instance *state.SessionInstance,
 	return s.reply(ctx, instance, msg)
 }
 
-func (s ICQService) reply(ctx context.Context, instance *state.SessionInstance, message wire.ICQMessageReplyEnvelope) error {
+func (s *ICQService) reply(ctx context.Context, instance *state.SessionInstance, message wire.ICQMessageReplyEnvelope) error {
 	msg := wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.ICQ,
@@ -892,7 +895,7 @@ func (s ICQService) reply(ctx context.Context, instance *state.SessionInstance,
 	return nil
 }
 
-func (s ICQService) reqAck(ctx context.Context, instance *state.SessionInstance, seq uint16, subType uint16) error {
+func (s *ICQService) reqAck(ctx context.Context, instance *state.SessionInstance, seq uint16, subType uint16) error {
 	msg := wire.ICQMessageReplyEnvelope{
 		Message: wire.ICQ_0x07DA_0x00DC_DBQueryMetaReplyMoreInfo{
 			ICQMetadata: wire.ICQMetadata{
@@ -908,7 +911,7 @@ func (s ICQService) reqAck(ctx context.Context, instance *state.SessionInstance,
 	return s.reply(ctx, instance, msg)
 }
 
-func (s ICQService) userInfo(ctx context.Context, instance *state.SessionInstance, user state.User, seq uint16) error {
+func (s *ICQService) userInfo(ctx context.Context, instance *state.SessionInstance, user state.User, seq uint16) error {
 	userInfo := wire.ICQ_0x07DA_0x00C8_DBQueryMetaReplyBasicInfo{
 		ICQMetadata: wire.ICQMetadata{
 			UIN:     instance.UIN(),
@@ -957,7 +960,7 @@ func (s ICQService) userInfo(ctx context.Context, instance *state.SessionInstanc
 
 }
 
-func (s ICQService) workInfo(ctx context.Context, instance *state.SessionInstance, user state.User, seq uint16) error {
+func (s *ICQService) workInfo(ctx context.Context, instance *state.SessionInstance, user state.User, seq uint16) error {
 	msg := wire.ICQMessageReplyEnvelope{
 		Message: wire.ICQ_0x07DA_0x00D2_DBQueryMetaReplyWorkInfo{
 			ICQMetadata: wire.ICQMetadata{

+ 8 - 8
foodgroup/icq_test.go

@@ -46,7 +46,7 @@ func TestICQService_DeleteMsgReq(t *testing.T) {
 					Return(params.err)
 			}
 
-			s := NewICQService(nil, nil, nil, slog.Default(), nil, offlineMessageManager, nil)
+			s := NewICQService(nil, nil, nil, slog.Default(), nil, offlineMessageManager)
 			err := s.DeleteMsgReq(context.Background(), tt.instance, tt.seq)
 			assert.NoError(t, err)
 		})
@@ -2030,16 +2030,16 @@ func TestICQService_OfflineMsgReq(t *testing.T) {
 					RelayToScreenName(mock.Anything, params.screenName, params.message)
 			}
 
-			var icbmSenderCalls int
-			icbmSender := func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
-				icbmSenderCalls++
-				return nil, nil
-			}
+			var feedbagSenderCalls int
 
-			s := NewICQService(messageRelayer, nil, nil, slog.Default(), nil, offlineMessageManager, icbmSender)
+			s := NewICQService(messageRelayer, nil, nil, slog.Default(), nil, offlineMessageManager)
+			s.forwardICQAuthEvents = func(ctx context.Context, sender state.IdentScreenName, recipient state.IdentScreenName, authMsg wire.ICBMCh4Message) error {
+				feedbagSenderCalls++
+				return nil
+			}
 			err := s.OfflineMsgReq(context.Background(), tt.instance, tt.seq)
 			assert.NoError(t, err)
-			assert.Equal(t, tt.wantICBMSenderCalls, icbmSenderCalls)
+			assert.Equal(t, tt.wantICBMSenderCalls, feedbagSenderCalls)
 		})
 	}
 }

+ 174 - 0
foodgroup/mock_contact_pre_authorizer_test.go

@@ -0,0 +1,174 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package foodgroup
+
+import (
+	"context"
+
+	"github.com/mk6i/open-oscar-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// newMockContactPreAuthorizer creates a new instance of mockContactPreAuthorizer. 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 newMockContactPreAuthorizer(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockContactPreAuthorizer {
+	mock := &mockContactPreAuthorizer{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
+
+// mockContactPreAuthorizer is an autogenerated mock type for the ContactPreAuthorizer type
+type mockContactPreAuthorizer struct {
+	mock.Mock
+}
+
+type mockContactPreAuthorizer_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockContactPreAuthorizer) EXPECT() *mockContactPreAuthorizer_Expecter {
+	return &mockContactPreAuthorizer_Expecter{mock: &_m.Mock}
+}
+
+// RecordPreAuth provides a mock function for the type mockContactPreAuthorizer
+func (_mock *mockContactPreAuthorizer) RecordPreAuth(ctx context.Context, owner state.IdentScreenName, requester state.IdentScreenName) error {
+	ret := _mock.Called(ctx, owner, requester)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RecordPreAuth")
+	}
+
+	var r0 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, state.IdentScreenName) error); ok {
+		r0 = returnFunc(ctx, owner, requester)
+	} else {
+		r0 = ret.Error(0)
+	}
+	return r0
+}
+
+// mockContactPreAuthorizer_RecordPreAuth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordPreAuth'
+type mockContactPreAuthorizer_RecordPreAuth_Call struct {
+	*mock.Call
+}
+
+// RecordPreAuth is a helper method to define mock.On call
+//   - ctx context.Context
+//   - owner state.IdentScreenName
+//   - requester state.IdentScreenName
+func (_e *mockContactPreAuthorizer_Expecter) RecordPreAuth(ctx interface{}, owner interface{}, requester interface{}) *mockContactPreAuthorizer_RecordPreAuth_Call {
+	return &mockContactPreAuthorizer_RecordPreAuth_Call{Call: _e.mock.On("RecordPreAuth", ctx, owner, requester)}
+}
+
+func (_c *mockContactPreAuthorizer_RecordPreAuth_Call) Run(run func(ctx context.Context, owner state.IdentScreenName, requester state.IdentScreenName)) *mockContactPreAuthorizer_RecordPreAuth_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 state.IdentScreenName
+		if args[1] != nil {
+			arg1 = args[1].(state.IdentScreenName)
+		}
+		var arg2 state.IdentScreenName
+		if args[2] != nil {
+			arg2 = args[2].(state.IdentScreenName)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockContactPreAuthorizer_RecordPreAuth_Call) Return(err error) *mockContactPreAuthorizer_RecordPreAuth_Call {
+	_c.Call.Return(err)
+	return _c
+}
+
+func (_c *mockContactPreAuthorizer_RecordPreAuth_Call) RunAndReturn(run func(ctx context.Context, owner state.IdentScreenName, requester state.IdentScreenName) error) *mockContactPreAuthorizer_RecordPreAuth_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RequiresAuthorization provides a mock function for the type mockContactPreAuthorizer
+func (_mock *mockContactPreAuthorizer) RequiresAuthorization(ctx context.Context, owner state.IdentScreenName, requester state.IdentScreenName) (bool, error) {
+	ret := _mock.Called(ctx, owner, requester)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RequiresAuthorization")
+	}
+
+	var r0 bool
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, state.IdentScreenName) (bool, error)); ok {
+		return returnFunc(ctx, owner, requester)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, state.IdentScreenName) bool); ok {
+		r0 = returnFunc(ctx, owner, requester)
+	} else {
+		r0 = ret.Get(0).(bool)
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, state.IdentScreenName, state.IdentScreenName) error); ok {
+		r1 = returnFunc(ctx, owner, requester)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockContactPreAuthorizer_RequiresAuthorization_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RequiresAuthorization'
+type mockContactPreAuthorizer_RequiresAuthorization_Call struct {
+	*mock.Call
+}
+
+// RequiresAuthorization is a helper method to define mock.On call
+//   - ctx context.Context
+//   - owner state.IdentScreenName
+//   - requester state.IdentScreenName
+func (_e *mockContactPreAuthorizer_Expecter) RequiresAuthorization(ctx interface{}, owner interface{}, requester interface{}) *mockContactPreAuthorizer_RequiresAuthorization_Call {
+	return &mockContactPreAuthorizer_RequiresAuthorization_Call{Call: _e.mock.On("RequiresAuthorization", ctx, owner, requester)}
+}
+
+func (_c *mockContactPreAuthorizer_RequiresAuthorization_Call) Run(run func(ctx context.Context, owner state.IdentScreenName, requester state.IdentScreenName)) *mockContactPreAuthorizer_RequiresAuthorization_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 state.IdentScreenName
+		if args[1] != nil {
+			arg1 = args[1].(state.IdentScreenName)
+		}
+		var arg2 state.IdentScreenName
+		if args[2] != nil {
+			arg2 = args[2].(state.IdentScreenName)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+		)
+	})
+	return _c
+}
+
+func (_c *mockContactPreAuthorizer_RequiresAuthorization_Call) Return(b bool, err error) *mockContactPreAuthorizer_RequiresAuthorization_Call {
+	_c.Call.Return(b, err)
+	return _c
+}
+
+func (_c *mockContactPreAuthorizer_RequiresAuthorization_Call) RunAndReturn(run func(ctx context.Context, owner state.IdentScreenName, requester state.IdentScreenName) (bool, error)) *mockContactPreAuthorizer_RequiresAuthorization_Call {
+	_c.Call.Return(run)
+	return _c
+}

+ 14 - 0
foodgroup/types.go

@@ -116,6 +116,20 @@ type BARTItemManager interface {
 	DeleteBARTItem(ctx context.Context, hash []byte) error
 }
 
+// ContactPreAuthorizer evaluates and records contact pre-authorization: when
+// owner requires authorization to be added, requester must hold a grant from owner.
+type ContactPreAuthorizer interface {
+	// RequiresAuthorization reports whether requester must obtain authorization
+	// from owner before adding owner as a buddy. Returns false if owner does
+	// not require authorization or has already pre-authorized requester.
+	RequiresAuthorization(ctx context.Context, owner, requester state.IdentScreenName) (bool, error)
+
+	// RecordPreAuth records that owner has pre-authorized requester to add
+	// owner as a buddy without an authorization prompt. Returns
+	// state.ErrNoUser if requester does not exist.
+	RecordPreAuth(ctx context.Context, owner, requester state.IdentScreenName) error
+}
+
 // RelationshipFetcher is the interface for retrieving relationships between users.
 type RelationshipFetcher interface {
 	// AllRelationships retrieves the relationships between the specified user (`me`)

+ 47 - 6
server/oscar/handler.go

@@ -168,8 +168,19 @@ func (rt Handler) BuddyAddBuddies(ctx context.Context, instance *state.SessionIn
 	if err := wire.UnmarshalBE(&inSNAC, r); err != nil {
 		return err
 	}
-	rt.LogRequest(ctx, inFrame, inSNAC)
-	return rt.BuddyService.AddBuddies(ctx, instance, inSNAC)
+	rejectSNAC, err := rt.BuddyService.AddBuddies(ctx, instance, inFrame, inSNAC)
+	if err != nil {
+		return err
+	}
+	if rejectSNAC != nil {
+		rt.LogRequestAndResponse(ctx, inFrame, inSNAC, rejectSNAC.Frame, rejectSNAC.Body)
+		if sendErr := rw.SendSNAC(rejectSNAC.Frame, rejectSNAC.Body); sendErr != nil {
+			return sendErr
+		}
+	} else {
+		rt.LogRequest(ctx, inFrame, inSNAC)
+	}
+	return nil
 }
 
 func (rt Handler) BuddyDelBuddies(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
@@ -186,8 +197,19 @@ func (rt Handler) BuddyAddTempBuddies(ctx context.Context, instance *state.Sessi
 	if err := wire.UnmarshalBE(&inSNAC, r); err != nil {
 		return err
 	}
-	rt.LogRequest(ctx, inFrame, inSNAC)
-	return rt.BuddyService.AddTempBuddies(ctx, instance, inSNAC)
+	rejectSNAC, err := rt.BuddyService.AddTempBuddies(ctx, instance, inFrame, inSNAC)
+	if err != nil {
+		return err
+	}
+	if rejectSNAC != nil {
+		rt.LogRequestAndResponse(ctx, inFrame, inSNAC, rejectSNAC.Frame, rejectSNAC.Body)
+		if sendErr := rw.SendSNAC(rejectSNAC.Frame, rejectSNAC.Body); sendErr != nil {
+			return sendErr
+		}
+	} else {
+		rt.LogRequest(ctx, inFrame, inSNAC)
+	}
+	return nil
 }
 
 func (rt Handler) BuddyDelTempBuddies(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
@@ -367,6 +389,23 @@ func (rt Handler) FeedbagEndCluster(ctx context.Context, instance *state.Session
 	return nil
 }
 
+func (rt Handler) FeedbagPreAuthorizeBuddy(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.FeedbagService.PreAuthorizeBuddy(ctx, instance, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	if outSNAC != nil {
+		rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+		return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+	}
+	rt.LogRequest(ctx, inFrame, inBody)
+	return nil
+}
+
 func (rt Handler) FeedbagRequestAuthorizeToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
 	inBody := wire.SNAC_0x13_0x18_FeedbagRequestAuthorizationToHost{}
 	if err := wire.UnmarshalBE(&inBody, r); err != nil {
@@ -384,7 +423,7 @@ func (rt Handler) FeedbagRespondAuthorizeToHost(ctx context.Context, instance *s
 	if err := wire.UnmarshalBE(&inBody, r); err != nil {
 		return err
 	}
-	if err := rt.FeedbagService.RespondAuthorizeToHost(ctx, instance, inFrame, inBody); err != nil {
+	if err := rt.FeedbagService.RespondAuthorizeToHost(ctx, instance.IdentScreenName(), inFrame, inBody); err != nil {
 		return err
 	}
 	rt.LogRequest(ctx, inFrame, inBody)
@@ -412,8 +451,8 @@ func (rt Handler) ICBMChannelMsgToHost(ctx context.Context, instance *state.Sess
 	if err != nil {
 		return err
 	}
-	rt.Logger.InfoContext(ctx, "user sent an IM", slog.String("recipient", inBody.ScreenName))
 	if outSNAC == nil {
+		rt.LogRequest(ctx, inFrame, inBody)
 		return nil
 	}
 	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
@@ -1086,6 +1125,8 @@ func (rt Handler) Handle(ctx context.Context, server uint16, instance *state.Ses
 			return rt.FeedbagQuery(ctx, instance, inFrame, r, rw)
 		case wire.FeedbagQueryIfModified:
 			return rt.FeedbagQueryIfModified(ctx, instance, inFrame, r, rw)
+		case wire.FeedbagPreAuthorizeBuddy:
+			return rt.FeedbagPreAuthorizeBuddy(ctx, instance, inFrame, r, rw)
 		case wire.FeedbagRequestAuthorizeToHost:
 			return rt.FeedbagRequestAuthorizeToHost(ctx, instance, inFrame, r, rw)
 		case wire.FeedbagRespondAuthorizeToHost:

+ 37 - 4
server/oscar/handler_test.go

@@ -755,8 +755,8 @@ func TestHandler_BuddyAddBuddies(t *testing.T) {
 
 			svc := newMockBuddyService(t)
 			svc.EXPECT().
-				AddBuddies(mock.Anything, mock.Anything, input.Body).
-				Return(tt.serviceError)
+				AddBuddies(mock.Anything, mock.Anything, mock.Anything, input.Body).
+				Return(nil, tt.serviceError)
 
 			h := Handler{
 				BuddyService: svc,
@@ -899,8 +899,8 @@ func TestHandler_BuddyAddTempBuddies(t *testing.T) {
 
 			svc := newMockBuddyService(t)
 			svc.EXPECT().
-				AddTempBuddies(mock.Anything, mock.Anything, input.Body).
-				Return(tt.serviceError)
+				AddTempBuddies(mock.Anything, mock.Anything, mock.Anything, input.Body).
+				Return(nil, tt.serviceError)
 
 			h := Handler{
 				BuddyService: svc,
@@ -2306,6 +2306,39 @@ func TestHandler_FeedbagRespondAuthorizeToHost(t *testing.T) {
 	}
 	responseWriter := newMockResponseWriter(t)
 
+	instance := state.NewSession().AddInstance()
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, instance, input.Frame, buf, responseWriter, config.Listener{}))
+}
+
+func TestHandler_FeedbagPreAuthorizeBuddy(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagPreAuthorizeBuddy,
+		},
+		Body: wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy{
+			ScreenName: "100002",
+			Message:    "x",
+		},
+	}
+
+	svc := newMockFeedbagService(t)
+	svc.EXPECT().
+		PreAuthorizeBuddy(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(nil, nil)
+
+	h := Handler{
+		FeedbagService: svc,
+		RouteLogger: middleware.RouteLogger{
+			Logger: slog.Default(),
+		},
+	}
+	responseWriter := newMockResponseWriter(t)
+
 	buf := &bytes.Buffer{}
 	assert.NoError(t, wire.MarshalBE(input.Body, buf))
 

+ 64 - 30
server/oscar/mock_buddy_service_test.go

@@ -40,20 +40,31 @@ func (_m *mockBuddyService) EXPECT() *mockBuddyService_Expecter {
 }
 
 // AddBuddies provides a mock function for the type mockBuddyService
-func (_mock *mockBuddyService) AddBuddies(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) error {
-	ret := _mock.Called(ctx, instance, inBody)
+func (_mock *mockBuddyService) AddBuddies(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) (*wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame, inBody)
 
 	if len(ret) == 0 {
 		panic("no return value specified for AddBuddies")
 	}
 
-	var r0 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNAC_0x03_0x04_BuddyAddBuddies) error); ok {
-		r0 = returnFunc(ctx, instance, inBody)
+	var r0 *wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x03_0x04_BuddyAddBuddies) (*wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x03_0x04_BuddyAddBuddies) *wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody)
 	} else {
-		r0 = ret.Error(0)
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.SNACMessage)
+		}
 	}
-	return r0
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x03_0x04_BuddyAddBuddies) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
 }
 
 // mockBuddyService_AddBuddies_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddBuddies'
@@ -64,12 +75,13 @@ type mockBuddyService_AddBuddies_Call struct {
 // AddBuddies is a helper method to define mock.On call
 //   - ctx context.Context
 //   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
 //   - inBody wire.SNAC_0x03_0x04_BuddyAddBuddies
-func (_e *mockBuddyService_Expecter) AddBuddies(ctx interface{}, instance interface{}, inBody interface{}) *mockBuddyService_AddBuddies_Call {
-	return &mockBuddyService_AddBuddies_Call{Call: _e.mock.On("AddBuddies", ctx, instance, inBody)}
+func (_e *mockBuddyService_Expecter) AddBuddies(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockBuddyService_AddBuddies_Call {
+	return &mockBuddyService_AddBuddies_Call{Call: _e.mock.On("AddBuddies", ctx, instance, inFrame, inBody)}
 }
 
-func (_c *mockBuddyService_AddBuddies_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies)) *mockBuddyService_AddBuddies_Call {
+func (_c *mockBuddyService_AddBuddies_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies)) *mockBuddyService_AddBuddies_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -79,44 +91,60 @@ func (_c *mockBuddyService_AddBuddies_Call) Run(run func(ctx context.Context, in
 		if args[1] != nil {
 			arg1 = args[1].(*state.SessionInstance)
 		}
-		var arg2 wire.SNAC_0x03_0x04_BuddyAddBuddies
+		var arg2 wire.SNACFrame
 		if args[2] != nil {
-			arg2 = args[2].(wire.SNAC_0x03_0x04_BuddyAddBuddies)
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x03_0x04_BuddyAddBuddies
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x03_0x04_BuddyAddBuddies)
 		}
 		run(
 			arg0,
 			arg1,
 			arg2,
+			arg3,
 		)
 	})
 	return _c
 }
 
-func (_c *mockBuddyService_AddBuddies_Call) Return(err error) *mockBuddyService_AddBuddies_Call {
-	_c.Call.Return(err)
+func (_c *mockBuddyService_AddBuddies_Call) Return(sNACMessage *wire.SNACMessage, err error) *mockBuddyService_AddBuddies_Call {
+	_c.Call.Return(sNACMessage, err)
 	return _c
 }
 
-func (_c *mockBuddyService_AddBuddies_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) error) *mockBuddyService_AddBuddies_Call {
+func (_c *mockBuddyService_AddBuddies_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) (*wire.SNACMessage, error)) *mockBuddyService_AddBuddies_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
 // AddTempBuddies provides a mock function for the type mockBuddyService
-func (_mock *mockBuddyService) AddTempBuddies(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x0F_BuddyAddTempBuddies) error {
-	ret := _mock.Called(ctx, instance, inBody)
+func (_mock *mockBuddyService) AddTempBuddies(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x0F_BuddyAddTempBuddies) (*wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame, inBody)
 
 	if len(ret) == 0 {
 		panic("no return value specified for AddTempBuddies")
 	}
 
-	var r0 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNAC_0x03_0x0F_BuddyAddTempBuddies) error); ok {
-		r0 = returnFunc(ctx, instance, inBody)
+	var r0 *wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x03_0x0F_BuddyAddTempBuddies) (*wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x03_0x0F_BuddyAddTempBuddies) *wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody)
 	} else {
-		r0 = ret.Error(0)
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.SNACMessage)
+		}
 	}
-	return r0
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x03_0x0F_BuddyAddTempBuddies) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
 }
 
 // mockBuddyService_AddTempBuddies_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddTempBuddies'
@@ -127,12 +155,13 @@ type mockBuddyService_AddTempBuddies_Call struct {
 // AddTempBuddies is a helper method to define mock.On call
 //   - ctx context.Context
 //   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
 //   - inBody wire.SNAC_0x03_0x0F_BuddyAddTempBuddies
-func (_e *mockBuddyService_Expecter) AddTempBuddies(ctx interface{}, instance interface{}, inBody interface{}) *mockBuddyService_AddTempBuddies_Call {
-	return &mockBuddyService_AddTempBuddies_Call{Call: _e.mock.On("AddTempBuddies", ctx, instance, inBody)}
+func (_e *mockBuddyService_Expecter) AddTempBuddies(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockBuddyService_AddTempBuddies_Call {
+	return &mockBuddyService_AddTempBuddies_Call{Call: _e.mock.On("AddTempBuddies", ctx, instance, inFrame, inBody)}
 }
 
-func (_c *mockBuddyService_AddTempBuddies_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x0F_BuddyAddTempBuddies)) *mockBuddyService_AddTempBuddies_Call {
+func (_c *mockBuddyService_AddTempBuddies_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x0F_BuddyAddTempBuddies)) *mockBuddyService_AddTempBuddies_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -142,25 +171,30 @@ func (_c *mockBuddyService_AddTempBuddies_Call) Run(run func(ctx context.Context
 		if args[1] != nil {
 			arg1 = args[1].(*state.SessionInstance)
 		}
-		var arg2 wire.SNAC_0x03_0x0F_BuddyAddTempBuddies
+		var arg2 wire.SNACFrame
 		if args[2] != nil {
-			arg2 = args[2].(wire.SNAC_0x03_0x0F_BuddyAddTempBuddies)
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x03_0x0F_BuddyAddTempBuddies
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x03_0x0F_BuddyAddTempBuddies)
 		}
 		run(
 			arg0,
 			arg1,
 			arg2,
+			arg3,
 		)
 	})
 	return _c
 }
 
-func (_c *mockBuddyService_AddTempBuddies_Call) Return(err error) *mockBuddyService_AddTempBuddies_Call {
-	_c.Call.Return(err)
+func (_c *mockBuddyService_AddTempBuddies_Call) Return(sNACMessage *wire.SNACMessage, err error) *mockBuddyService_AddTempBuddies_Call {
+	_c.Call.Return(sNACMessage, err)
 	return _c
 }
 
-func (_c *mockBuddyService_AddTempBuddies_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x0F_BuddyAddTempBuddies) error) *mockBuddyService_AddTempBuddies_Call {
+func (_c *mockBuddyService_AddTempBuddies_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x0F_BuddyAddTempBuddies) (*wire.SNACMessage, error)) *mockBuddyService_AddTempBuddies_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 87 - 7
server/oscar/mock_feedbag_service_test.go

@@ -171,6 +171,86 @@ func (_c *mockFeedbagService_EndCluster_Call) RunAndReturn(run func(ctx context.
 	return _c
 }
 
+// PreAuthorizeBuddy provides a mock function for the type mockFeedbagService
+func (_mock *mockFeedbagService) PreAuthorizeBuddy(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy) (*wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for PreAuthorizeBuddy")
+	}
+
+	var r0 *wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy) (*wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy) *wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.SNACMessage)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockFeedbagService_PreAuthorizeBuddy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PreAuthorizeBuddy'
+type mockFeedbagService_PreAuthorizeBuddy_Call struct {
+	*mock.Call
+}
+
+// PreAuthorizeBuddy is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy
+func (_e *mockFeedbagService_Expecter) PreAuthorizeBuddy(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagService_PreAuthorizeBuddy_Call {
+	return &mockFeedbagService_PreAuthorizeBuddy_Call{Call: _e.mock.On("PreAuthorizeBuddy", ctx, instance, inFrame, inBody)}
+}
+
+func (_c *mockFeedbagService_PreAuthorizeBuddy_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy)) *mockFeedbagService_PreAuthorizeBuddy_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_PreAuthorizeBuddy_Call) Return(sNACMessage *wire.SNACMessage, err error) *mockFeedbagService_PreAuthorizeBuddy_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockFeedbagService_PreAuthorizeBuddy_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy) (*wire.SNACMessage, error)) *mockFeedbagService_PreAuthorizeBuddy_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // Query provides a mock function for the type mockFeedbagService
 func (_mock *mockFeedbagService) Query(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
 	ret := _mock.Called(ctx, instance, inFrame)
@@ -391,7 +471,7 @@ func (_c *mockFeedbagService_RequestAuthorizeToHost_Call) RunAndReturn(run func(
 }
 
 // RespondAuthorizeToHost provides a mock function for the type mockFeedbagService
-func (_mock *mockFeedbagService) RespondAuthorizeToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error {
+func (_mock *mockFeedbagService) RespondAuthorizeToHost(ctx context.Context, instance state.IdentScreenName, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error {
 	ret := _mock.Called(ctx, instance, inFrame, inBody)
 
 	if len(ret) == 0 {
@@ -399,7 +479,7 @@ func (_mock *mockFeedbagService) RespondAuthorizeToHost(ctx context.Context, ins
 	}
 
 	var r0 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error); ok {
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, wire.SNACFrame, wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error); ok {
 		r0 = returnFunc(ctx, instance, inFrame, inBody)
 	} else {
 		r0 = ret.Error(0)
@@ -414,22 +494,22 @@ type mockFeedbagService_RespondAuthorizeToHost_Call struct {
 
 // RespondAuthorizeToHost is a helper method to define mock.On call
 //   - ctx context.Context
-//   - instance *state.SessionInstance
+//   - instance state.IdentScreenName
 //   - inFrame wire.SNACFrame
 //   - inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost
 func (_e *mockFeedbagService_Expecter) RespondAuthorizeToHost(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagService_RespondAuthorizeToHost_Call {
 	return &mockFeedbagService_RespondAuthorizeToHost_Call{Call: _e.mock.On("RespondAuthorizeToHost", ctx, instance, inFrame, inBody)}
 }
 
-func (_c *mockFeedbagService_RespondAuthorizeToHost_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost)) *mockFeedbagService_RespondAuthorizeToHost_Call {
+func (_c *mockFeedbagService_RespondAuthorizeToHost_Call) Run(run func(ctx context.Context, instance state.IdentScreenName, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost)) *mockFeedbagService_RespondAuthorizeToHost_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
+		var arg1 state.IdentScreenName
 		if args[1] != nil {
-			arg1 = args[1].(*state.SessionInstance)
+			arg1 = args[1].(state.IdentScreenName)
 		}
 		var arg2 wire.SNACFrame
 		if args[2] != nil {
@@ -454,7 +534,7 @@ func (_c *mockFeedbagService_RespondAuthorizeToHost_Call) Return(err error) *moc
 	return _c
 }
 
-func (_c *mockFeedbagService_RespondAuthorizeToHost_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error) *mockFeedbagService_RespondAuthorizeToHost_Call {
+func (_c *mockFeedbagService_RespondAuthorizeToHost_Call) RunAndReturn(run func(ctx context.Context, instance state.IdentScreenName, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error) *mockFeedbagService_RespondAuthorizeToHost_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 4 - 3
server/oscar/types.go

@@ -72,9 +72,9 @@ type BARTService interface {
 
 type BuddyService interface {
 	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
-	AddBuddies(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) error
+	AddBuddies(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) (*wire.SNACMessage, error)
 	DelBuddies(_ context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x05_BuddyDelBuddies) error
-	AddTempBuddies(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x0F_BuddyAddTempBuddies) error
+	AddTempBuddies(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x0F_BuddyAddTempBuddies) (*wire.SNACMessage, error)
 	DelTempBuddies(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x10_BuddyDelTempBuddies) error
 }
 
@@ -93,8 +93,9 @@ type FeedbagService interface {
 	DeleteItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (*wire.SNACMessage, error)
 	Query(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error)
 	QueryIfModified(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error)
+	PreAuthorizeBuddy(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy) (*wire.SNACMessage, error)
 	RequestAuthorizeToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x18_FeedbagRequestAuthorizationToHost) error
-	RespondAuthorizeToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error
+	RespondAuthorizeToHost(ctx context.Context, instance state.IdentScreenName, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error
 	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
 	StartCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster)
 	EndCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame)

+ 1 - 1
server/toc/cmd_client.go

@@ -295,7 +295,7 @@ func (s OSCARProxy) AddBuddy(ctx context.Context, me *state.SessionInstance, arg
 		}{ScreenName: sn})
 	}
 
-	if err := s.BuddyService.AddBuddies(ctx, me, snac); err != nil {
+	if _, err := s.BuddyService.AddBuddies(ctx, me, wire.SNACFrame{}, snac); err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("BuddyService.AddBuddies: %w", err))
 	}
 

+ 4 - 4
server/toc/cmd_client_test.go

@@ -104,8 +104,8 @@ func TestOSCARProxy_RecvClientCmd_AddBuddy(t *testing.T) {
 			buddySvc := newMockBuddyService(t)
 			for _, params := range tc.mockParams.addBuddiesParams {
 				buddySvc.EXPECT().
-					AddBuddies(ctx, matchSession(params.me), params.inBody).
-					Return(params.err)
+					AddBuddies(ctx, matchSession(params.me), mock.Anything, params.inBody).
+					Return(nil, params.err)
 			}
 
 			svc := OSCARProxy{
@@ -6011,8 +6011,8 @@ func TestOSCARProxy_RecvClientCmd_SetConfig(t *testing.T) {
 			buddySvc := newMockBuddyService(t)
 			for _, params := range tc.mockParams.addBuddiesParams {
 				buddySvc.EXPECT().
-					AddBuddies(ctx, matchSession(params.me), params.inBody).
-					Return(params.err)
+					AddBuddies(ctx, matchSession(params.me), mock.Anything, params.inBody).
+					Return(nil, params.err)
 			}
 			tocConfigSvc := newMockTOCConfigStore(t)
 			for _, params := range tc.mockParams.setTOCConfigParams {

+ 32 - 15
server/toc/mock_buddy_service_test.go

@@ -40,20 +40,31 @@ func (_m *mockBuddyService) EXPECT() *mockBuddyService_Expecter {
 }
 
 // AddBuddies provides a mock function for the type mockBuddyService
-func (_mock *mockBuddyService) AddBuddies(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) error {
-	ret := _mock.Called(ctx, instance, inBody)
+func (_mock *mockBuddyService) AddBuddies(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) (*wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame, inBody)
 
 	if len(ret) == 0 {
 		panic("no return value specified for AddBuddies")
 	}
 
-	var r0 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNAC_0x03_0x04_BuddyAddBuddies) error); ok {
-		r0 = returnFunc(ctx, instance, inBody)
+	var r0 *wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x03_0x04_BuddyAddBuddies) (*wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x03_0x04_BuddyAddBuddies) *wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody)
 	} else {
-		r0 = ret.Error(0)
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.SNACMessage)
+		}
 	}
-	return r0
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x03_0x04_BuddyAddBuddies) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
 }
 
 // mockBuddyService_AddBuddies_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddBuddies'
@@ -64,12 +75,13 @@ type mockBuddyService_AddBuddies_Call struct {
 // AddBuddies is a helper method to define mock.On call
 //   - ctx context.Context
 //   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
 //   - inBody wire.SNAC_0x03_0x04_BuddyAddBuddies
-func (_e *mockBuddyService_Expecter) AddBuddies(ctx interface{}, instance interface{}, inBody interface{}) *mockBuddyService_AddBuddies_Call {
-	return &mockBuddyService_AddBuddies_Call{Call: _e.mock.On("AddBuddies", ctx, instance, inBody)}
+func (_e *mockBuddyService_Expecter) AddBuddies(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockBuddyService_AddBuddies_Call {
+	return &mockBuddyService_AddBuddies_Call{Call: _e.mock.On("AddBuddies", ctx, instance, inFrame, inBody)}
 }
 
-func (_c *mockBuddyService_AddBuddies_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies)) *mockBuddyService_AddBuddies_Call {
+func (_c *mockBuddyService_AddBuddies_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies)) *mockBuddyService_AddBuddies_Call {
 	_c.Call.Run(func(args mock.Arguments) {
 		var arg0 context.Context
 		if args[0] != nil {
@@ -79,25 +91,30 @@ func (_c *mockBuddyService_AddBuddies_Call) Run(run func(ctx context.Context, in
 		if args[1] != nil {
 			arg1 = args[1].(*state.SessionInstance)
 		}
-		var arg2 wire.SNAC_0x03_0x04_BuddyAddBuddies
+		var arg2 wire.SNACFrame
 		if args[2] != nil {
-			arg2 = args[2].(wire.SNAC_0x03_0x04_BuddyAddBuddies)
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x03_0x04_BuddyAddBuddies
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x03_0x04_BuddyAddBuddies)
 		}
 		run(
 			arg0,
 			arg1,
 			arg2,
+			arg3,
 		)
 	})
 	return _c
 }
 
-func (_c *mockBuddyService_AddBuddies_Call) Return(err error) *mockBuddyService_AddBuddies_Call {
-	_c.Call.Return(err)
+func (_c *mockBuddyService_AddBuddies_Call) Return(sNACMessage *wire.SNACMessage, err error) *mockBuddyService_AddBuddies_Call {
+	_c.Call.Return(sNACMessage, err)
 	return _c
 }
 
-func (_c *mockBuddyService_AddBuddies_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) error) *mockBuddyService_AddBuddies_Call {
+func (_c *mockBuddyService_AddBuddies_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) (*wire.SNACMessage, error)) *mockBuddyService_AddBuddies_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 7 - 7
server/toc/mock_feedbag_service_test.go

@@ -322,7 +322,7 @@ func (_c *mockFeedbagService_QueryIfModified_Call) RunAndReturn(run func(ctx con
 }
 
 // RespondAuthorizeToHost provides a mock function for the type mockFeedbagService
-func (_mock *mockFeedbagService) RespondAuthorizeToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error {
+func (_mock *mockFeedbagService) RespondAuthorizeToHost(ctx context.Context, instance state.IdentScreenName, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error {
 	ret := _mock.Called(ctx, instance, inFrame, inBody)
 
 	if len(ret) == 0 {
@@ -330,7 +330,7 @@ func (_mock *mockFeedbagService) RespondAuthorizeToHost(ctx context.Context, ins
 	}
 
 	var r0 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error); ok {
+	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, wire.SNACFrame, wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error); ok {
 		r0 = returnFunc(ctx, instance, inFrame, inBody)
 	} else {
 		r0 = ret.Error(0)
@@ -345,22 +345,22 @@ type mockFeedbagService_RespondAuthorizeToHost_Call struct {
 
 // RespondAuthorizeToHost is a helper method to define mock.On call
 //   - ctx context.Context
-//   - instance *state.SessionInstance
+//   - instance state.IdentScreenName
 //   - inFrame wire.SNACFrame
 //   - inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost
 func (_e *mockFeedbagService_Expecter) RespondAuthorizeToHost(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagService_RespondAuthorizeToHost_Call {
 	return &mockFeedbagService_RespondAuthorizeToHost_Call{Call: _e.mock.On("RespondAuthorizeToHost", ctx, instance, inFrame, inBody)}
 }
 
-func (_c *mockFeedbagService_RespondAuthorizeToHost_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost)) *mockFeedbagService_RespondAuthorizeToHost_Call {
+func (_c *mockFeedbagService_RespondAuthorizeToHost_Call) Run(run func(ctx context.Context, instance state.IdentScreenName, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost)) *mockFeedbagService_RespondAuthorizeToHost_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
+		var arg1 state.IdentScreenName
 		if args[1] != nil {
-			arg1 = args[1].(*state.SessionInstance)
+			arg1 = args[1].(state.IdentScreenName)
 		}
 		var arg2 wire.SNACFrame
 		if args[2] != nil {
@@ -385,7 +385,7 @@ func (_c *mockFeedbagService_RespondAuthorizeToHost_Call) Return(err error) *moc
 	return _c
 }
 
-func (_c *mockFeedbagService_RespondAuthorizeToHost_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error) *mockFeedbagService_RespondAuthorizeToHost_Call {
+func (_c *mockFeedbagService_RespondAuthorizeToHost_Call) RunAndReturn(run func(ctx context.Context, instance state.IdentScreenName, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error) *mockFeedbagService_RespondAuthorizeToHost_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 2 - 2
server/toc/types.go

@@ -12,7 +12,7 @@ import (
 )
 
 type BuddyService interface {
-	AddBuddies(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) error
+	AddBuddies(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) (*wire.SNACMessage, error)
 	BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error
 	BroadcastBuddyDeparted(ctx context.Context, screenName state.IdentScreenName) error
 	DelBuddies(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x05_BuddyDelBuddies) error
@@ -103,7 +103,7 @@ type FeedbagService interface {
 	DeleteItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (*wire.SNACMessage, error)
 	Query(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error)
 	QueryIfModified(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error)
-	RespondAuthorizeToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error
+	RespondAuthorizeToHost(ctx context.Context, instance state.IdentScreenName, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error
 	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
 	StartCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster)
 	EndCluster(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame)

+ 1 - 1
server/webapi/types.go

@@ -12,7 +12,7 @@ import (
 )
 
 type BuddyService interface {
-	AddBuddies(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) error
+	AddBuddies(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) (*wire.SNACMessage, error)
 	BroadcastBuddyDeparted(ctx context.Context, screenName state.IdentScreenName) error
 	DelBuddies(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x03_0x05_BuddyDelBuddies) error
 	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage

+ 2 - 0
state/migrations/0030_contact_preauth.down.sql

@@ -0,0 +1,2 @@
+DROP INDEX IF EXISTS idx_contactPreauth_authorizedScreenName;
+DROP TABLE IF EXISTS contactPreauth;

+ 13 - 0
state/migrations/0030_contact_preauth.up.sql

@@ -0,0 +1,13 @@
+-- Pre-authorization grants: owner may allow requester to add owner as a buddy
+-- when owner requires authorization (ICQ first; table is protocol-agnostic).
+CREATE TABLE contactPreauth
+(
+    ownerScreenName        VARCHAR(16) NOT NULL,
+    authorizedScreenName   VARCHAR(16) NOT NULL,
+    createdAt              INTEGER     NOT NULL,
+    PRIMARY KEY (ownerScreenName, authorizedScreenName),
+    FOREIGN KEY (ownerScreenName) REFERENCES users (identScreenName) ON DELETE CASCADE ON UPDATE CASCADE,
+    FOREIGN KEY (authorizedScreenName) REFERENCES users (identScreenName) ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+CREATE INDEX idx_contactPreauth_authorizedScreenName ON contactPreauth (authorizedScreenName);

+ 11 - 5
state/session_manager.go

@@ -148,10 +148,17 @@ func (s *InMemorySessionManager) RelayToOtherInstances(ctx context.Context, inst
 func (s *InMemorySessionManager) RelayToScreenNameActiveOnly(ctx context.Context, screenName IdentScreenName, msg wire.SNACMessage) {
 	sess := s.RetrieveSession(screenName)
 	if sess == nil {
-		s.logger.WarnContext(ctx, "RelayToScreenNameActiveOnly: session not found", "recipient", screenName, "food_group", msg.Frame.FoodGroup, "sub_group", msg.Frame.SubGroup)
+		s.logger.WarnContext(ctx, "RelayToScreenNameActiveOnly: session not found",
+			"recipient", screenName,
+			"food_group", wire.FoodGroupName(msg.Frame.FoodGroup),
+			"sub_group", wire.SubGroupName(msg.Frame.FoodGroup, msg.Frame.SubGroup))
 		return
 	}
-	s.logger.DebugContext(ctx, "RelayToScreenNameActiveOnly: found session, relaying", "recipient", screenName, "food_group", msg.Frame.FoodGroup, "sub_group", msg.Frame.SubGroup, "instances", len(sess.Instances()), "inactive", sess.Inactive())
+	s.logger.DebugContext(ctx, "RelayToScreenNameActiveOnly: found session, relaying", "recipient", screenName,
+		"food_group", wire.FoodGroupName(msg.Frame.FoodGroup),
+		"sub_group", wire.SubGroupName(msg.Frame.FoodGroup, msg.Frame.SubGroup),
+		"instances", len(sess.Instances()),
+		"inactive", sess.Inactive())
 	s.maybeRelayMessageActiveOnly(ctx, msg, sess)
 }
 
@@ -160,9 +167,8 @@ func (s *InMemorySessionManager) maybeRelayMessage(ctx context.Context, msg wire
 		if !instance.live() {
 			s.logger.DebugContext(ctx, "maybeRelayMessage: skipping non-live instance",
 				"recipient", sess.IdentScreenName(),
-				"food_group", msg.Frame.FoodGroup,
-				"sub_group", msg.Frame.SubGroup,
-			)
+				"food_group", wire.FoodGroupName(msg.Frame.FoodGroup),
+				"sub_group", wire.SubGroupName(msg.Frame.FoodGroup, msg.Frame.SubGroup))
 			continue
 		}
 		switch instance.RelayMessageToInstance(msg) {

+ 50 - 0
state/user_store.go

@@ -333,6 +333,56 @@ func (f SQLiteUserStore) User(ctx context.Context, screenName IdentScreenName) (
 	return &users[0], nil
 }
 
+// RequiresAuthorization reports whether adding owner as a contact by requester
+// is still blocked: owner requires authorization and requester does not have a
+// pre-authorization grant from owner.
+func (f SQLiteUserStore) RequiresAuthorization(ctx context.Context, owner, requester IdentScreenName) (bool, error) {
+	u, err := f.User(ctx, owner)
+	if err != nil {
+		return false, fmt.Errorf("RequiresAuthorization: %w", err)
+	}
+	if u == nil || !u.ICQPermissions.AuthRequired {
+		return false, nil
+	}
+	var one int
+	err = f.db.QueryRowContext(ctx,
+		`SELECT 1 FROM contactPreauth WHERE ownerScreenName = ? AND authorizedScreenName = ? LIMIT 1`,
+		owner.String(), requester.String(),
+	).Scan(&one)
+	if errors.Is(err, sql.ErrNoRows) {
+		return true, nil
+	}
+	if err != nil {
+		return false, fmt.Errorf("RequiresAuthorization: %w", err)
+	}
+	return false, nil
+}
+
+// RecordPreAuth records that owner has pre-authorized requester to add owner
+// without a further authorization prompt. Returns ErrNoUser if requester
+// does not exist.
+func (f SQLiteUserStore) RecordPreAuth(ctx context.Context, owner, requester IdentScreenName) error {
+	q := `
+		INSERT INTO contactPreauth (ownerScreenName, authorizedScreenName, createdAt)
+		SELECT ?, ?, UNIXEPOCH()
+		WHERE EXISTS (SELECT 1 FROM users WHERE identScreenName = ?)
+		ON CONFLICT (ownerScreenName, authorizedScreenName)
+			DO UPDATE SET createdAt = UNIXEPOCH()
+	`
+	result, err := f.db.ExecContext(ctx, q, owner.String(), requester.String(), requester.String())
+	if err != nil {
+		return fmt.Errorf("RecordPreAuth: %w", err)
+	}
+	n, err := result.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("RecordPreAuth: %w", err)
+	}
+	if n == 0 {
+		return ErrNoUser
+	}
+	return nil
+}
+
 // queryUsers retrieves a list of users from the database based on the
 // specified WHERE clause and query parameters. Returns a slice of User objects
 // or an error if the query fails.

+ 55 - 0
state/user_store_test.go

@@ -3724,3 +3724,58 @@ func TestSQLiteUserStore_DeleteBARTItem(t *testing.T) {
 		assert.ErrorIs(t, err, ErrBARTItemNotFound)
 	})
 }
+
+func TestSQLiteUserStore_ContactPreAuth(t *testing.T) {
+	ctx := context.Background()
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	f, err := NewSQLiteUserStore(testFile)
+	require.NoError(t, err)
+
+	owner := NewIdentScreenName("100001")
+	requester := NewIdentScreenName("100002")
+
+	require.NoError(t, f.InsertUser(ctx, User{
+		IdentScreenName:   owner,
+		DisplayScreenName: DisplayScreenName("100001"),
+		IsICQ:             true,
+		ICQPermissions:    ICQPermissions{AuthRequired: true},
+	}))
+	require.NoError(t, f.InsertUser(ctx, User{
+		IdentScreenName:   requester,
+		DisplayScreenName: DisplayScreenName("100002"),
+		IsICQ:             true,
+	}))
+
+	blocked, err := f.RequiresAuthorization(ctx, owner, requester)
+	require.NoError(t, err)
+	assert.True(t, blocked)
+
+	require.NoError(t, f.RecordPreAuth(ctx, owner, requester))
+	blocked, err = f.RequiresAuthorization(ctx, owner, requester)
+	require.NoError(t, err)
+	assert.False(t, blocked)
+
+	// Pre-auth rows persist; FeedbagUpsert and AddBuddy do not delete them.
+	buddyItem := wire.FeedbagItem{
+		GroupID:   0,
+		ItemID:    1,
+		ClassID:   wire.FeedbagClassIdBuddy,
+		Name:      owner.String(),
+		TLVLBlock: wire.TLVLBlock{},
+	}
+	require.NoError(t, f.FeedbagUpsert(ctx, requester, []wire.FeedbagItem{buddyItem}))
+	var count int
+	err = f.db.QueryRow(`SELECT COUNT(*) FROM contactPreauth WHERE ownerScreenName = ? AND authorizedScreenName = ?`,
+		owner.String(), requester.String()).Scan(&count)
+	require.NoError(t, err)
+	assert.Equal(t, 1, count)
+
+	require.NoError(t, f.AddBuddy(ctx, requester, owner))
+	err = f.db.QueryRow(`SELECT COUNT(*) FROM contactPreauth WHERE ownerScreenName = ? AND authorizedScreenName = ?`,
+		owner.String(), requester.String()).Scan(&count)
+	require.NoError(t, err)
+	assert.Equal(t, 1, count)
+}

+ 24 - 0
wire/snacs.go

@@ -653,6 +653,14 @@ type SNAC_0x03_0x04_BuddyAddBuddies struct {
 	}
 }
 
+// SNAC_0x03_0x0A_BuddyRejectNotification is SRV_NOTIFICATION_REJECTED (iserverd):
+// one or more length-prefixed buddy id strings for contacts not added.
+type SNAC_0x03_0x0A_BuddyRejectNotification struct {
+	Buddies []struct {
+		ScreenName string `oscar:"len_prefix=uint8"`
+	}
+}
+
 type SNAC_0x03_0x05_BuddyDelBuddies struct {
 	Buddies []struct {
 		ScreenName string `oscar:"len_prefix=uint8"`
@@ -1876,6 +1884,22 @@ type SNAC_0x13_0x1C_FeedbagBuddyAdded struct {
 	Nullterm   uint16
 }
 
+// SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy is the client request for
+// FEEDBAG__PRE_AUTHORIZE_BUDDY.
+type SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy struct {
+	ScreenName string `oscar:"len_prefix=uint8"`
+	Message    string `oscar:"len_prefix=uint16"`
+	Flags      uint16
+}
+
+// SNAC_0x13_0x15_FeedbagPreAuthorizedBuddy is the server notification
+// FEEDBAG__PRE_AUTHORIZED_BUDDY.
+type SNAC_0x13_0x15_FeedbagPreAuthorizedBuddy struct {
+	ScreenName string `oscar:"len_prefix=uint8"`
+	Message    string `oscar:"len_prefix=uint16"`
+	Flags      uint16
+}
+
 //
 // 0x15: ICQ
 //

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä