Просмотр исходного кода

implement auth flow btwn aim and icq

Mike 2 месяцев назад
Родитель
Сommit
5bf363b19f

+ 10 - 0
foodgroup/buddy.go

@@ -64,6 +64,16 @@ func (s BuddyService) AddBuddies(ctx context.Context, instance *state.SessionIns
 	for _, entry := range inBody.Buddies {
 		them := state.NewIdentScreenName(entry.ScreenName)
 		if them.UIN() != 0 {
+			if instance.IdentScreenName().UIN() == 0 {
+				// AIM may not add ICQ UIN buddies on the client-side buddy path; only the
+				// feedbag route supports ICQ interop for AIM with proper authorization.
+				// Otherwise an AIM session could subscribe to buddy presence ("snoop" on ICQ
+				// status) without completing the authorization flow.
+				rejected = append(rejected, struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{ScreenName: entry.ScreenName})
+				continue
+			}
 			blocked, err := s.contactPreAuthorizer.RequiresAuthorization(ctx, them, instance.IdentScreenName())
 			if err != nil {
 				return nil, err

+ 69 - 0
foodgroup/buddy_test.go

@@ -159,6 +159,75 @@ func TestBuddyService_AddBuddies(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:     "AIM session rejects ICQ UIN on client-side buddy path",
+			instance: newTestInstance("aimuser", sessOptSignonComplete),
+			bodyIn: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+				Buddies: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{ScreenName: "100001"},
+				},
+			},
+			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:     "AIM session rejects ICQ UIN but adds non-UIN buddy in same batch",
+			instance: newTestInstance("aimuser", sessOptSignonComplete),
+			bodyIn: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+				Buddies: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{ScreenName: "100001"},
+					{ScreenName: "buddyalice"},
+				},
+			},
+			mockParams: mockParams{
+				clientSideBuddyListManagerParams: clientSideBuddyListManagerParams{
+					addBuddyParams: addBuddyParams{
+						{
+							me:   state.NewIdentScreenName("aimuser"),
+							them: state.NewIdentScreenName("buddyalice"),
+						},
+					},
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							from:   state.NewIdentScreenName("aimuser"),
+							filter: []state.IdentScreenName{state.NewIdentScreenName("buddyalice")},
+						},
+					},
+				},
+			},
+			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),

+ 43 - 17
foodgroup/feedbag.go

@@ -224,7 +224,17 @@ func (s *FeedbagService) UpsertItem(ctx context.Context, instance *state.Session
 	for _, item := range items {
 		if item.ClassID == wire.FeedbagClassIdBuddy && !item.HasTag(wire.FeedbagAttributesPending) {
 			sn := state.NewIdentScreenName(item.Name)
-			if sn.UIN() != 0 {
+			switch {
+			case instance.UIN() != 0 && sn.UIN() == 0:
+				// sender: icq, recipient:aim
+				// Automatically authorize AIM users when adding to ICQ buddy
+				// list since AIM does not support the ICQ authorization flow.
+				if err := s.contactPreAuthorizer.RecordPreAuth(ctx, instance.IdentScreenName(), sn); err != nil {
+					return nil, fmt.Errorf("contactPreAuthorizer.RecordPreAuth: %w", err)
+				}
+			case instance.UIN() != 0 && sn.UIN() != 0:
+				// sender: icq, recipient:icq
+				// Perform ICQ authorization flow.
 				blocked, err := s.contactPreAuthorizer.RequiresAuthorization(ctx, sn, instance.IdentScreenName())
 				if err != nil {
 					return nil, fmt.Errorf("contactPreAuthorizer.RequiresAuthorization: %w", err)
@@ -233,6 +243,21 @@ func (s *FeedbagService) UpsertItem(ctx context.Context, instance *state.Session
 					authRequired[item.Name] = true
 					continue
 				}
+			case instance.UIN() == 0 && sn.UIN() != 0:
+				// sender:aim, recipient: icq
+				// Automatically request authorization on behalf of AIM user since
+				// AIM does not support the ICQ authorization flow. The ICQ user
+				// will appear offline until authorization is granted.
+				blocked, err := s.contactPreAuthorizer.RequiresAuthorization(ctx, sn, instance.IdentScreenName())
+				if err != nil {
+					return nil, fmt.Errorf("contactPreAuthorizer.RequiresAuthorization: %w", err)
+				}
+				if blocked {
+					item.Append(wire.NewTLVBE(wire.FeedbagAttributesPending, []byte{}))
+					if err := s.sendLegacyAuthReq(ctx, instance, sn, "", state.ICQBasicInfo{}, 1); err != nil {
+						return nil, fmt.Errorf("sendLegacyAuthReq: %w", err)
+					}
+				}
 			}
 		}
 		toUpsert = append(toUpsert, item)
@@ -510,13 +535,6 @@ func (s *FeedbagService) RequestAuthorizeToHost(ctx context.Context, instance *s
 		return nil
 	}
 
-	// send an offline authorization or request to legacy client
-	frame := wire.SNACFrame{
-		FoodGroup: wire.ICBM,
-		SubGroup:  wire.ICBMChannelMsgToHost,
-		RequestID: inFrame.RequestID,
-	}
-
 	// if authorized, the recipient can reciprocate without requesting authorization
 	authorized := 1
 	blocked, err := s.contactPreAuthorizer.RequiresAuthorization(ctx, instance.IdentScreenName(), recipient)
@@ -536,31 +554,39 @@ func (s *FeedbagService) RequestAuthorizeToHost(ctx context.Context, instance *s
 		s.logger.ErrorContext(ctx, "user not found", "screen_name", instance.IdentScreenName())
 	}
 
+	return s.sendLegacyAuthReq(ctx, instance, recipient, inBody.Reason, userInfo.ICQBasicInfo, authorized)
+}
+
+// sendLegacyAuthReq sends an offline authorization request to ICQ client
+func (s *FeedbagService) sendLegacyAuthReq(ctx context.Context, from *state.SessionInstance, to state.IdentScreenName, reason string, userInfo state.ICQBasicInfo, authorized int) error {
+	frame := wire.SNACFrame{
+		FoodGroup: wire.ICBM,
+		SubGroup:  wire.ICBMChannelMsgToHost,
+	}
 	snac := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 		ChannelID:  wire.ICBMChannelICQ,
-		ScreenName: inBody.ScreenName,
+		ScreenName: to.String(),
 		TLVRestBlock: wire.TLVRestBlock{
 			TLVList: wire.TLVList{
 				wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
-					UIN:         instance.UIN(),
+					UIN:         from.UIN(),
 					MessageType: wire.ICBMMsgTypeAuthReq,
 					Message: fmt.Sprintf("%s\xFE%s\xFE%s\xFE%s\xFE%d\xFE%s",
-						userInfo.ICQBasicInfo.Nickname,
-						userInfo.ICQBasicInfo.FirstName,
-						userInfo.ICQBasicInfo.LastName,
-						userInfo.ICQBasicInfo.EmailAddress,
+						userInfo.Nickname,
+						userInfo.FirstName,
+						userInfo.LastName,
+						userInfo.EmailAddress,
 						authorized,
-						utf8ToLatin1(inBody.Reason)),
+						utf8ToLatin1(reason)),
 				}),
 				wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
 			},
 		},
 	}
 
-	if _, err := s.icbmSender(ctx, instance, frame, snac); err != nil {
+	if _, err := s.icbmSender(ctx, from, frame, snac); err != nil {
 		return fmt.Errorf("icbmSender: %w", err)
 	}
-
 	return nil
 }
 

+ 219 - 14
foodgroup/feedbag_test.go

@@ -498,7 +498,7 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 		},
 		{
 			name:     "add 2 ICQ buddies one that requires authorization",
-			instance: newTestInstance("me"),
+			instance: newTestInstance("100001", sessOptUIN(100001)),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.Feedbag,
@@ -522,7 +522,7 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 				feedbagManagerParams: feedbagManagerParams{
 					feedbagUpsertParams: feedbagUpsertParams{
 						{
-							screenName: state.NewIdentScreenName("me"),
+							screenName: state.NewIdentScreenName("100001"),
 							items: []wire.FeedbagItem{
 								{
 									ClassID: wire.FeedbagClassIdBuddy,
@@ -543,7 +543,7 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastVisibilityParams: broadcastVisibilityParams{
 						{
-							from: state.NewIdentScreenName("me"),
+							from: state.NewIdentScreenName("100001"),
 							filter: []state.IdentScreenName{
 								state.NewIdentScreenName("123401"),
 							},
@@ -553,7 +553,7 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 				messageRelayerParams: messageRelayerParams{
 					relayToOtherInstancesParams: relayToOtherInstancesParams{
 						{
-							screenName: state.NewIdentScreenName("me"),
+							screenName: state.NewIdentScreenName("100001"),
 							message: wire.SNACMessage{
 								Frame: wire.SNACFrame{
 									FoodGroup: wire.Feedbag,
@@ -573,7 +573,7 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 					},
 					relayToSelfParams: relayToSelfParams{
 						{
-							screenName: state.NewIdentScreenName("me"),
+							screenName: state.NewIdentScreenName("100001"),
 							message: wire.SNACMessage{
 								Frame: wire.SNACFrame{
 									FoodGroup: wire.Feedbag,
@@ -589,8 +589,8 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 				},
 				contactPreAuthorizerParams: contactPreAuthorizerParams{
 					requiresAuthorizationParams: requiresAuthorizationParams{
-						{owner: state.NewIdentScreenName("123400"), requester: state.NewIdentScreenName("me"), result: true},
-						{owner: state.NewIdentScreenName("123401"), requester: state.NewIdentScreenName("me"), result: false},
+						{owner: state.NewIdentScreenName("123400"), requester: state.NewIdentScreenName("100001"), result: true},
+						{owner: state.NewIdentScreenName("123401"), requester: state.NewIdentScreenName("100001"), result: false},
 					},
 				},
 			},
@@ -602,7 +602,7 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
 						wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
-							UIN:         0,
+							UIN:         100001,
 							MessageType: wire.ICBMMsgTypeAdded,
 						}),
 						wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
@@ -2007,6 +2007,212 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:     "ICQ user adds AIM buddy: expect ICQ user to preauthorize AIM user",
+			instance: newTestInstance("100777", sessOptUIN(100777)),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagInsertItem,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x13_0x08_FeedbagInsertItem{
+					Items: []wire.FeedbagItem{
+						{
+							ClassID: wire.FeedbagClassIdBuddy,
+							Name:    "buddyaim",
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				contactPreAuthorizerParams: contactPreAuthorizerParams{
+					recordPreAuthParams: recordPreAuthParams{
+						{
+							owner: state.NewIdentScreenName("100777"),
+							buddy: state.NewIdentScreenName("buddyaim"),
+						},
+					},
+				},
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("100777"),
+							items: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "buddyaim",
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToOtherInstancesParams: relayToOtherInstancesParams{
+						{
+							screenName: state.NewIdentScreenName("100777"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagInsertItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+									Items: []wire.FeedbagItem{
+										{
+											ClassID: wire.FeedbagClassIdBuddy,
+											Name:    "buddyaim",
+										},
+									},
+								},
+							},
+						},
+					},
+					relayToSelfParams: relayToSelfParams{
+						{
+							screenName: state.NewIdentScreenName("100777"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagStatus,
+									RequestID: 1234,
+								},
+								Body: wire.SNAC_0x13_0x0E_FeedbagStatus{
+									Results: []uint16{0x0000},
+								},
+							},
+						},
+					},
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							from: state.NewIdentScreenName("100777"),
+							filter: []state.IdentScreenName{
+								state.NewIdentScreenName("buddyaim"),
+							},
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+		},
+		{
+			name:     "AIM user adds ICQ buddy pending auth: expect AIM user to send auth request to ICQ user",
+			instance: newTestInstance("myaim"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagInsertItem,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x13_0x08_FeedbagInsertItem{
+					Items: []wire.FeedbagItem{
+						{
+							ClassID: wire.FeedbagClassIdBuddy,
+							Name:    "990011",
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				contactPreAuthorizerParams: contactPreAuthorizerParams{
+					requiresAuthorizationParams: requiresAuthorizationParams{
+						{
+							owner:     state.NewIdentScreenName("990011"),
+							requester: state.NewIdentScreenName("myaim"),
+							result:    true,
+						},
+					},
+				},
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("myaim"),
+							items: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "990011",
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.FeedbagAttributesPending, []byte{}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToOtherInstancesParams: relayToOtherInstancesParams{
+						{
+							screenName: state.NewIdentScreenName("myaim"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagInsertItem,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+									Items: []wire.FeedbagItem{
+										{
+											ClassID: wire.FeedbagClassIdBuddy,
+											Name:    "990011",
+											TLVLBlock: wire.TLVLBlock{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(wire.FeedbagAttributesPending, []byte{}),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+					relayToSelfParams: relayToSelfParams{
+						{
+							screenName: state.NewIdentScreenName("myaim"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Feedbag,
+									SubGroup:  wire.FeedbagStatus,
+									RequestID: 1234,
+								},
+								Body: wire.SNAC_0x13_0x0E_FeedbagStatus{
+									Results: []uint16{0x0000},
+								},
+							},
+						},
+					},
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							from: state.NewIdentScreenName("myaim"),
+							filter: []state.IdentScreenName{
+								state.NewIdentScreenName("990011"),
+							},
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+			expectICBM:   true,
+			wantICBMBody: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				ChannelID:  wire.ICBMChannelICQ,
+				ScreenName: "990011",
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVLE(wire.ICBMTLVData, wire.ICBMCh4Message{
+							UIN:         0,
+							MessageType: wire.ICBMMsgTypeAuthReq,
+							Message:     "\xFE\xFE\xFE\xFE1\xFE",
+						}),
+						wire.NewTLVBE(wire.ICBMTLVStore, []byte{}),
+					},
+				},
+			},
+		},
 	}
 
 	for _, tc := range cases {
@@ -2058,6 +2264,9 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 					Return(params.err)
 			}
 			contactPreAuth := newMockContactPreAuthorizer(t)
+			for _, params := range tc.mockParams.recordPreAuthParams {
+				contactPreAuth.EXPECT().RecordPreAuth(matchContext(), params.owner, params.buddy).Return(params.err)
+			}
 			for _, params := range tc.mockParams.requiresAuthorizationParams {
 				contactPreAuth.EXPECT().RequiresAuthorization(matchContext(), params.owner, params.requester).Return(params.result, params.err)
 			}
@@ -2494,7 +2703,6 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.ICBM,
 					SubGroup:  wire.ICBMChannelMsgToHost,
-					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 					ChannelID:  wire.ICBMChannelICQ,
@@ -2560,7 +2768,6 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.ICBM,
 					SubGroup:  wire.ICBMChannelMsgToHost,
-					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 					ChannelID:  wire.ICBMChannelICQ,
@@ -2622,7 +2829,6 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.ICBM,
 					SubGroup:  wire.ICBMChannelMsgToHost,
-					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 					ChannelID:  wire.ICBMChannelICQ,
@@ -2684,7 +2890,6 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.ICBM,
 					SubGroup:  wire.ICBMChannelMsgToHost,
-					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
 					ChannelID:  wire.ICBMChannelICQ,
@@ -2918,7 +3123,7 @@ func TestFeedbagService_RespondAuthorizeToHost(t *testing.T) {
 									Name:    "100001",
 									TLVLBlock: wire.TLVLBlock{
 										TLVList: wire.TLVList{
-											wire.NewTLVBE(wire.FeedbagAttributesPending, uint16(0)),
+											wire.NewTLVBE(wire.FeedbagAttributesPending, []byte{}),
 										},
 									},
 								},
@@ -3037,7 +3242,7 @@ func TestFeedbagService_RespondAuthorizeToHost(t *testing.T) {
 									Name:    "100001",
 									TLVLBlock: wire.TLVLBlock{
 										TLVList: wire.TLVList{
-											wire.NewTLVBE(wire.FeedbagAttributesPending, uint16(0)),
+											wire.NewTLVBE(wire.FeedbagAttributesPending, []byte{}),
 										},
 									},
 								},

+ 14 - 0
foodgroup/icq.go

@@ -644,6 +644,20 @@ func (s *ICQService) SetWorkInfo(ctx context.Context, instance *state.SessionIns
 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 {
+		if errors.Is(err, state.ErrNoUser) {
+			msg := wire.ICQMessageReplyEnvelope{
+				Message: wire.ICQ_0x07DA_0x0104_DBQueryMetaReplyShortInfo{
+					ICQMetadata: wire.ICQMetadata{
+						UIN:     instance.UIN(),
+						ReqType: wire.ICQDBQueryMetaReply,
+						Seq:     seq,
+					},
+					ReqSubType: wire.ICQDBQueryMetaReplyShortInfo,
+					Success:    wire.ICQStatusCodeErr,
+				},
+			}
+			return s.reply(ctx, instance, msg)
+		}
 		return err
 	}
 

+ 2 - 0
state/migrations/0031_feedbag_auth_pending.down.sql

@@ -0,0 +1,2 @@
+ALTER TABLE feedbag
+	DROP COLUMN authPending;

+ 4 - 0
state/migrations/0031_feedbag_auth_pending.up.sql

@@ -0,0 +1,4 @@
+-- Denormalized buddy authorization-pending flag (TLV 0x0066 on buddy items).
+-- Maintained by FeedbagUpsert; no backfill of historical rows.
+ALTER TABLE feedbag
+	ADD COLUMN authPending BOOLEAN NOT NULL DEFAULT FALSE;

+ 1 - 1
state/relationship.go

@@ -56,7 +56,7 @@ WITH myScreenName AS (SELECT ?),
                               COALESCE(clientSide.isPermit OR feedbag.isPermit, FALSE) AS isPermit,
                               COALESCE(clientSide.isDeny OR feedbag.isDeny, FALSE) AS isDeny
                        FROM (SELECT feedbag.name                                         AS _screenName,
-                                    MAX(CASE WHEN feedbag.classId = 0 THEN 1 ELSE 0 END) AS isBuddy,
+                                    MAX(CASE WHEN feedbag.classId = 0 AND NOT feedbag.authPending THEN 1 ELSE 0 END) AS isBuddy,
                                     MAX(CASE WHEN feedbag.classId = 2 THEN 1 ELSE 0 END) AS isPermit,
                                     MAX(CASE WHEN feedbag.classId = 3 THEN 1 ELSE 0 END) AS isDeny
                              FROM feedbag

+ 41 - 3
state/relationship_test.go

@@ -3,6 +3,7 @@ package state
 import (
 	"context"
 	"os"
+	"slices"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -21,6 +22,9 @@ func TestSQLiteUserStore_AllRelationships(t *testing.T) {
 		permitList []IdentScreenName
 		// buddyList is the list of users on the deny list. only active when wire.FeedbagPDModeDenySome is set.
 		denyList []IdentScreenName
+		// buddyPendingAuth lists buddies (subset of buddyList) whose server-side feedbag buddy row
+		// is written with TLV FeedbagAttributesPending / authPending.
+		buddyPendingAuth []IdentScreenName
 	}
 
 	tests := []struct {
@@ -3131,6 +3135,35 @@ func TestSQLiteUserStore_AllRelationships(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:            "feedbag server-side: buddy with auth pending does not count on your list",
+			me:              NewIdentScreenName("me"),
+			clientSideLists: map[IdentScreenName]buddyList{},
+			serverSideLists: map[IdentScreenName]buddyList{
+				NewIdentScreenName("me"): {
+					privacyMode:      wire.FeedbagPDModePermitAll,
+					buddyList:        []IdentScreenName{NewIdentScreenName("them")},
+					buddyPendingAuth: []IdentScreenName{NewIdentScreenName("them")},
+					permitList:       []IdentScreenName{},
+					denyList:         []IdentScreenName{},
+				},
+				NewIdentScreenName("them"): {
+					privacyMode: wire.FeedbagPDModePermitAll,
+					buddyList:   []IdentScreenName{},
+					permitList:  []IdentScreenName{},
+					denyList:    []IdentScreenName{},
+				},
+			},
+			expect: []Relationship{
+				{
+					User:          NewIdentScreenName("them"),
+					BlocksYou:     false,
+					YouBlock:      false,
+					IsOnTheirList: false,
+					IsOnYourList:  false,
+				},
+			},
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -3162,15 +3195,20 @@ func TestSQLiteUserStore_AllRelationships(t *testing.T) {
 				}
 				itemID++
 				for _, buddy := range list.buddyList {
-					items = append(items, newFeedbagItem(wire.FeedbagClassIdBuddy, itemID, buddy.String()))
+					items = append(items, newFeedbagItem(
+						wire.FeedbagClassIdBuddy,
+						itemID,
+						buddy.String(),
+						slices.Contains(list.buddyPendingAuth, buddy),
+					))
 					itemID++
 				}
 				for _, buddy := range list.permitList {
-					items = append(items, newFeedbagItem(wire.FeedbagClassIDPermit, itemID, buddy.String()))
+					items = append(items, newFeedbagItem(wire.FeedbagClassIDPermit, itemID, buddy.String(), false))
 					itemID++
 				}
 				for _, buddy := range list.denyList {
-					items = append(items, newFeedbagItem(wire.FeedbagClassIDDeny, itemID, buddy.String()))
+					items = append(items, newFeedbagItem(wire.FeedbagClassIDDeny, itemID, buddy.String(), false))
 					itemID++
 				}
 				assert.NoError(t, feedbagStore.FeedbagUpsert(context.Background(), sn, items))

+ 8 - 4
state/user_store.go

@@ -769,13 +769,14 @@ func (f SQLiteUserStore) FeedbagDelete(ctx context.Context, screenName IdentScre
 
 func (f SQLiteUserStore) FeedbagUpsert(ctx context.Context, screenName IdentScreenName, items []wire.FeedbagItem) error {
 	q := `
-		INSERT INTO feedbag (screenName, groupID, itemID, classID, name, attributes, pdMode, lastModified)
-		VALUES (?, ?, ?, ?, ?, ?, ?, UNIXEPOCH())
+		INSERT INTO feedbag (screenName, groupID, itemID, classID, name, attributes, pdMode, authPending, lastModified)
+		VALUES (?, ?, ?, ?, ?, ?, ?, ?, UNIXEPOCH())
 		ON CONFLICT (screenName, groupID, itemID)
 			DO UPDATE SET classID      = excluded.classID,
 						  name         = excluded.name,
 						  attributes   = excluded.attributes,
-						  pdMode       = excluded.pdMode, 
+						  pdMode       = excluded.pdMode,
+						  authPending  = excluded.authPending,
 						  lastModified = UNIXEPOCH()
 	`
 
@@ -800,6 +801,8 @@ func (f SQLiteUserStore) FeedbagUpsert(ctx context.Context, screenName IdentScre
 				pdMode = uint8(wire.FeedbagPDModePermitAll)
 			}
 		}
+		authPending := item.ClassID == wire.FeedbagClassIdBuddy &&
+			item.HasTag(wire.FeedbagAttributesPending)
 		_, err := f.db.ExecContext(ctx,
 			q,
 			screenName.String(),
@@ -808,7 +811,8 @@ func (f SQLiteUserStore) FeedbagUpsert(ctx context.Context, screenName IdentScre
 			item.ClassID,
 			item.Name,
 			buf.Bytes(),
-			pdMode)
+			pdMode,
+			authPending)
 		if err != nil {
 			return err
 		}

+ 68 - 2
state/user_store_test.go

@@ -127,6 +127,68 @@ func TestSQLiteUserStore_FeedbagUpsert(t *testing.T) {
 		assert.NoError(t, err)
 		assert.Equal(t, wire.FeedbagPDMode(pdMode), wire.FeedbagPDModePermitAll)
 	})
+
+	t.Run("authPending true for buddy with FeedbagAttributesPending TLV", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+
+		f, err := NewSQLiteUserStore(testFile)
+		require.NoError(t, err)
+
+		me := NewIdentScreenName("me")
+		err = f.FeedbagUpsert(context.Background(), me, []wire.FeedbagItem{
+			{
+				GroupID: 0,
+				ItemID:  1,
+				ClassID: wire.FeedbagClassIdBuddy,
+				Name:    "buddy1",
+				TLVLBlock: wire.TLVLBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.FeedbagAttributesPending, uint16(0)),
+					},
+				},
+			},
+		})
+		require.NoError(t, err)
+
+		var authPending bool
+		err = f.db.QueryRow(
+			`SELECT authPending FROM feedbag WHERE screenName = ? AND itemID = 1`,
+			me.String(),
+		).Scan(&authPending)
+		require.NoError(t, err)
+		assert.True(t, authPending)
+	})
+
+	t.Run("authPending false for buddy without pending TLV", func(t *testing.T) {
+		defer func() {
+			assert.NoError(t, os.Remove(testFile))
+		}()
+
+		f, err := NewSQLiteUserStore(testFile)
+		require.NoError(t, err)
+
+		me := NewIdentScreenName("me")
+		err = f.FeedbagUpsert(context.Background(), me, []wire.FeedbagItem{
+			{
+				GroupID:   0,
+				ItemID:    1,
+				ClassID:   wire.FeedbagClassIdBuddy,
+				Name:      "buddy1",
+				TLVLBlock: wire.TLVLBlock{},
+			},
+		})
+		require.NoError(t, err)
+
+		var authPending bool
+		err = f.db.QueryRow(
+			`SELECT authPending FROM feedbag WHERE screenName = ? AND itemID = 1`,
+			me.String(),
+		).Scan(&authPending)
+		require.NoError(t, err)
+		assert.False(t, authPending)
+	})
 }
 
 func TestFeedbagDelete(t *testing.T) {
@@ -626,12 +688,16 @@ func TestSQLiteUserStore_DeleteUser_DeleteNonExistentUser(t *testing.T) {
 	assert.ErrorIs(t, ErrNoUser, err)
 }
 
-func newFeedbagItem(classID uint16, itemID uint16, name string) wire.FeedbagItem {
-	return wire.FeedbagItem{
+func newFeedbagItem(classID uint16, itemID uint16, name string, feedbagPending bool) wire.FeedbagItem {
+	item := wire.FeedbagItem{
 		ClassID: classID,
 		ItemID:  itemID,
 		Name:    name,
 	}
+	if feedbagPending && classID == wire.FeedbagClassIdBuddy {
+		item.Append(wire.NewTLVBE(wire.FeedbagAttributesPending, uint16(0)))
+	}
+	return item
 }
 
 func pdInfoItem(itemID uint16, pdMode wire.FeedbagPDMode) wire.FeedbagItem {