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

refactor linked accounts

Store linked account info in feedbag rather than generate it on
the fly. Ensure that users can't add linked account records via
feedbag update; they can only be added via mgmt api for security.

I think this implementation removes some possibilities for bugs in
the Query() and QueryIfModified() methods, which dynamically added
linked account records at query time. In addition, we can remove
the LinkedAccontManager type and just use existing Feedbag facilities.
Mike 4 недель назад
Родитель
Сommit
81fabc4e8e

+ 13 - 38
api.yml

@@ -145,7 +145,7 @@ paths:
                 suspended_status:
                 suspended_status:
                   type: string
                   type: string
                   nullable: true
                   nullable: true
-                  enum: [deleted, expired, suspended, suspended_age]
+                  enum: [ deleted, expired, suspended, suspended_age ]
                   description: The suspended status of the account
                   description: The suspended status of the account
                 is_bot:
                 is_bot:
                   type: boolean
                   type: boolean
@@ -813,7 +813,7 @@ paths:
     get:
     get:
       summary: List all Web API keys
       summary: List all Web API keys
       description: Retrieve a list of all Web API keys for the Web AIM API.
       description: Retrieve a list of all Web API keys for the Web AIM API.
-      tags: [Web API Management]
+      tags: [ Web API Management ]
       responses:
       responses:
         '200':
         '200':
           description: Successful response containing a list of API keys.
           description: Successful response containing a list of API keys.
@@ -833,7 +833,7 @@ paths:
     post:
     post:
       summary: Create a new Web API key
       summary: Create a new Web API key
       description: Create a new API key for Web AIM API authentication.
       description: Create a new API key for Web AIM API authentication.
-      tags: [Web API Management]
+      tags: [ Web API Management ]
       requestBody:
       requestBody:
         required: true
         required: true
         content:
         content:
@@ -852,7 +852,7 @@ paths:
                   items:
                   items:
                     type: string
                     type: string
                   description: List of allowed CORS origins. Empty list allows all origins (useful for mobile apps).
                   description: List of allowed CORS origins. Empty list allows all origins (useful for mobile apps).
-                  example: ["https://example.com", "https://app.example.com"]
+                  example: [ "https://example.com", "https://app.example.com" ]
                 rate_limit:
                 rate_limit:
                   type: integer
                   type: integer
                   description: Maximum requests per minute allowed for this key.
                   description: Maximum requests per minute allowed for this key.
@@ -863,7 +863,7 @@ paths:
                   items:
                   items:
                     type: string
                     type: string
                   description: List of capabilities/features enabled for this key. Empty list allows all capabilities.
                   description: List of capabilities/features enabled for this key. Empty list allows all capabilities.
-                  example: ["aim.session", "presence.get", "im.send"]
+                  example: [ "aim.session", "presence.get", "im.send" ]
       responses:
       responses:
         '201':
         '201':
           description: API key created successfully.
           description: API key created successfully.
@@ -901,7 +901,7 @@ paths:
     get:
     get:
       summary: Get a specific Web API key
       summary: Get a specific Web API key
       description: Retrieve details of a specific Web API key by its developer ID.
       description: Retrieve details of a specific Web API key by its developer ID.
-      tags: [Web API Management]
+      tags: [ Web API Management ]
       parameters:
       parameters:
         - name: id
         - name: id
           in: path
           in: path
@@ -933,7 +933,7 @@ paths:
     put:
     put:
       summary: Update a Web API key
       summary: Update a Web API key
       description: Update settings for an existing Web API key.
       description: Update settings for an existing Web API key.
-      tags: [Web API Management]
+      tags: [ Web API Management ]
       parameters:
       parameters:
         - name: id
         - name: id
           in: path
           in: path
@@ -991,7 +991,7 @@ paths:
     delete:
     delete:
       summary: Delete a Web API key
       summary: Delete a Web API key
       description: Permanently delete a Web API key.
       description: Permanently delete a Web API key.
-      tags: [Web API Management]
+      tags: [ Web API Management ]
       parameters:
       parameters:
         - name: id
         - name: id
           in: path
           in: path
@@ -1503,13 +1503,13 @@ paths:
         '201':
         '201':
           description: Linked account created successfully.
           description: Linked account created successfully.
         '400':
         '400':
-          description: Invalid request body.
+          description: Invalid request body, or screen name cannot be linked to itself.
           content:
           content:
             application/json:
             application/json:
               schema:
               schema:
                 $ref: '#/components/schemas/MessageResponse'
                 $ref: '#/components/schemas/MessageResponse'
         '404':
         '404':
-          description: Primary user not found.
+          description: Primary user or linked user not found.
           content:
           content:
             application/json:
             application/json:
               schema:
               schema:
@@ -1526,31 +1526,6 @@ paths:
             application/json:
             application/json:
               schema:
               schema:
                 $ref: '#/components/schemas/MessageResponse'
                 $ref: '#/components/schemas/MessageResponse'
-    delete:
-      summary: Remove all linked accounts
-      description: Remove all linked account relationships for the specified primary user.
-      parameters:
-        - in: path
-          name: screenname
-          schema:
-            type: string
-          description: User's AIM screen name or ICQ UIN.
-          required: true
-      responses:
-        '204':
-          description: All linked accounts removed successfully.
-        '404':
-          description: User not found.
-          content:
-            application/json:
-              schema:
-                $ref: '#/components/schemas/MessageResponse'
-        '500':
-          description: Internal server error.
-          content:
-            application/json:
-              schema:
-                $ref: '#/components/schemas/MessageResponse'
 
 
   /user/{screenname}/linked-account/{linked_screenname}:
   /user/{screenname}/linked-account/{linked_screenname}:
     delete:
     delete:
@@ -1599,7 +1574,7 @@ components:
 
 
     BARTType:
     BARTType:
       type: integer
       type: integer
-      enum: [0, 1, 2, 3, 4, 5, 6, 12, 13, 15, 96, 129, 131, 136, 137, 1024, 1026, 1027, 1028]
+      enum: [ 0, 1, 2, 3, 4, 5, 6, 12, 13, 15, 96, 129, 131, 136, 137, 1024, 1026, 1027, 1028 ]
       description: |
       description: |
         BART (Buddy ART) type identifier. Valid type IDs and their corresponding names:
         BART (Buddy ART) type identifier. Valid type IDs and their corresponding names:
         - 0: buddy_icon_small (GIF/JPG/BMP, <= 32 pixels and 2k)
         - 0: buddy_icon_small (GIF/JPG/BMP, <= 32 pixels and 2k)
@@ -1655,10 +1630,10 @@ components:
           items:
           items:
             type: string
             type: string
           description: List of allowed CORS origins. Empty list allows all origins.
           description: List of allowed CORS origins. Empty list allows all origins.
-          example: ["https://example.com"]
+          example: [ "https://example.com" ]
         capabilities:
         capabilities:
           type: array
           type: array
           items:
           items:
             type: string
             type: string
           description: List of enabled features/endpoints. Empty list allows all capabilities.
           description: List of enabled features/endpoints. Empty list allows all capabilities.
-          example: ["aim.session", "presence.get"]
+          example: [ "aim.session", "presence.get" ]

+ 0 - 2
cmd/server/factory.go

@@ -93,7 +93,6 @@ func MakeCommonDeps() (Container, error) {
 		c.sqLiteUserStore,
 		c.sqLiteUserStore,
 		c.sqLiteUserStore,
 		c.sqLiteUserStore,
 		c.sqLiteUserStore,
 		c.sqLiteUserStore,
-		c.sqLiteUserStore,
 	)
 	)
 
 
 	c.icbmSvc = foodgroup.NewICBMService(
 	c.icbmSvc = foodgroup.NewICBMService(
@@ -417,7 +416,6 @@ func MgmtAPI(deps Container) *http.Server {
 		deps.sqLiteUserStore,        // profileRetriever
 		deps.sqLiteUserStore,        // profileRetriever
 		deps.sqLiteUserStore,        // webAPIKeyManager
 		deps.sqLiteUserStore,        // webAPIKeyManager
 		deps.sqLiteUserStore,        // icqProfileManager
 		deps.sqLiteUserStore,        // icqProfileManager
-		deps.sqLiteUserStore,        // linkedAccountManager
 		state.NewAccountCreator(deps.sqLiteUserStore.InsertUser),
 		state.NewAccountCreator(deps.sqLiteUserStore.InsertUser),
 		logger,
 		logger,
 	)
 	)

+ 9 - 9
foodgroup/auth.go

@@ -33,7 +33,7 @@ func NewAuthService(
 	chatMessageRelayer ChatMessageRelayer,
 	chatMessageRelayer ChatMessageRelayer,
 	accountManager AccountManager,
 	accountManager AccountManager,
 	bartItemManager BARTItemManager,
 	bartItemManager BARTItemManager,
-	linkedAccountManager LinkedAccountManager,
+	feedbagManager FeedbagManager,
 	classes wire.RateLimitClasses,
 	classes wire.RateLimitClasses,
 	createAccount state.CreateAccountFunc,
 	createAccount state.CreateAccountFunc,
 	logger *slog.Logger,
 	logger *slog.Logger,
@@ -48,7 +48,7 @@ func NewAuthService(
 		chatMessageRelayer:         chatMessageRelayer,
 		chatMessageRelayer:         chatMessageRelayer,
 		accountManager:             accountManager,
 		accountManager:             accountManager,
 		bartItemManager:            bartItemManager,
 		bartItemManager:            bartItemManager,
-		linkedAccountManager:       linkedAccountManager,
+		feedbagManager:             feedbagManager,
 		rateLimitClasses:           classes,
 		rateLimitClasses:           classes,
 		timeNow:                    time.Now,
 		timeNow:                    time.Now,
 		maxConcurrentLoginsPerUser: MaxConcurrentLoginsPerUser,
 		maxConcurrentLoginsPerUser: MaxConcurrentLoginsPerUser,
@@ -71,7 +71,7 @@ type AuthService struct {
 	userManager                UserManager
 	userManager                UserManager
 	accountManager             AccountManager
 	accountManager             AccountManager
 	bartItemManager            BARTItemManager
 	bartItemManager            BARTItemManager
-	linkedAccountManager       LinkedAccountManager
+	feedbagManager             FeedbagManager
 	rateLimitClasses           wire.RateLimitClasses
 	rateLimitClasses           wire.RateLimitClasses
 	timeNow                    func() time.Time
 	timeNow                    func() time.Time
 	maxConcurrentLoginsPerUser int
 	maxConcurrentLoginsPerUser int
@@ -668,22 +668,22 @@ func (s AuthService) loginSuccessResponse(ctx context.Context, props loginProper
 // addLinkedAccountsTLV builds the linked accounts XML and appends the
 // addLinkedAccountsTLV builds the linked accounts XML and appends the
 // corresponding TLV to tlvs. If linkedNames is empty, tlvs is not modified.
 // corresponding TLV to tlvs. If linkedNames is empty, tlvs is not modified.
 func (s AuthService) addLinkedAccountsTLV(ctx context.Context, screenName state.DisplayScreenName, tlvs *wire.TLVList) error {
 func (s AuthService) addLinkedAccountsTLV(ctx context.Context, screenName state.DisplayScreenName, tlvs *wire.TLVList) error {
-	accounts, err := s.linkedAccountManager.LinkedAccounts(ctx, screenName.IdentScreenName())
+	items, err := s.feedbagManager.Feedbag(ctx, screenName.IdentScreenName())
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("failed to get linked accounts: %w", err)
 		return fmt.Errorf("failed to get linked accounts: %w", err)
 	}
 	}
 
 
-	if len(accounts) == 0 {
+	linkedNames := state.NewFeedbagList(items, nil).LinkedScreenNames()
+
+	if len(linkedNames) == 0 {
 		return nil
 		return nil
 	}
 	}
 
 
-	linkedNames := append([]state.IdentScreenName{}, accounts...)
-
-	xml, err := buildLinkedAccountsXML(screenName.IdentScreenName(), linkedNames)
+	acctXML, err := buildLinkedAccountsXML(screenName.IdentScreenName(), linkedNames)
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("failed to build linked accounts xml: %w", err)
 		return fmt.Errorf("failed to build linked accounts xml: %w", err)
 	}
 	}
-	*tlvs = append(*tlvs, wire.NewTLVBE(wire.OServiceTLVTagsLinkedAccounts, xml))
+	*tlvs = append(*tlvs, wire.NewTLVBE(wire.OServiceTLVTagsLinkedAccounts, acctXML))
 	return nil
 	return nil
 }
 }
 
 

+ 50 - 50
foodgroup/auth_test.go

@@ -755,11 +755,11 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 						},
 						},
 					},
 					},
 				},
 				},
-				linkedAccountManagerParams: linkedAccountManagerParams{
-					linkedAccountsParams: linkedAccountsParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
 						{
 						{
 							screenName: user.IdentScreenName,
 							screenName: user.IdentScreenName,
-							result:     []state.IdentScreenName{state.NewIdentScreenName("linked1")},
+							results:    []wire.FeedbagItem{{ClassID: wire.FeedbagClassIdAlInfo, Name: "linked1"}},
 						},
 						},
 					},
 					},
 				},
 				},
@@ -783,7 +783,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 			},
 		},
 		},
 		{
 		{
-			name:           "linked account manager error during login, returns error",
+			name:           "feedbag error during login, returns error",
 			advertisedHost: "127.0.0.1:5190",
 			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 				TLVRestBlock: wire.TLVRestBlock{
@@ -817,8 +817,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 						},
 						},
 					},
 					},
 				},
 				},
-				linkedAccountManagerParams: linkedAccountManagerParams{
-					linkedAccountsParams: linkedAccountsParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
 						{
 						{
 							screenName: user.IdentScreenName,
 							screenName: user.IdentScreenName,
 							err:        io.EOF,
 							err:        io.EOF,
@@ -888,20 +888,20 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 					Return(params.result)
 					Return(params.result)
 			}
 			}
 
 
-			linkedAccountManager := newMockLinkedAccountManager(t)
-			for _, params := range tc.mockParams.linkedAccountsParams {
-				linkedAccountManager.EXPECT().
-					LinkedAccounts(matchContext(), params.screenName).
-					Return(params.result, params.err)
+			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tc.mockParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(matchContext(), params.screenName).
+					Return(params.results, params.err)
 			}
 			}
-			linkedAccountManager.EXPECT().LinkedAccounts(matchContext(), mock.Anything).Return(nil, nil).Maybe()
+			feedbagManager.EXPECT().Feedbag(matchContext(), mock.Anything).Return(nil, nil).Maybe()
 
 
 			svc := AuthService{
 			svc := AuthService{
 				config:                     tc.cfg,
 				config:                     tc.cfg,
 				cookieBaker:                cookieBaker,
 				cookieBaker:                cookieBaker,
 				userManager:                userManager,
 				userManager:                userManager,
 				sessionRetriever:           sessionRetriever,
 				sessionRetriever:           sessionRetriever,
-				linkedAccountManager:       linkedAccountManager,
+				feedbagManager:             feedbagManager,
 				maxConcurrentLoginsPerUser: 2,
 				maxConcurrentLoginsPerUser: 2,
 				createAccount:              tc.createAccount,
 				createAccount:              tc.createAccount,
 				logger:                     slog.Default(),
 				logger:                     slog.Default(),
@@ -1219,7 +1219,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			},
 			},
 		},
 		},
 		{
 		{
-			name:           "linked account manager error during login, returns error",
+			name:           "feedbag error during login, returns error",
 			advertisedHost: "127.0.0.1:5190",
 			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.FLAPSignonFrame{
 			inputSNAC: wire.FLAPSignonFrame{
 				TLVRestBlock: wire.TLVRestBlock{
 				TLVRestBlock: wire.TLVRestBlock{
@@ -1253,8 +1253,8 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 						},
 						},
 					},
 					},
 				},
 				},
-				linkedAccountManagerParams: linkedAccountManagerParams{
-					linkedAccountsParams: linkedAccountsParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
 						{
 						{
 							screenName: user.IdentScreenName,
 							screenName: user.IdentScreenName,
 							err:        io.EOF,
 							err:        io.EOF,
@@ -1378,20 +1378,20 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 					Issue(params.dataIn).
 					Issue(params.dataIn).
 					Return(params.cookieOut, params.err)
 					Return(params.cookieOut, params.err)
 			}
 			}
-			linkedAccountManager := newMockLinkedAccountManager(t)
-			for _, params := range tc.mockParams.linkedAccountsParams {
-				linkedAccountManager.EXPECT().
-					LinkedAccounts(matchContext(), params.screenName).
-					Return(params.result, params.err)
+			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tc.mockParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(matchContext(), params.screenName).
+					Return(params.results, params.err)
 			}
 			}
-			linkedAccountManager.EXPECT().LinkedAccounts(matchContext(), mock.Anything).Return(nil, nil).Maybe()
+			feedbagManager.EXPECT().Feedbag(matchContext(), mock.Anything).Return(nil, nil).Maybe()
 			svc := AuthService{
 			svc := AuthService{
-				config:               tc.cfg,
-				cookieBaker:          cookieBaker,
-				userManager:          userManager,
-				linkedAccountManager: linkedAccountManager,
-				createAccount:        tc.createAccount,
-				logger:               slog.Default(),
+				config:         tc.cfg,
+				cookieBaker:    cookieBaker,
+				userManager:    userManager,
+				feedbagManager: feedbagManager,
+				createAccount:  tc.createAccount,
+				logger:         slog.Default(),
 			}
 			}
 			outputSNAC, err := svc.FLAPLogin(context.Background(), tc.inputSNAC, tc.advertisedHost)
 			outputSNAC, err := svc.FLAPLogin(context.Background(), tc.inputSNAC, tc.advertisedHost)
 			assert.ErrorIs(t, err, tc.wantErr)
 			assert.ErrorIs(t, err, tc.wantErr)
@@ -1725,14 +1725,14 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 					RetrieveSession(params.screenName).
 					RetrieveSession(params.screenName).
 					Return(params.result)
 					Return(params.result)
 			}
 			}
-			linkedAccountManager := newMockLinkedAccountManager(t)
-			linkedAccountManager.EXPECT().LinkedAccounts(matchContext(), mock.Anything).Return(nil, nil).Maybe()
+			feedbagManager := newMockFeedbagManager(t)
+			feedbagManager.EXPECT().Feedbag(matchContext(), mock.Anything).Return(nil, nil).Maybe()
 			svc := AuthService{
 			svc := AuthService{
 				config:                     tc.cfg,
 				config:                     tc.cfg,
 				cookieBaker:                cookieBaker,
 				cookieBaker:                cookieBaker,
 				userManager:                userManager,
 				userManager:                userManager,
 				sessionRetriever:           sessionRetriever,
 				sessionRetriever:           sessionRetriever,
-				linkedAccountManager:       linkedAccountManager,
+				feedbagManager:             feedbagManager,
 				timeNow:                    tc.timeNow,
 				timeNow:                    tc.timeNow,
 				maxConcurrentLoginsPerUser: 2,
 				maxConcurrentLoginsPerUser: 2,
 				createAccount:              tc.createAccount,
 				createAccount:              tc.createAccount,
@@ -2412,52 +2412,52 @@ func TestAuthService_addLinkedAccountsTLV(t *testing.T) {
 	cases := []struct {
 	cases := []struct {
 		name       string
 		name       string
 		screenName state.DisplayScreenName
 		screenName state.DisplayScreenName
-		// mockLinkedAccounts is what linkedAccountManager.LinkedAccounts returns
-		mockLinkedAccounts []state.IdentScreenName
-		mockErr            error
+		// feedbagItems is what feedbagManager.Feedbag returns
+		feedbagItems []wire.FeedbagItem
+		feedbagErr   error
 		// wantTLVCount is the expected number of TLVs after the call
 		// wantTLVCount is the expected number of TLVs after the call
 		wantTLVCount int
 		wantTLVCount int
 		wantErr      bool
 		wantErr      bool
 	}{
 	}{
 		{
 		{
-			name:               "no linked accounts, TLV list unchanged",
-			screenName:         "PrimaryUser",
-			mockLinkedAccounts: nil,
-			wantTLVCount:       0,
+			name:         "no linked accounts, TLV list unchanged",
+			screenName:   "PrimaryUser",
+			feedbagItems: nil,
+			wantTLVCount: 0,
 		},
 		},
 		{
 		{
 			name:       "one linked account, TLV appended",
 			name:       "one linked account, TLV appended",
 			screenName: "PrimaryUser",
 			screenName: "PrimaryUser",
-			mockLinkedAccounts: []state.IdentScreenName{
-				state.NewIdentScreenName("LinkedUser1"),
+			feedbagItems: []wire.FeedbagItem{
+				{ClassID: wire.FeedbagClassIdAlInfo, Name: "linkeduser1"},
 			},
 			},
 			wantTLVCount: 1,
 			wantTLVCount: 1,
 		},
 		},
 		{
 		{
 			name:       "multiple linked accounts, single TLV appended",
 			name:       "multiple linked accounts, single TLV appended",
 			screenName: "PrimaryUser",
 			screenName: "PrimaryUser",
-			mockLinkedAccounts: []state.IdentScreenName{
-				state.NewIdentScreenName("LinkedUser1"),
-				state.NewIdentScreenName("LinkedUser2"),
+			feedbagItems: []wire.FeedbagItem{
+				{ClassID: wire.FeedbagClassIdAlInfo, Name: "linkeduser1"},
+				{ClassID: wire.FeedbagClassIdAlInfo, Name: "linkeduser2"},
 			},
 			},
 			wantTLVCount: 1,
 			wantTLVCount: 1,
 		},
 		},
 		{
 		{
-			name:       "linkedAccountManager returns error, error propagated",
+			name:       "feedbagManager returns error, error propagated",
 			screenName: "PrimaryUser",
 			screenName: "PrimaryUser",
-			mockErr:    io.EOF,
+			feedbagErr: io.EOF,
 			wantErr:    true,
 			wantErr:    true,
 		},
 		},
 	}
 	}
 
 
 	for _, tc := range cases {
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
-			linkedAccountManager := newMockLinkedAccountManager(t)
-			linkedAccountManager.EXPECT().
-				LinkedAccounts(matchContext(), state.NewIdentScreenName(string(tc.screenName))).
-				Return(tc.mockLinkedAccounts, tc.mockErr)
+			feedbagManager := newMockFeedbagManager(t)
+			feedbagManager.EXPECT().
+				Feedbag(matchContext(), state.NewIdentScreenName(string(tc.screenName))).
+				Return(tc.feedbagItems, tc.feedbagErr)
 
 
-			svc := AuthService{linkedAccountManager: linkedAccountManager}
+			svc := AuthService{feedbagManager: feedbagManager}
 			tlvs := wire.TLVList{}
 			tlvs := wire.TLVList{}
 			err := svc.addLinkedAccountsTLV(context.Background(), tc.screenName, &tlvs)
 			err := svc.addLinkedAccountsTLV(context.Background(), tc.screenName, &tlvs)
 
 

+ 24 - 58
foodgroup/feedbag.go

@@ -25,7 +25,6 @@ func NewFeedbagService(
 	sessionRetriever SessionRetriever,
 	sessionRetriever SessionRetriever,
 	contactPreAuthorizer ContactPreAuthorizer,
 	contactPreAuthorizer ContactPreAuthorizer,
 	userManager UserManager,
 	userManager UserManager,
-	linkedAccountManager LinkedAccountManager,
 	buddyAddedNotifierDeduper BuddyAddedNotifierDeduper,
 	buddyAddedNotifierDeduper BuddyAddedNotifierDeduper,
 ) *FeedbagService {
 ) *FeedbagService {
 	return &FeedbagService{
 	return &FeedbagService{
@@ -33,7 +32,6 @@ func NewFeedbagService(
 		buddyBroadcaster:          newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		buddyBroadcaster:          newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		buddyAddedNotifierDeduper: buddyAddedNotifierDeduper,
 		buddyAddedNotifierDeduper: buddyAddedNotifierDeduper,
 		feedbagManager:            feedbagManager,
 		feedbagManager:            feedbagManager,
-		linkedAccountManager:      linkedAccountManager,
 		logger:                    logger,
 		logger:                    logger,
 		messageRelayer:            messageRelayer,
 		messageRelayer:            messageRelayer,
 		relationshipFetcher:       relationshipFetcher,
 		relationshipFetcher:       relationshipFetcher,
@@ -53,7 +51,6 @@ type FeedbagService struct {
 	buddyBroadcaster          buddyBroadcaster
 	buddyBroadcaster          buddyBroadcaster
 	buddyAddedNotifierDeduper BuddyAddedNotifierDeduper
 	buddyAddedNotifierDeduper BuddyAddedNotifierDeduper
 	feedbagManager            FeedbagManager
 	feedbagManager            FeedbagManager
-	linkedAccountManager      LinkedAccountManager
 	logger                    *slog.Logger
 	logger                    *slog.Logger
 	messageRelayer            MessageRelayer
 	messageRelayer            MessageRelayer
 	relationshipFetcher       RelationshipFetcher
 	relationshipFetcher       RelationshipFetcher
@@ -122,34 +119,11 @@ func (s *FeedbagService) RightsQuery(_ context.Context, inFrame wire.SNACFrame)
 // Query fetches the user's feedbag (aka buddy list). It returns
 // Query fetches the user's feedbag (aka buddy list). It returns
 // wire.FeedbagReply, which contains feedbag entries.
 // 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) {
-	rawFb, err := s.feedbagManager.Feedbag(ctx, instance.IdentScreenName())
+	fb, err := s.feedbagManager.Feedbag(ctx, instance.IdentScreenName())
 	if err != nil {
 	if err != nil {
 		return wire.SNACMessage{}, err
 		return wire.SNACMessage{}, err
 	}
 	}
 
 
-	// Strip any stored ClassIdAlInfo items — they are generated dynamically below.
-	fb := make([]wire.FeedbagItem, 0, len(rawFb))
-	for _, item := range rawFb {
-		if item.ClassID != wire.FeedbagClassIdAlInfo {
-			fb = append(fb, item)
-		}
-	}
-
-	// Dynamically build the FeedbagItem for each linked account. Use a placeholder string for
-	// the roasted password, as clients don't seem to need it for anything.
-	linked, err := s.linkedAccountManager.LinkedAccounts(ctx, instance.IdentScreenName())
-	if err == nil {
-		for i, acc := range linked {
-			fb = append(fb, wire.FeedbagItem{
-				Name:      acc.String(),
-				ClassID:   wire.FeedbagClassIdAlInfo,
-				ItemID:    uint16(0x8000 + i), // Use a reserved high range
-				GroupID:   0,
-				TLVLBlock: wire.TLVLBlock{},
-			})
-		}
-	}
-
 	lm := time.UnixMilli(0)
 	lm := time.UnixMilli(0)
 
 
 	if len(fb) > 0 {
 	if len(fb) > 0 {
@@ -178,43 +152,19 @@ func (s *FeedbagService) Query(ctx context.Context, instance *state.SessionInsta
 // inBody.LastUpdate, else return wire.FeedbagReply, which contains feedbag
 // inBody.LastUpdate, else return wire.FeedbagReply, which contains feedbag
 // entries.
 // 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) {
-	rawFb, err := s.feedbagManager.Feedbag(ctx, instance.IdentScreenName())
+	fb, err := s.feedbagManager.Feedbag(ctx, instance.IdentScreenName())
 	if err != nil {
 	if err != nil {
 		return wire.SNACMessage{}, err
 		return wire.SNACMessage{}, err
 	}
 	}
 
 
-	fb := make([]wire.FeedbagItem, 0, len(rawFb))
-	for _, item := range rawFb {
-		if item.ClassID != wire.FeedbagClassIdAlInfo {
-			fb = append(fb, item)
-		}
-	}
-
-	hasLinkedAccounts := false
-	linked, linkedErr := s.linkedAccountManager.LinkedAccounts(ctx, instance.IdentScreenName())
-	if linkedErr == nil {
-		for i, acc := range linked {
-			hasLinkedAccounts = true
-			fb = append(fb, wire.FeedbagItem{
-				Name:      acc.String(),
-				ClassID:   wire.FeedbagClassIdAlInfo,
-				ItemID:    uint16(0x8000 + i),
-				GroupID:   0,
-				TLVLBlock: wire.TLVLBlock{},
-			})
-		}
-	}
-
 	lm := time.UnixMilli(0)
 	lm := time.UnixMilli(0)
 
 
-	if len(rawFb) > 0 {
+	if len(fb) > 0 {
 		lm, err = s.feedbagManager.FeedbagLastModified(ctx, instance.IdentScreenName())
 		lm, err = s.feedbagManager.FeedbagLastModified(ctx, instance.IdentScreenName())
 		if err != nil {
 		if err != nil {
 			return wire.SNACMessage{}, err
 			return wire.SNACMessage{}, err
 		}
 		}
-		// Skip the not-modified response when linked accounts are present — AlInfo
-		// items are generated dynamically and never cached by the client.
-		if !hasLinkedAccounts && lm.Before(time.Unix(int64(inBody.LastUpdate), 0)) {
+		if lm.Before(time.Unix(int64(inBody.LastUpdate), 0)) {
 			return wire.SNACMessage{
 			return wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.Feedbag,
 					FoodGroup: wire.Feedbag,
@@ -253,9 +203,25 @@ func (s *FeedbagService) QueryIfModified(ctx context.Context, instance *state.Se
 // feedbag. It returns wire.FeedbagStatus, which contains update confirmation.
 // 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 {
 	for _, item := range items {
-		// don't let users block themselves, it causes the AIM client to go
-		// into a weird state.
-		if item.ClassID == wire.FeedbagClassIDDeny && state.NewIdentScreenName(item.Name) == instance.IdentScreenName() {
+		switch item.ClassID {
+		case wire.FeedbagClassIDDeny:
+			// don't let users block themselves, it causes the AIM client to go
+			// into a weird state.
+			if state.NewIdentScreenName(item.Name) == instance.IdentScreenName() {
+				return &wire.SNACMessage{
+					Frame: wire.SNACFrame{
+						FoodGroup: wire.Feedbag,
+						SubGroup:  wire.FeedbagErr,
+						RequestID: inFrame.RequestID,
+					},
+					Body: wire.SNACError{
+						Code: wire.ErrorCodeNotSupportedByHost,
+					},
+				}, nil
+			}
+		case wire.FeedbagClassIdAlInfo:
+			// don't let users add arbitrary linked accounts. they can only be
+			// added via the management API.
 			return &wire.SNACMessage{
 			return &wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.Feedbag,
 					FoodGroup: wire.Feedbag,
@@ -263,7 +229,7 @@ func (s *FeedbagService) UpsertItem(ctx context.Context, instance *state.Session
 					RequestID: inFrame.RequestID,
 					RequestID: inFrame.RequestID,
 				},
 				},
 				Body: wire.SNACError{
 				Body: wire.SNACError{
-					Code: wire.ErrorCodeNotSupportedByHost,
+					Code: wire.ErrorCodeInsufficientRights,
 				},
 				},
 			}, nil
 			}, nil
 		}
 		}

+ 31 - 322
foodgroup/feedbag_test.go

@@ -4,7 +4,6 @@ import (
 	"bytes"
 	"bytes"
 	"context"
 	"context"
 	"fmt"
 	"fmt"
-	"io"
 	"log/slog"
 	"log/slog"
 	"testing"
 	"testing"
 	"time"
 	"time"
@@ -131,11 +130,8 @@ func TestFeedbagService_Query(t *testing.T) {
 					Return(params.result, nil)
 					Return(params.result, nil)
 			}
 			}
 
 
-			linkedAccountManager := newMockLinkedAccountManager(t)
-			linkedAccountManager.EXPECT().LinkedAccounts(matchContext(), mock.Anything).Return(nil, nil).Maybe()
 			svc := FeedbagService{
 			svc := FeedbagService{
-				feedbagManager:       feedbagManager,
-				linkedAccountManager: linkedAccountManager,
+				feedbagManager: feedbagManager,
 			}
 			}
 			outputSNAC, err := svc.Query(context.Background(), tc.instance, tc.inputSNAC.Frame)
 			outputSNAC, err := svc.Query(context.Background(), tc.instance, tc.inputSNAC.Frame)
 			assert.NoError(t, err)
 			assert.NoError(t, err)
@@ -144,107 +140,6 @@ func TestFeedbagService_Query(t *testing.T) {
 	}
 	}
 }
 }
 
 
-func TestFeedbagService_Query_LinkedAccounts(t *testing.T) {
-	inFrame := wire.SNACFrame{
-		FoodGroup: wire.Feedbag,
-		SubGroup:  wire.FeedbagQuery,
-		RequestID: 1234,
-	}
-	me := state.NewIdentScreenName("me")
-
-	cases := []struct {
-		name                string
-		feedbagItems        []wire.FeedbagItem
-		linkedAccounts      []state.IdentScreenName
-		linkedAccountsErr   error
-		feedbagLastModified time.Time
-		expectItems         []wire.FeedbagItem
-	}{
-		{
-			name:                "linked accounts appended to feedbag items",
-			feedbagItems:        []wire.FeedbagItem{},
-			linkedAccounts:      []state.IdentScreenName{state.NewIdentScreenName("linked1"), state.NewIdentScreenName("linked2")},
-			feedbagLastModified: time.UnixMilli(1696472198082),
-			expectItems: []wire.FeedbagItem{
-				{
-					Name:      "linked1",
-					ClassID:   wire.FeedbagClassIdAlInfo,
-					ItemID:    uint16(0x8000),
-					GroupID:   0,
-					TLVLBlock: wire.TLVLBlock{},
-				},
-				{
-					Name:      "linked2",
-					ClassID:   wire.FeedbagClassIdAlInfo,
-					ItemID:    uint16(0x8001),
-					GroupID:   0,
-					TLVLBlock: wire.TLVLBlock{},
-				},
-			},
-		},
-		{
-			name:                "no linked accounts, feedbag unchanged",
-			feedbagItems:        []wire.FeedbagItem{{Name: "buddy1"}},
-			linkedAccounts:      nil,
-			feedbagLastModified: time.UnixMilli(1696472198082),
-			expectItems:         []wire.FeedbagItem{{Name: "buddy1"}},
-		},
-		{
-			name:                "linkedAccountManager error, linked items silently skipped",
-			feedbagItems:        []wire.FeedbagItem{{Name: "buddy1"}},
-			linkedAccountsErr:   io.EOF,
-			feedbagLastModified: time.UnixMilli(1696472198082),
-			expectItems:         []wire.FeedbagItem{{Name: "buddy1"}},
-		},
-		{
-			name: "stored AlInfo items in rawFb are stripped and replaced by dynamic linked accounts",
-			feedbagItems: []wire.FeedbagItem{
-				{Name: "buddy1"},
-				{Name: "stalelinked", ClassID: wire.FeedbagClassIdAlInfo},
-			},
-			linkedAccounts:      []state.IdentScreenName{state.NewIdentScreenName("linked1")},
-			feedbagLastModified: time.UnixMilli(1696472198082),
-			expectItems: []wire.FeedbagItem{
-				{Name: "buddy1"},
-				{
-					Name:      "linked1",
-					ClassID:   wire.FeedbagClassIdAlInfo,
-					ItemID:    uint16(0x8000),
-					GroupID:   0,
-					TLVLBlock: wire.TLVLBlock{},
-				},
-			},
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			feedbagManager := newMockFeedbagManager(t)
-			feedbagManager.EXPECT().
-				Feedbag(matchContext(), me).
-				Return(tc.feedbagItems, nil)
-			if len(tc.expectItems) > 0 {
-				feedbagManager.EXPECT().
-					FeedbagLastModified(matchContext(), me).
-					Return(tc.feedbagLastModified, nil)
-			}
-
-			linkedAccountManager := newMockLinkedAccountManager(t)
-			linkedAccountManager.EXPECT().
-				LinkedAccounts(matchContext(), me).
-				Return(tc.linkedAccounts, tc.linkedAccountsErr)
-
-			svc := NewFeedbagService(slog.Default(), nil, feedbagManager, nil, nil, nil, nil, nil, linkedAccountManager, nil)
-			instance := newTestInstance("me")
-
-			outputSNAC, err := svc.Query(context.Background(), instance, inFrame)
-			assert.NoError(t, err)
-			body := outputSNAC.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
-			assert.Equal(t, tc.expectItems, body.Items)
-		})
-	}
-}
-
 func TestFeedbagService_QueryIfModified(t *testing.T) {
 func TestFeedbagService_QueryIfModified(t *testing.T) {
 	cases := []struct {
 	cases := []struct {
 		// name is the unit test name
 		// name is the unit test name
@@ -418,11 +313,8 @@ func TestFeedbagService_QueryIfModified(t *testing.T) {
 			//
 			//
 			// send input SNAC
 			// send input SNAC
 			//
 			//
-			linkedAccountManager := newMockLinkedAccountManager(t)
-			linkedAccountManager.EXPECT().LinkedAccounts(matchContext(), mock.Anything).Return(nil, nil).Maybe()
 			svc := FeedbagService{
 			svc := FeedbagService{
-				feedbagManager:       feedbagManager,
-				linkedAccountManager: linkedAccountManager,
+				feedbagManager: feedbagManager,
 			}
 			}
 			outputSNAC, err := svc.QueryIfModified(context.Background(), tc.instance, tc.inputSNAC.Frame,
 			outputSNAC, err := svc.QueryIfModified(context.Background(), tc.instance, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x13_0x05_FeedbagQueryIfModified))
 				tc.inputSNAC.Body.(wire.SNAC_0x13_0x05_FeedbagQueryIfModified))
@@ -435,210 +327,32 @@ func TestFeedbagService_QueryIfModified(t *testing.T) {
 	}
 	}
 }
 }
 
 
-func TestFeedbagService_QueryIfModified_LinkedAccounts(t *testing.T) {
-	inFrame := wire.SNACFrame{
-		FoodGroup: wire.Feedbag,
-		SubGroup:  wire.FeedbagInsertItem,
-		RequestID: 1234,
-	}
-	me := state.NewIdentScreenName("me")
-
-	cases := []struct {
-		name              string
-		feedbagItems      []wire.FeedbagItem
-		lastModified      time.Time
-		lastUpdate        time.Time
-		linkedAccounts    []state.IdentScreenName
-		linkedAccountsErr error
-		expectSNAC        wire.SNACMessage
-	}{
-		{
-			// FeedbagLastModified is gated on len(rawFb) > 0 in QueryIfModified,
-			// so linked accounts only appear in LastUpdate when rawFb is non-empty.
-			name:         "linked accounts appended to non-empty feedbag when modified",
-			feedbagItems: []wire.FeedbagItem{{Name: "buddy1"}},
-			lastModified: time.UnixMilli(200000),
-			lastUpdate:   time.UnixMilli(100000),
-			linkedAccounts: []state.IdentScreenName{
-				state.NewIdentScreenName("linked1"),
-			},
-			expectSNAC: wire.SNACMessage{
-				Frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagReply, RequestID: 1234},
-				Body: wire.SNAC_0x13_0x06_FeedbagReply{
-					Items: []wire.FeedbagItem{
-						{Name: "buddy1"},
-						{
-							Name:      "linked1",
-							ClassID:   wire.FeedbagClassIdAlInfo,
-							ItemID:    uint16(0x8000),
-							GroupID:   0,
-							TLVLBlock: wire.TLVLBlock{},
-						},
-					},
-					LastUpdate: uint32(time.UnixMilli(200000).Unix()),
-				},
-			},
-		},
-		{
-			// Linked accounts bypass the not-modified optimization, so a full
-			// FeedbagReply is always returned when linked accounts are present.
-			name:         "full reply returned when linked accounts present even if feedbag unmodified",
-			feedbagItems: []wire.FeedbagItem{{Name: "buddy1"}},
-			lastModified: time.UnixMilli(100000),
-			lastUpdate:   time.UnixMilli(200000),
-			linkedAccounts: []state.IdentScreenName{
-				state.NewIdentScreenName("linked1"),
-				state.NewIdentScreenName("linked2"),
-			},
-			expectSNAC: wire.SNACMessage{
-				Frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagReply, RequestID: 1234},
-				Body: wire.SNAC_0x13_0x06_FeedbagReply{
-					Items: []wire.FeedbagItem{
-						{Name: "buddy1"},
-						{
-							Name:      "linked1",
-							ClassID:   wire.FeedbagClassIdAlInfo,
-							ItemID:    uint16(0x8000),
-							GroupID:   0,
-							TLVLBlock: wire.TLVLBlock{},
-						},
-						{
-							Name:      "linked2",
-							ClassID:   wire.FeedbagClassIdAlInfo,
-							ItemID:    uint16(0x8001),
-							GroupID:   0,
-							TLVLBlock: wire.TLVLBlock{},
-						},
-					},
-					LastUpdate: uint32(time.UnixMilli(100000).Unix()),
-				},
-			},
-		},
-		{
-			name:              "linkedAccountManager error, linked items silently skipped",
-			feedbagItems:      []wire.FeedbagItem{{Name: "buddy1"}},
-			lastModified:      time.UnixMilli(200000),
-			lastUpdate:        time.UnixMilli(100000),
-			linkedAccountsErr: io.EOF,
-			expectSNAC: wire.SNACMessage{
-				Frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagReply, RequestID: 1234},
-				Body: wire.SNAC_0x13_0x06_FeedbagReply{
-					Items:      []wire.FeedbagItem{{Name: "buddy1"}},
-					LastUpdate: uint32(time.UnixMilli(200000).Unix()),
-				},
-			},
-		},
-		{
-			name:         "linked accounts returned even when raw feedbag empty, LastUpdate is zero",
-			feedbagItems: []wire.FeedbagItem{},
-			linkedAccounts: []state.IdentScreenName{
-				state.NewIdentScreenName("linked1"),
-			},
-			expectSNAC: wire.SNACMessage{
-				Frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagReply, RequestID: 1234},
-				Body: wire.SNAC_0x13_0x06_FeedbagReply{
-					Items: []wire.FeedbagItem{
-						{
-							Name:      "linked1",
-							ClassID:   wire.FeedbagClassIdAlInfo,
-							ItemID:    uint16(0x8000),
-							GroupID:   0,
-							TLVLBlock: wire.TLVLBlock{},
-						},
-					},
-					LastUpdate: 0,
-				},
-			},
-		},
-		{
-			name: "stored AlInfo items in rawFb are stripped and replaced by dynamic linked accounts",
-			feedbagItems: []wire.FeedbagItem{
-				{Name: "buddy1"},
-				{Name: "stalelinked", ClassID: wire.FeedbagClassIdAlInfo},
-			},
-			lastModified: time.UnixMilli(200000),
-			lastUpdate:   time.UnixMilli(100000),
-			linkedAccounts: []state.IdentScreenName{
-				state.NewIdentScreenName("linked1"),
-			},
-			expectSNAC: wire.SNACMessage{
-				Frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagReply, RequestID: 1234},
-				Body: wire.SNAC_0x13_0x06_FeedbagReply{
-					Items: []wire.FeedbagItem{
-						{Name: "buddy1"},
-						{
-							Name:      "linked1",
-							ClassID:   wire.FeedbagClassIdAlInfo,
-							ItemID:    uint16(0x8000),
-							GroupID:   0,
-							TLVLBlock: wire.TLVLBlock{},
-						},
-					},
-					LastUpdate: uint32(time.UnixMilli(200000).Unix()),
-				},
-			},
-		},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			feedbagManager := newMockFeedbagManager(t)
-			feedbagManager.EXPECT().
-				Feedbag(matchContext(), me).
-				Return(tc.feedbagItems, nil)
-			if len(tc.feedbagItems) > 0 {
-				feedbagManager.EXPECT().
-					FeedbagLastModified(matchContext(), me).
-					Return(tc.lastModified, nil)
-			}
-
-			linkedAccountManager := newMockLinkedAccountManager(t)
-			linkedAccountManager.EXPECT().
-				LinkedAccounts(matchContext(), me).
-				Return(tc.linkedAccounts, tc.linkedAccountsErr)
-
-			svc := NewFeedbagService(slog.Default(), nil, feedbagManager, nil, nil, nil, nil, nil, linkedAccountManager, nil)
-			instance := newTestInstance("me")
-
-			outputSNAC, err := svc.QueryIfModified(context.Background(), instance, inFrame,
-				wire.SNAC_0x13_0x05_FeedbagQueryIfModified{
-					LastUpdate: uint32(tc.lastUpdate.Unix()),
-				})
-			assert.NoError(t, err)
-			assert.Equal(t, tc.expectSNAC, outputSNAC)
-		})
-	}
-}
-
-func TestFeedbagService_UpsertItem_AlInfoIgnored(t *testing.T) {
+func TestFeedbagService_UpsertItem_AlInfoRejected(t *testing.T) {
 	instance := newTestInstance("me")
 	instance := newTestInstance("me")
 	inFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem, RequestID: 1234}
 	inFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem, RequestID: 1234}
 	items := []wire.FeedbagItem{
 	items := []wire.FeedbagItem{
 		{ClassID: wire.FeedbagClassIdAlInfo, Name: "linkeduser"},
 		{ClassID: wire.FeedbagClassIdAlInfo, Name: "linkeduser"},
 	}
 	}
 
 
-	// feedbagManager expects no FeedbagUpsert call since AlInfo items are dropped
+	// feedbagManager expects no FeedbagUpsert call since AlInfo items are rejected
 	feedbagManager := newMockFeedbagManager(t)
 	feedbagManager := newMockFeedbagManager(t)
 
 
+	// messageRelayer expects no calls — handler returns early with error response
 	messageRelayer := newMockMessageRelayer(t)
 	messageRelayer := newMockMessageRelayer(t)
-	messageRelayer.EXPECT().
-		RelayToSelf(mock.Anything, mock.Anything, wire.SNACMessage{
-			Frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagStatus, RequestID: 1234},
-			Body:  wire.SNAC_0x13_0x0E_FeedbagStatus{Results: []uint16{0x0000}},
-		})
-	messageRelayer.EXPECT().
-		RelayToOtherInstances(mock.Anything, mock.Anything, wire.SNACMessage{
-			Frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem, RequestID: wire.ReqIDFromServer},
-			Body:  wire.SNAC_0x13_0x09_FeedbagUpdateItem{Items: nil},
-		})
-
-	// linkedAccountManager expects no InsertLinkedAccount call
-	linkedAccountManager := newMockLinkedAccountManager(t)
 
 
-	svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, nil, nil, nil, nil, linkedAccountManager, nil)
+	svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, nil, nil, nil, nil, nil)
 	output, err := svc.UpsertItem(context.Background(), instance, inFrame, items)
 	output, err := svc.UpsertItem(context.Background(), instance, inFrame, items)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
-	assert.Nil(t, output)
+	assert.Equal(t, &wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagErr,
+			RequestID: 1234,
+		},
+		Body: wire.SNACError{
+			Code: wire.ErrorCodeInsufficientRights,
+		},
+	}, output)
 }
 }
 
 
 func TestFeedbagService_DeleteItem_AlInfoIgnored(t *testing.T) {
 func TestFeedbagService_DeleteItem_AlInfoIgnored(t *testing.T) {
@@ -672,15 +386,10 @@ func TestFeedbagService_DeleteItem_AlInfoIgnored(t *testing.T) {
 		BroadcastVisibility(mock.Anything, matchSession(state.NewIdentScreenName("me")), []state.IdentScreenName(nil), true).
 		BroadcastVisibility(mock.Anything, matchSession(state.NewIdentScreenName("me")), []state.IdentScreenName(nil), true).
 		Return(nil)
 		Return(nil)
 
 
-	linkedAccountManager := newMockLinkedAccountManager(t)
-	// No expectations set — DeleteLinkedAccount must not be called for AlInfo items.
-	// mock.AssertExpectations at cleanup will fail if any unexpected call occurs.
-
 	svc := FeedbagService{
 	svc := FeedbagService{
-		buddyBroadcaster:     buddyBroadcaster,
-		feedbagManager:       feedbagManager,
-		messageRelayer:       messageRelayer,
-		linkedAccountManager: linkedAccountManager,
+		buddyBroadcaster: buddyBroadcaster,
+		feedbagManager:   feedbagManager,
+		messageRelayer:   messageRelayer,
 	}
 	}
 	output, err := svc.DeleteItem(context.Background(), instance, inFrame, inBody)
 	output, err := svc.DeleteItem(context.Background(), instance, inFrame, inBody)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -688,7 +397,7 @@ func TestFeedbagService_DeleteItem_AlInfoIgnored(t *testing.T) {
 }
 }
 
 
 func TestFeedbagService_RightsQuery(t *testing.T) {
 func TestFeedbagService_RightsQuery(t *testing.T) {
-	svc := NewFeedbagService(nil, nil, nil, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+	svc := NewFeedbagService(nil, nil, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 
 
 	outputSNAC := svc.RightsQuery(context.Background(), wire.SNACFrame{RequestID: 1234})
 	outputSNAC := svc.RightsQuery(context.Background(), wire.SNACFrame{RequestID: 1234})
 	expectSNAC := wire.SNACMessage{
 	expectSNAC := wire.SNACMessage{
@@ -3000,7 +2709,7 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 				assert.Equal(t, wantBody, haveBody)
 				assert.Equal(t, wantBody, haveBody)
 				return nil, nil
 				return nil, nil
 			}
 			}
-			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, bartItemManager, nil, sessionRetriever, contactPreAuth, nil, nil, buddyAddedDeduper)
+			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, bartItemManager, nil, sessionRetriever, contactPreAuth, nil, buddyAddedDeduper)
 			svc.buddyBroadcaster = buddyUpdateBroadcaster
 			svc.buddyBroadcaster = buddyUpdateBroadcaster
 			svc.icbmSender = icbmSender
 			svc.icbmSender = icbmSender
 			output, err := svc.UpsertItem(context.Background(), tc.instance, tc.inputSNAC.Frame,
 			output, err := svc.UpsertItem(context.Background(), tc.instance, tc.inputSNAC.Frame,
@@ -3440,7 +3149,7 @@ func TestFeedbagService_Use(t *testing.T) {
 					Return(params.err)
 					Return(params.err)
 			}
 			}
 
 
-			svc := NewFeedbagService(slog.Default(), nil, feedbagManager, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+			svc := NewFeedbagService(slog.Default(), nil, feedbagManager, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 			svc.buddyBroadcaster = buddyUpdateBroadcaster
 			svc.buddyBroadcaster = buddyUpdateBroadcaster
 
 
 			haveErr := svc.Use(context.Background(), tt.instance)
 			haveErr := svc.Use(context.Background(), tt.instance)
@@ -3873,7 +3582,7 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 					Return(params.result, params.err)
 					Return(params.result, params.err)
 			}
 			}
 
 
-			svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, sessionRetriever, contactPreAuth, userManager, nil, newMockBuddyAddedNotifierDeduper(t))
+			svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, sessionRetriever, contactPreAuth, userManager, newMockBuddyAddedNotifierDeduper(t))
 			svc.icbmSender = icbmSender
 			svc.icbmSender = icbmSender
 
 
 			haveErr := svc.RequestAuthorizeToHost(
 			haveErr := svc.RequestAuthorizeToHost(
@@ -4344,7 +4053,7 @@ func TestFeedbagService_RespondAuthorizeToHost(t *testing.T) {
 				return nil, tt.wantErr
 				return nil, tt.wantErr
 			}
 			}
 
 
-			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth, nil, newMockBuddyAddedNotifierDeduper(t))
 			svc.buddyBroadcaster = buddyBroadcaster
 			svc.buddyBroadcaster = buddyBroadcaster
 			svc.icbmSender = icbmSender
 			svc.icbmSender = icbmSender
 
 
@@ -4679,7 +4388,7 @@ func TestFeedbagService_PreAuthorizeBuddy(t *testing.T) {
 				return nil, nil
 				return nil, nil
 			}
 			}
 
 
-			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth, nil, newMockBuddyAddedNotifierDeduper(t))
 			svc.icbmSender = icbmSender
 			svc.icbmSender = icbmSender
 
 
 			out, err := svc.PreAuthorizeBuddy(context.Background(), alice, tt.inFrame, tt.inBody)
 			out, err := svc.PreAuthorizeBuddy(context.Background(), alice, tt.inFrame, tt.inBody)
@@ -4908,7 +4617,7 @@ func TestFeedbagService_StartCluster(t *testing.T) {
 			Body:  inBody,
 			Body:  inBody,
 		})
 		})
 
 
-	svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+	svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 	svc.StartCluster(context.Background(), instance, inFrame, inBody)
 	svc.StartCluster(context.Background(), instance, inFrame, inBody)
 
 
 	assert.True(t, instance.InNotifyTxn())
 	assert.True(t, instance.InNotifyTxn())
@@ -4985,7 +4694,7 @@ func TestFeedbagService_EndCluster(t *testing.T) {
 					Return(params.err)
 					Return(params.err)
 			}
 			}
 
 
-			svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+			svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 			svc.buddyBroadcaster = buddyBroadcaster
 			svc.buddyBroadcaster = buddyBroadcaster
 			err := svc.EndCluster(context.Background(), tc.instance, endFrame)
 			err := svc.EndCluster(context.Background(), tc.instance, endFrame)
 			assert.NoError(t, err)
 			assert.NoError(t, err)
@@ -5040,7 +4749,7 @@ func TestFeedbagService_notifyTxnCluster(t *testing.T) {
 			Return(nil).
 			Return(nil).
 			Once()
 			Once()
 
 
-		svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+		svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 		svc.buddyBroadcaster = buddyBroadcaster
 		svc.buddyBroadcaster = buddyBroadcaster
 
 
 		svc.StartCluster(context.Background(), instance, startFrame, startBody)
 		svc.StartCluster(context.Background(), instance, startFrame, startBody)
@@ -5082,7 +4791,7 @@ func TestFeedbagService_notifyTxnCluster(t *testing.T) {
 			Return(nil).
 			Return(nil).
 			Once()
 			Once()
 
 
-		svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+		svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 		svc.buddyBroadcaster = buddyBroadcaster
 		svc.buddyBroadcaster = buddyBroadcaster
 
 
 		svc.StartCluster(context.Background(), instance, startFrame, startBody)
 		svc.StartCluster(context.Background(), instance, startFrame, startBody)
@@ -5121,7 +4830,7 @@ func TestFeedbagService_notifyTxnCluster(t *testing.T) {
 			Return(nil).
 			Return(nil).
 			Once()
 			Once()
 
 
-		svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+		svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 		svc.buddyBroadcaster = buddyBroadcaster
 		svc.buddyBroadcaster = buddyBroadcaster
 
 
 		svc.StartCluster(context.Background(), instance, startFrame, startBody)
 		svc.StartCluster(context.Background(), instance, startFrame, startBody)
@@ -5416,7 +5125,7 @@ func TestFeedbagService_ForwardICQAuthEvents(t *testing.T) {
 				return nil, tt.wantErr
 				return nil, tt.wantErr
 			}
 			}
 
 
-			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth, nil, newMockBuddyAddedNotifierDeduper(t))
 			svc.icbmSender = icbmSender
 			svc.icbmSender = icbmSender
 
 
 			err := svc.ForwardICQAuthEvents(context.Background(), sender.IdentScreenName(), recipient, tt.authMsg)
 			err := svc.ForwardICQAuthEvents(context.Background(), sender.IdentScreenName(), recipient, tt.authMsg)

+ 0 - 15
foodgroup/helpers_test.go

@@ -34,7 +34,6 @@ type mockParams struct {
 	sessionRegistryParams
 	sessionRegistryParams
 	sessionRetrieverParams
 	sessionRetrieverParams
 	userManagerParams
 	userManagerParams
-	linkedAccountManagerParams
 }
 }
 
 
 // contactPreAuthorizerParams is a helper struct that contains mock parameters for
 // contactPreAuthorizerParams is a helper struct that contains mock parameters for
@@ -802,20 +801,6 @@ type createChatRoomParams []struct {
 	err  error
 	err  error
 }
 }
 
 
-// linkedAccountManagerParams is a helper struct that contains mock parameters for
-// LinkedAccountManager methods
-type linkedAccountManagerParams struct {
-	linkedAccountsParams
-}
-
-// linkedAccountsParams is the list of parameters passed at the mock
-// LinkedAccountManager.LinkedAccounts call site
-type linkedAccountsParams []struct {
-	screenName state.IdentScreenName
-	result     []state.IdentScreenName
-	err        error
-}
-
 // sessOptWarning sets a warning level on the session object
 // sessOptWarning sets a warning level on the session object
 func sessOptWarning(level int16) func(instance *state.SessionInstance) {
 func sessOptWarning(level int16) func(instance *state.SessionInstance) {
 	return func(instance *state.SessionInstance) {
 	return func(instance *state.SessionInstance) {

+ 0 - 305
foodgroup/mock_linked_account_manager_test.go

@@ -1,305 +0,0 @@
-// 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"
-)
-
-// newMockLinkedAccountManager creates a new instance of mockLinkedAccountManager. 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 newMockLinkedAccountManager(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockLinkedAccountManager {
-	mock := &mockLinkedAccountManager{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}
-
-// mockLinkedAccountManager is an autogenerated mock type for the LinkedAccountManager type
-type mockLinkedAccountManager struct {
-	mock.Mock
-}
-
-type mockLinkedAccountManager_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockLinkedAccountManager) EXPECT() *mockLinkedAccountManager_Expecter {
-	return &mockLinkedAccountManager_Expecter{mock: &_m.Mock}
-}
-
-// CheckLinkedAccount provides a mock function for the type mockLinkedAccountManager
-func (_mock *mockLinkedAccountManager) CheckLinkedAccount(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName) (bool, error) {
-	ret := _mock.Called(ctx, screenName, linkedScreenName)
-
-	if len(ret) == 0 {
-		panic("no return value specified for CheckLinkedAccount")
-	}
-
-	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, screenName, linkedScreenName)
-	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, state.IdentScreenName) bool); ok {
-		r0 = returnFunc(ctx, screenName, linkedScreenName)
-	} else {
-		r0 = ret.Get(0).(bool)
-	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, state.IdentScreenName, state.IdentScreenName) error); ok {
-		r1 = returnFunc(ctx, screenName, linkedScreenName)
-	} else {
-		r1 = ret.Error(1)
-	}
-	return r0, r1
-}
-
-// mockLinkedAccountManager_CheckLinkedAccount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckLinkedAccount'
-type mockLinkedAccountManager_CheckLinkedAccount_Call struct {
-	*mock.Call
-}
-
-// CheckLinkedAccount is a helper method to define mock.On call
-//   - ctx context.Context
-//   - screenName state.IdentScreenName
-//   - linkedScreenName state.IdentScreenName
-func (_e *mockLinkedAccountManager_Expecter) CheckLinkedAccount(ctx interface{}, screenName interface{}, linkedScreenName interface{}) *mockLinkedAccountManager_CheckLinkedAccount_Call {
-	return &mockLinkedAccountManager_CheckLinkedAccount_Call{Call: _e.mock.On("CheckLinkedAccount", ctx, screenName, linkedScreenName)}
-}
-
-func (_c *mockLinkedAccountManager_CheckLinkedAccount_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName)) *mockLinkedAccountManager_CheckLinkedAccount_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 *mockLinkedAccountManager_CheckLinkedAccount_Call) Return(b bool, err error) *mockLinkedAccountManager_CheckLinkedAccount_Call {
-	_c.Call.Return(b, err)
-	return _c
-}
-
-func (_c *mockLinkedAccountManager_CheckLinkedAccount_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName) (bool, error)) *mockLinkedAccountManager_CheckLinkedAccount_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// DeleteLinkedAccount provides a mock function for the type mockLinkedAccountManager
-func (_mock *mockLinkedAccountManager) DeleteLinkedAccount(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName) error {
-	ret := _mock.Called(ctx, screenName, linkedScreenName)
-
-	if len(ret) == 0 {
-		panic("no return value specified for DeleteLinkedAccount")
-	}
-
-	var r0 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, state.IdentScreenName) error); ok {
-		r0 = returnFunc(ctx, screenName, linkedScreenName)
-	} else {
-		r0 = ret.Error(0)
-	}
-	return r0
-}
-
-// mockLinkedAccountManager_DeleteLinkedAccount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteLinkedAccount'
-type mockLinkedAccountManager_DeleteLinkedAccount_Call struct {
-	*mock.Call
-}
-
-// DeleteLinkedAccount is a helper method to define mock.On call
-//   - ctx context.Context
-//   - screenName state.IdentScreenName
-//   - linkedScreenName state.IdentScreenName
-func (_e *mockLinkedAccountManager_Expecter) DeleteLinkedAccount(ctx interface{}, screenName interface{}, linkedScreenName interface{}) *mockLinkedAccountManager_DeleteLinkedAccount_Call {
-	return &mockLinkedAccountManager_DeleteLinkedAccount_Call{Call: _e.mock.On("DeleteLinkedAccount", ctx, screenName, linkedScreenName)}
-}
-
-func (_c *mockLinkedAccountManager_DeleteLinkedAccount_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName)) *mockLinkedAccountManager_DeleteLinkedAccount_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 *mockLinkedAccountManager_DeleteLinkedAccount_Call) Return(err error) *mockLinkedAccountManager_DeleteLinkedAccount_Call {
-	_c.Call.Return(err)
-	return _c
-}
-
-func (_c *mockLinkedAccountManager_DeleteLinkedAccount_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName) error) *mockLinkedAccountManager_DeleteLinkedAccount_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// InsertLinkedAccount provides a mock function for the type mockLinkedAccountManager
-func (_mock *mockLinkedAccountManager) InsertLinkedAccount(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName) error {
-	ret := _mock.Called(ctx, screenName, linkedScreenName)
-
-	if len(ret) == 0 {
-		panic("no return value specified for InsertLinkedAccount")
-	}
-
-	var r0 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, state.IdentScreenName) error); ok {
-		r0 = returnFunc(ctx, screenName, linkedScreenName)
-	} else {
-		r0 = ret.Error(0)
-	}
-	return r0
-}
-
-// mockLinkedAccountManager_InsertLinkedAccount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InsertLinkedAccount'
-type mockLinkedAccountManager_InsertLinkedAccount_Call struct {
-	*mock.Call
-}
-
-// InsertLinkedAccount is a helper method to define mock.On call
-//   - ctx context.Context
-//   - screenName state.IdentScreenName
-//   - linkedScreenName state.IdentScreenName
-func (_e *mockLinkedAccountManager_Expecter) InsertLinkedAccount(ctx interface{}, screenName interface{}, linkedScreenName interface{}) *mockLinkedAccountManager_InsertLinkedAccount_Call {
-	return &mockLinkedAccountManager_InsertLinkedAccount_Call{Call: _e.mock.On("InsertLinkedAccount", ctx, screenName, linkedScreenName)}
-}
-
-func (_c *mockLinkedAccountManager_InsertLinkedAccount_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName)) *mockLinkedAccountManager_InsertLinkedAccount_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 *mockLinkedAccountManager_InsertLinkedAccount_Call) Return(err error) *mockLinkedAccountManager_InsertLinkedAccount_Call {
-	_c.Call.Return(err)
-	return _c
-}
-
-func (_c *mockLinkedAccountManager_InsertLinkedAccount_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName) error) *mockLinkedAccountManager_InsertLinkedAccount_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// LinkedAccounts provides a mock function for the type mockLinkedAccountManager
-func (_mock *mockLinkedAccountManager) LinkedAccounts(ctx context.Context, screenName state.IdentScreenName) ([]state.IdentScreenName, error) {
-	ret := _mock.Called(ctx, screenName)
-
-	if len(ret) == 0 {
-		panic("no return value specified for LinkedAccounts")
-	}
-
-	var r0 []state.IdentScreenName
-	var r1 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) ([]state.IdentScreenName, error)); ok {
-		return returnFunc(ctx, screenName)
-	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) []state.IdentScreenName); ok {
-		r0 = returnFunc(ctx, screenName)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).([]state.IdentScreenName)
-		}
-	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, state.IdentScreenName) error); ok {
-		r1 = returnFunc(ctx, screenName)
-	} else {
-		r1 = ret.Error(1)
-	}
-	return r0, r1
-}
-
-// mockLinkedAccountManager_LinkedAccounts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinkedAccounts'
-type mockLinkedAccountManager_LinkedAccounts_Call struct {
-	*mock.Call
-}
-
-// LinkedAccounts is a helper method to define mock.On call
-//   - ctx context.Context
-//   - screenName state.IdentScreenName
-func (_e *mockLinkedAccountManager_Expecter) LinkedAccounts(ctx interface{}, screenName interface{}) *mockLinkedAccountManager_LinkedAccounts_Call {
-	return &mockLinkedAccountManager_LinkedAccounts_Call{Call: _e.mock.On("LinkedAccounts", ctx, screenName)}
-}
-
-func (_c *mockLinkedAccountManager_LinkedAccounts_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName)) *mockLinkedAccountManager_LinkedAccounts_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		var arg0 context.Context
-		if args[0] != nil {
-			arg0 = args[0].(context.Context)
-		}
-		var arg1 state.IdentScreenName
-		if args[1] != nil {
-			arg1 = args[1].(state.IdentScreenName)
-		}
-		run(
-			arg0,
-			arg1,
-		)
-	})
-	return _c
-}
-
-func (_c *mockLinkedAccountManager_LinkedAccounts_Call) Return(identScreenNames []state.IdentScreenName, err error) *mockLinkedAccountManager_LinkedAccounts_Call {
-	_c.Call.Return(identScreenNames, err)
-	return _c
-}
-
-func (_c *mockLinkedAccountManager_LinkedAccounts_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName) ([]state.IdentScreenName, error)) *mockLinkedAccountManager_LinkedAccounts_Call {
-	_c.Call.Return(run)
-	return _c
-}

+ 5 - 5
foodgroup/oservice.go

@@ -28,7 +28,7 @@ type OServiceService struct {
 	chatMessageRelayer    ChatMessageRelayer
 	chatMessageRelayer    ChatMessageRelayer
 	profileManager        ProfileManager
 	profileManager        ProfileManager
 	offlineMessageManager OfflineMessageManager
 	offlineMessageManager OfflineMessageManager
-	linkedAccountManager  LinkedAccountManager
+	feedbagManager        FeedbagManager
 }
 }
 
 
 // NewOServiceService creates a new instance of NewOServiceService.
 // NewOServiceService creates a new instance of NewOServiceService.
@@ -45,7 +45,7 @@ func NewOServiceService(
 	chatMessageRelayer ChatMessageRelayer,
 	chatMessageRelayer ChatMessageRelayer,
 	profileManager ProfileManager,
 	profileManager ProfileManager,
 	offlineMessageManager OfflineMessageManager,
 	offlineMessageManager OfflineMessageManager,
-	linkedAccountManager LinkedAccountManager,
+	feedbagManager FeedbagManager,
 ) *OServiceService {
 ) *OServiceService {
 	return &OServiceService{
 	return &OServiceService{
 		cookieIssuer:          cookieIssuer,
 		cookieIssuer:          cookieIssuer,
@@ -59,7 +59,7 @@ func NewOServiceService(
 		chatMessageRelayer:    chatMessageRelayer,
 		chatMessageRelayer:    chatMessageRelayer,
 		profileManager:        profileManager,
 		profileManager:        profileManager,
 		offlineMessageManager: offlineMessageManager,
 		offlineMessageManager: offlineMessageManager,
-		linkedAccountManager:  linkedAccountManager,
+		feedbagManager:        feedbagManager,
 	}
 	}
 }
 }
 
 
@@ -638,11 +638,11 @@ func (s OServiceService) ServiceRequest(ctx context.Context, service uint16, ins
 			linkedScreenName := state.NewIdentScreenName(string(snBytes))
 			linkedScreenName := state.NewIdentScreenName(string(snBytes))
 			s.logger.Debug("Linked Account signon request", "primary", instance.IdentScreenName(), "linked", linkedScreenName.String())
 			s.logger.Debug("Linked Account signon request", "primary", instance.IdentScreenName(), "linked", linkedScreenName.String())
 
 
-			linked, err := s.linkedAccountManager.CheckLinkedAccount(ctx, instance.IdentScreenName(), linkedScreenName)
+			items, err := s.feedbagManager.Feedbag(ctx, instance.IdentScreenName())
 			if err != nil {
 			if err != nil {
 				return nil, fmt.Errorf("unable to check linked account: %w", err)
 				return nil, fmt.Errorf("unable to check linked account: %w", err)
 			}
 			}
-			if !linked {
+			if !state.NewFeedbagList(items, nil).HasLinkedScreenName(linkedScreenName.String()) {
 				return nil, errors.New("linked account session requested but accounts are not linked")
 				return nil, errors.New("linked account session requested but accounts are not linked")
 			}
 			}
 			return fnIssueCookie(state.ServerCookie{
 			return fnIssueCookie(state.ServerCookie{

+ 28 - 31
foodgroup/oservice_test.go

@@ -876,12 +876,11 @@ func TestOServiceService_ServiceRequest_LinkedAccountSignon(t *testing.T) {
 	}
 	}
 
 
 	cases := []struct {
 	cases := []struct {
-		name      string
-		instance  *state.SessionInstance
-		inputBody wire.SNAC_0x01_0x04_OServiceServiceRequest
-		// linked is what CheckLinkedAccount returns
-		linked   bool
-		checkErr error
+		name         string
+		instance     *state.SessionInstance
+		inputBody    wire.SNAC_0x01_0x04_OServiceServiceRequest
+		feedbagItems []wire.FeedbagItem
+		feedbagErr   error
 		// setupCookie is whether to expect cookie issuance
 		// setupCookie is whether to expect cookie issuance
 		setupCookie     bool
 		setupCookie     bool
 		expectOutput    wire.SNACMessage
 		expectOutput    wire.SNACMessage
@@ -889,10 +888,10 @@ func TestOServiceService_ServiceRequest_LinkedAccountSignon(t *testing.T) {
 		wantErr         error
 		wantErr         error
 	}{
 	}{
 		{
 		{
-			name:        "linked account signon OK, returns BOS cookie with no MultiConnFlag",
-			inputBody:   makeBody(true, string(linkedUser)),
-			linked:      true,
-			setupCookie: true,
+			name:         "linked account signon OK, returns BOS cookie with no MultiConnFlag",
+			inputBody:    makeBody(true, string(linkedUser)),
+			feedbagItems: []wire.FeedbagItem{{ClassID: wire.FeedbagClassIdAlInfo, Name: linkedUserIdent.String()}},
+			setupCookie:  true,
 			expectOutput: wire.SNACMessage{
 			expectOutput: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.OService,
 					FoodGroup: wire.OService,
@@ -912,11 +911,11 @@ func TestOServiceService_ServiceRequest_LinkedAccountSignon(t *testing.T) {
 			},
 			},
 		},
 		},
 		{
 		{
-			name:        "primary has MultiConnFlagsRecentClient, linked account cookie inherits flag",
-			instance:    newTestInstance(state.DisplayScreenName(primaryUser.String()), sessOptMultiConnFlag(wire.MultiConnFlagsRecentClient)),
-			inputBody:   makeBody(true, string(linkedUser)),
-			linked:      true,
-			setupCookie: true,
+			name:         "primary has MultiConnFlagsRecentClient, linked account cookie inherits flag",
+			instance:     newTestInstance(state.DisplayScreenName(primaryUser.String()), sessOptMultiConnFlag(wire.MultiConnFlagsRecentClient)),
+			inputBody:    makeBody(true, string(linkedUser)),
+			feedbagItems: []wire.FeedbagItem{{ClassID: wire.FeedbagClassIdAlInfo, Name: linkedUserIdent.String()}},
+			setupCookie:  true,
 			expectOutput: wire.SNACMessage{
 			expectOutput: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.OService,
 					FoodGroup: wire.OService,
@@ -936,11 +935,11 @@ func TestOServiceService_ServiceRequest_LinkedAccountSignon(t *testing.T) {
 			},
 			},
 		},
 		},
 		{
 		{
-			name:        "primary has MultiConnFlagsSingleClient, linked account cookie inherits flag",
-			instance:    newTestInstance(state.DisplayScreenName(primaryUser.String()), sessOptMultiConnFlag(wire.MultiConnFlagsSingleClient)),
-			inputBody:   makeBody(true, string(linkedUser)),
-			linked:      true,
-			setupCookie: true,
+			name:         "primary has MultiConnFlagsSingleClient, linked account cookie inherits flag",
+			instance:     newTestInstance(state.DisplayScreenName(primaryUser.String()), sessOptMultiConnFlag(wire.MultiConnFlagsSingleClient)),
+			inputBody:    makeBody(true, string(linkedUser)),
+			feedbagItems: []wire.FeedbagItem{{ClassID: wire.FeedbagClassIdAlInfo, Name: linkedUserIdent.String()}},
+			setupCookie:  true,
 			expectOutput: wire.SNACMessage{
 			expectOutput: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.OService,
 					FoodGroup: wire.OService,
@@ -972,14 +971,13 @@ func TestOServiceService_ServiceRequest_LinkedAccountSignon(t *testing.T) {
 		{
 		{
 			name:            "accounts not linked, returns error",
 			name:            "accounts not linked, returns error",
 			inputBody:       makeBody(true, string(linkedUser)),
 			inputBody:       makeBody(true, string(linkedUser)),
-			linked:          false,
 			wantErrContains: "linked account session requested but accounts are not linked",
 			wantErrContains: "linked account session requested but accounts are not linked",
 		},
 		},
 		{
 		{
-			name:      "CheckLinkedAccount returns error, error propagated",
-			inputBody: makeBody(true, string(linkedUser)),
-			checkErr:  io.EOF,
-			wantErr:   io.EOF,
+			name:       "feedbag lookup error, error propagated",
+			inputBody:  makeBody(true, string(linkedUser)),
+			feedbagErr: io.EOF,
+			wantErr:    io.EOF,
 		},
 		},
 	}
 	}
 
 
@@ -997,18 +995,17 @@ func TestOServiceService_ServiceRequest_LinkedAccountSignon(t *testing.T) {
 					Return([]byte("the-cookie"), nil)
 					Return([]byte("the-cookie"), nil)
 			}
 			}
 
 
-			linkedAccountManager := newMockLinkedAccountManager(t)
-			// Only set up CheckLinkedAccount if the request has both required TLVs
+			feedbagManager := newMockFeedbagManager(t)
 			if tc.inputBody.HasTag(0x0028) {
 			if tc.inputBody.HasTag(0x0028) {
 				if snBytes, ok := tc.inputBody.Bytes(0x01); ok && len(snBytes) > 0 {
 				if snBytes, ok := tc.inputBody.Bytes(0x01); ok && len(snBytes) > 0 {
-					linkedAccountManager.EXPECT().
-						CheckLinkedAccount(matchContext(), primaryUser, linkedUserIdent).
-						Return(tc.linked, tc.checkErr)
+					feedbagManager.EXPECT().
+						Feedbag(matchContext(), primaryUser).
+						Return(tc.feedbagItems, tc.feedbagErr)
 				}
 				}
 			}
 			}
 
 
 			svc := NewOServiceService(config.Config{}, nil, slog.Default(), cookieIssuer, nil, nil, nil, nil,
 			svc := NewOServiceService(config.Config{}, nil, slog.Default(), cookieIssuer, nil, nil, nil, nil,
-				wire.DefaultSNACRateLimits(), nil, nil, nil, linkedAccountManager)
+				wire.DefaultSNACRateLimits(), nil, nil, nil, feedbagManager)
 
 
 			listener := config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:5190"}
 			listener := config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:5190"}
 
 

+ 0 - 16
foodgroup/types.go

@@ -429,19 +429,3 @@ type UserManager interface {
 	// SetWarnLevel updates the last warn update time and warning level for a user.
 	// SetWarnLevel updates the last warn update time and warning level for a user.
 	SetWarnLevel(ctx context.Context, user state.IdentScreenName, lastWarnUpdate time.Time, lastWarnLevel uint16) error
 	SetWarnLevel(ctx context.Context, user state.IdentScreenName, lastWarnUpdate time.Time, lastWarnLevel uint16) error
 }
 }
-
-type LinkedAccountManager interface {
-	// LinkedAccounts retrieves all accounts linked to the primary screen name.
-	LinkedAccounts(ctx context.Context, screenName state.IdentScreenName) ([]state.IdentScreenName, error)
-
-	// InsertLinkedAccount adds a linked account relationship. Return state.ErrLinkExists
-	// if a link relationship with the same screenname already exists.
-	InsertLinkedAccount(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName) error
-
-	// DeleteLinkedAccount removes a linked account relationship. Returns state.ErrNoUser
-	// if the linked account relationship does not exist.
-	DeleteLinkedAccount(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName) error
-
-	// CheckLinkedAccount reports whether the proposed screenName->linkedScreenName relationship is valid.
-	CheckLinkedAccount(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName) (bool, error)
-}

+ 0 - 41
server/http/helpers_test.go

@@ -20,7 +20,6 @@ type mockParams struct {
 	directoryManagerParams
 	directoryManagerParams
 	feedBagRetrieverParams
 	feedBagRetrieverParams
 	feedbagManagerParams
 	feedbagManagerParams
-	linkedAccountManagerParams
 	messageRelayerParams
 	messageRelayerParams
 	profileRetrieverParams
 	profileRetrieverParams
 	sessionRetrieverParams
 	sessionRetrieverParams
@@ -354,46 +353,6 @@ type setUserPasswordParams []struct {
 	err         error
 	err         error
 }
 }
 
 
-// linkedAccountManagerParams is a helper struct that contains mock parameters for
-// LinkedAccountManager methods
-type linkedAccountManagerParams struct {
-	linkedAccountsParams
-	insertLinkedAccountParams
-	deleteLinkedAccountParams
-	deleteAllLinkedAccountParams
-}
-
-// linkedAccountsParams is the list of parameters passed at the mock
-// LinkedAccountManager.LinkedAccounts call site
-type linkedAccountsParams []struct {
-	screenName state.IdentScreenName
-	result     []state.IdentScreenName
-	err        error
-}
-
-// insertLinkedAccountParams is the list of parameters passed at the mock
-// LinkedAccountManager.InsertLinkedAccount call site
-type insertLinkedAccountParams []struct {
-	screenName       state.IdentScreenName
-	linkedScreenName state.IdentScreenName
-	err              error
-}
-
-// deleteLinkedAccountParams is the list of parameters passed at the mock
-// LinkedAccountManager.DeleteLinkedAccount call site
-type deleteLinkedAccountParams []struct {
-	screenName       state.IdentScreenName
-	linkedScreenName state.IdentScreenName
-	err              error
-}
-
-// deleteAllLinkedAccountParams is the list of parameters passed at the mock
-// LinkedAccountManager.DeleteAllLinkedAccounts call site
-type deleteAllLinkedAccountParams []struct {
-	screenName state.IdentScreenName
-	err        error
-}
-
 // matchContext matches any instance of Context interface.
 // matchContext matches any instance of Context interface.
 func matchContext() interface{} {
 func matchContext() interface{} {
 	return mock.MatchedBy(func(ctx any) bool {
 	return mock.MatchedBy(func(ctx any) bool {

+ 73 - 37
server/http/mgmt_api.go

@@ -13,6 +13,7 @@ import (
 	"math"
 	"math"
 	"math/rand"
 	"math/rand"
 	"net/http"
 	"net/http"
+	"slices"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
 	"time"
 	"time"
@@ -24,7 +25,7 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 )
 
 
-func NewManagementAPI(bld config.Build, listener string, userManager UserManager, sessionRetriever SessionRetriever, buddyBroadcaster BuddyBroadcaster, chatRoomRetriever ChatRoomRetriever, chatRoomCreator ChatRoomCreator, chatRoomDeleter ChatRoomDeleter, chatSessionRetriever ChatSessionRetriever, directoryManager DirectoryManager, messageRelayer MessageRelayer, bartAssetManager BARTAssetManager, feedbagRetriever FeedBagRetriever, feedbagManager FeedbagManager, accountManager AccountManager, profileRetriever ProfileRetriever, webAPIKeyManager WebAPIKeyManager, icqProfileManager ICQProfileManager, linkedAccountManager LinkedAccountManager, createAccount state.CreateAccountFunc, logger *slog.Logger) *Server {
+func NewManagementAPI(bld config.Build, listener string, userManager UserManager, sessionRetriever SessionRetriever, buddyBroadcaster BuddyBroadcaster, chatRoomRetriever ChatRoomRetriever, chatRoomCreator ChatRoomCreator, chatRoomDeleter ChatRoomDeleter, chatSessionRetriever ChatSessionRetriever, directoryManager DirectoryManager, messageRelayer MessageRelayer, bartAssetManager BARTAssetManager, feedbagRetriever FeedBagRetriever, feedbagManager FeedbagManager, accountManager AccountManager, profileRetriever ProfileRetriever, webAPIKeyManager WebAPIKeyManager, icqProfileManager ICQProfileManager, createAccount state.CreateAccountFunc, logger *slog.Logger) *Server {
 	mux := http.NewServeMux()
 	mux := http.NewServeMux()
 
 
 	// Handlers for '/user' route
 	// Handlers for '/user' route
@@ -189,16 +190,13 @@ func NewManagementAPI(bld config.Build, listener string, userManager UserManager
 
 
 	// Handlers for '/user/{screenname}/linked-account' routes
 	// Handlers for '/user/{screenname}/linked-account' routes
 	mux.HandleFunc("GET /user/{screenname}/linked-account", func(w http.ResponseWriter, r *http.Request) {
 	mux.HandleFunc("GET /user/{screenname}/linked-account", func(w http.ResponseWriter, r *http.Request) {
-		getLinkedAccountsHandler(w, r, userManager, linkedAccountManager)
+		getLinkedAccountsHandler(w, r, userManager, feedbagManager)
 	})
 	})
 	mux.HandleFunc("POST /user/{screenname}/linked-account", func(w http.ResponseWriter, r *http.Request) {
 	mux.HandleFunc("POST /user/{screenname}/linked-account", func(w http.ResponseWriter, r *http.Request) {
-		postLinkedAccountHandler(w, r, userManager, linkedAccountManager)
-	})
-	mux.HandleFunc("DELETE /user/{screenname}/linked-account", func(w http.ResponseWriter, r *http.Request) {
-		deleteAllLinkedAccountsHandler(w, r, userManager, linkedAccountManager)
+		postLinkedAccountHandler(w, r, userManager, feedbagManager, logger, rand.Intn)
 	})
 	})
 	mux.HandleFunc("DELETE /user/{screenname}/linked-account/{linked_screenname}", func(w http.ResponseWriter, r *http.Request) {
 	mux.HandleFunc("DELETE /user/{screenname}/linked-account/{linked_screenname}", func(w http.ResponseWriter, r *http.Request) {
-		deleteLinkedAccountHandler(w, r, userManager, linkedAccountManager)
+		deleteLinkedAccountHandler(w, r, userManager, feedbagManager, logger, rand.Intn)
 	})
 	})
 
 
 	return &Server{
 	return &Server{
@@ -1418,24 +1416,32 @@ func putICQProfileHandler(w http.ResponseWriter, r *http.Request, mgr ICQProfile
 	w.WriteHeader(http.StatusNoContent)
 	w.WriteHeader(http.StatusNoContent)
 }
 }
 
 
-func getLinkedAccountsHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, linkedAccountManager LinkedAccountManager) {
+func getLinkedAccountsHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, manager FeedbagManager) {
 	screenName := r.PathValue("screenname")
 	screenName := r.PathValue("screenname")
 	user, err := userManager.User(r.Context(), state.NewIdentScreenName(screenName))
 	user, err := userManager.User(r.Context(), state.NewIdentScreenName(screenName))
 	if err != nil || user == nil {
 	if err != nil || user == nil {
 		errorMsg(w, "user not found", http.StatusNotFound)
 		errorMsg(w, "user not found", http.StatusNotFound)
 		return
 		return
 	}
 	}
-	accounts, err := linkedAccountManager.LinkedAccounts(r.Context(), state.NewIdentScreenName(screenName))
+
+	feedbag, err := manager.Feedbag(r.Context(), user.IdentScreenName)
 	if err != nil {
 	if err != nil {
 		errorMsg(w, "internal server error", http.StatusInternalServerError)
 		errorMsg(w, "internal server error", http.StatusInternalServerError)
 		return
 		return
 	}
 	}
+	accounts := slices.Collect(func(yield func(item wire.FeedbagItem) bool) {
+		for _, item := range feedbag {
+			if item.ClassID == wire.FeedbagClassIdAlInfo {
+				yield(item)
+			}
+		}
+	})
 	type response struct {
 	type response struct {
 		LinkedAccounts []string `json:"linked_accounts"`
 		LinkedAccounts []string `json:"linked_accounts"`
 	}
 	}
 	out := response{LinkedAccounts: make([]string, 0, len(accounts))}
 	out := response{LinkedAccounts: make([]string, 0, len(accounts))}
 	for _, a := range accounts {
 	for _, a := range accounts {
-		out.LinkedAccounts = append(out.LinkedAccounts, a.String())
+		out.LinkedAccounts = append(out.LinkedAccounts, a.Name)
 	}
 	}
 	w.Header().Set("Content-Type", "application/json")
 	w.Header().Set("Content-Type", "application/json")
 	if err := json.NewEncoder(w).Encode(out); err != nil {
 	if err := json.NewEncoder(w).Encode(out); err != nil {
@@ -1443,9 +1449,10 @@ func getLinkedAccountsHandler(w http.ResponseWriter, r *http.Request, userManage
 	}
 	}
 }
 }
 
 
-func postLinkedAccountHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, linkedAccountManager LinkedAccountManager) {
-	screenName := r.PathValue("screenname")
-	user, err := userManager.User(r.Context(), state.NewIdentScreenName(screenName))
+func postLinkedAccountHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, manager FeedbagManager, logger *slog.Logger, intn func(n int) int) {
+	sn := state.NewIdentScreenName(r.PathValue("screenname"))
+
+	user, err := userManager.User(r.Context(), sn)
 	if err != nil || user == nil {
 	if err != nil || user == nil {
 		errorMsg(w, "user not found", http.StatusNotFound)
 		errorMsg(w, "user not found", http.StatusNotFound)
 		return
 		return
@@ -1457,42 +1464,50 @@ func postLinkedAccountHandler(w http.ResponseWriter, r *http.Request, userManage
 		errorMsg(w, "invalid request body", http.StatusBadRequest)
 		errorMsg(w, "invalid request body", http.StatusBadRequest)
 		return
 		return
 	}
 	}
-	if state.NewIdentScreenName(screenName) == state.NewIdentScreenName(body.LinkedScreenName) {
+	linkSn := state.NewIdentScreenName(body.LinkedScreenName)
+
+	if sn == linkSn {
 		errorMsg(w, "cannot link an account to itself", http.StatusBadRequest)
 		errorMsg(w, "cannot link an account to itself", http.StatusBadRequest)
 		return
 		return
 	}
 	}
-	linkedUser, err := userManager.User(r.Context(), state.NewIdentScreenName(body.LinkedScreenName))
-	if err != nil || linkedUser == nil {
-		errorMsg(w, "linked user not found", http.StatusNotFound)
+
+	linkedUser, err := userManager.User(r.Context(), linkSn)
+	if err != nil {
+		logger.Error("error getting linked user", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
 		return
 		return
 	}
 	}
-	err = linkedAccountManager.InsertLinkedAccount(r.Context(), state.NewIdentScreenName(screenName), state.NewIdentScreenName(body.LinkedScreenName))
-	if errors.Is(err, state.ErrLinkExists) {
-		errorMsg(w, "linked account already exists", http.StatusConflict)
+	if linkedUser == nil {
+		errorMsg(w, "linked user not found", http.StatusNotFound)
 		return
 		return
 	}
 	}
+
+	feedbag, err := manager.Feedbag(r.Context(), user.IdentScreenName)
 	if err != nil {
 	if err != nil {
 		errorMsg(w, "internal server error", http.StatusInternalServerError)
 		errorMsg(w, "internal server error", http.StatusInternalServerError)
 		return
 		return
 	}
 	}
-	w.WriteHeader(http.StatusCreated)
-}
 
 
-func deleteAllLinkedAccountsHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, linkedAccountManager LinkedAccountManager) {
-	screenName := r.PathValue("screenname")
-	user, err := userManager.User(r.Context(), state.NewIdentScreenName(screenName))
-	if err != nil || user == nil {
-		errorMsg(w, "user not found", http.StatusNotFound)
+	fbList := state.NewFeedbagList(feedbag, intn)
+
+	if fbList.HasLinkedScreenName(body.LinkedScreenName) {
+		errorMsg(w, "linked account already exists", http.StatusConflict)
 		return
 		return
 	}
 	}
-	if err := linkedAccountManager.DeleteAllLinkedAccounts(r.Context(), state.NewIdentScreenName(screenName)); err != nil {
-		errorMsg(w, "internal server error", http.StatusInternalServerError)
-		return
+	fbList.AddLinkedScreenName(body.LinkedScreenName)
+
+	if pending := fbList.PendingUpdates(); len(pending) > 0 {
+		if err := manager.FeedbagUpsert(r.Context(), user.IdentScreenName, pending); err != nil {
+			logger.Error("error inserting feedbag item", "err", err.Error())
+			errorMsg(w, "internal server error", http.StatusInternalServerError)
+			return
+		}
 	}
 	}
-	w.WriteHeader(http.StatusNoContent)
+
+	w.WriteHeader(http.StatusCreated)
 }
 }
 
 
-func deleteLinkedAccountHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, linkedAccountManager LinkedAccountManager) {
+func deleteLinkedAccountHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, manager FeedbagManager, logger *slog.Logger, intn func(n int) int) {
 	screenName := r.PathValue("screenname")
 	screenName := r.PathValue("screenname")
 	linkedScreenName := r.PathValue("linked_screenname")
 	linkedScreenName := r.PathValue("linked_screenname")
 	user, err := userManager.User(r.Context(), state.NewIdentScreenName(screenName))
 	user, err := userManager.User(r.Context(), state.NewIdentScreenName(screenName))
@@ -1500,15 +1515,36 @@ func deleteLinkedAccountHandler(w http.ResponseWriter, r *http.Request, userMana
 		errorMsg(w, "user not found", http.StatusNotFound)
 		errorMsg(w, "user not found", http.StatusNotFound)
 		return
 		return
 	}
 	}
-	err = linkedAccountManager.DeleteLinkedAccount(r.Context(), state.NewIdentScreenName(screenName), state.NewIdentScreenName(linkedScreenName))
-	if errors.Is(err, state.ErrNoUser) {
-		errorMsg(w, "linked account not found", http.StatusNotFound)
-		return
-	}
+
+	feedbag, err := manager.Feedbag(r.Context(), user.IdentScreenName)
 	if err != nil {
 	if err != nil {
 		errorMsg(w, "internal server error", http.StatusInternalServerError)
 		errorMsg(w, "internal server error", http.StatusInternalServerError)
 		return
 		return
 	}
 	}
+
+	fbList := state.NewFeedbagList(feedbag, intn)
+
+	if !fbList.HasLinkedScreenName(linkedScreenName) {
+		errorMsg(w, "linked account not found", http.StatusNotFound)
+		return
+	}
+	fbList.DeleteLinkedScreenName(linkedScreenName)
+
+	if pending := fbList.PendingDeletes(); len(pending) > 0 {
+		if err := manager.FeedbagDelete(r.Context(), user.IdentScreenName, pending); err != nil {
+			logger.Error("error inserting feedbag item", "err", err.Error())
+			errorMsg(w, "internal server error", http.StatusInternalServerError)
+			return
+		}
+	}
+	if pending := fbList.PendingUpdates(); len(pending) > 0 {
+		if err := manager.FeedbagUpsert(r.Context(), user.IdentScreenName, pending); err != nil {
+			logger.Error("error inserting feedbag item", "err", err.Error())
+			errorMsg(w, "internal server error", http.StatusInternalServerError)
+			return
+		}
+	}
+
 	w.WriteHeader(http.StatusNoContent)
 	w.WriteHeader(http.StatusNoContent)
 }
 }
 
 

+ 140 - 148
server/http/mgmt_api_test.go

@@ -5087,11 +5087,11 @@ func TestGetLinkedAccountsHandler(t *testing.T) {
 						},
 						},
 					},
 					},
 				},
 				},
-				linkedAccountManagerParams: linkedAccountManagerParams{
-					linkedAccountsParams: linkedAccountsParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
 						{
 						{
 							screenName: state.NewIdentScreenName("test1"),
 							screenName: state.NewIdentScreenName("test1"),
-							result:     []state.IdentScreenName{},
+							result:     []wire.FeedbagItem{},
 						},
 						},
 					},
 					},
 				},
 				},
@@ -5111,13 +5111,13 @@ func TestGetLinkedAccountsHandler(t *testing.T) {
 						},
 						},
 					},
 					},
 				},
 				},
-				linkedAccountManagerParams: linkedAccountManagerParams{
-					linkedAccountsParams: linkedAccountsParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
 						{
 						{
 							screenName: state.NewIdentScreenName("test1"),
 							screenName: state.NewIdentScreenName("test1"),
-							result: []state.IdentScreenName{
-								state.NewIdentScreenName("test2"),
-								state.NewIdentScreenName("test3"),
+							result: []wire.FeedbagItem{
+								{ClassID: wire.FeedbagClassIdAlInfo, Name: "test2"},
+								{ClassID: wire.FeedbagClassIdAlInfo, Name: "test3"},
 							},
 							},
 						},
 						},
 					},
 					},
@@ -5125,7 +5125,7 @@ func TestGetLinkedAccountsHandler(t *testing.T) {
 			},
 			},
 		},
 		},
 		{
 		{
-			name:       "linkedAccountManager error returns 500",
+			name:       "feedbag error returns 500",
 			screenname: "test1",
 			screenname: "test1",
 			statusCode: http.StatusInternalServerError,
 			statusCode: http.StatusInternalServerError,
 			want:       `{"message":"internal server error"}`,
 			want:       `{"message":"internal server error"}`,
@@ -5138,8 +5138,8 @@ func TestGetLinkedAccountsHandler(t *testing.T) {
 						},
 						},
 					},
 					},
 				},
 				},
-				linkedAccountManagerParams: linkedAccountManagerParams{
-					linkedAccountsParams: linkedAccountsParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
 						{
 						{
 							screenName: state.NewIdentScreenName("test1"),
 							screenName: state.NewIdentScreenName("test1"),
 							err:        io.EOF,
 							err:        io.EOF,
@@ -5163,14 +5163,14 @@ func TestGetLinkedAccountsHandler(t *testing.T) {
 					Return(params.result, params.err)
 					Return(params.result, params.err)
 			}
 			}
 
 
-			linkedAccountManager := newMockLinkedAccountManager(t)
-			for _, params := range tc.mockParams.linkedAccountsParams {
-				linkedAccountManager.EXPECT().
-					LinkedAccounts(matchContext(), params.screenName).
+			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tc.mockParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(matchContext(), params.screenName).
 					Return(params.result, params.err)
 					Return(params.result, params.err)
 			}
 			}
 
 
-			getLinkedAccountsHandler(responseRecorder, request, userManager, linkedAccountManager)
+			getLinkedAccountsHandler(responseRecorder, request, userManager, feedbagManager)
 
 
 			assert.Equal(t, tc.statusCode, responseRecorder.Code)
 			assert.Equal(t, tc.statusCode, responseRecorder.Code)
 			assert.Equal(t, tc.want, strings.TrimSpace(responseRecorder.Body.String()))
 			assert.Equal(t, tc.want, strings.TrimSpace(responseRecorder.Body.String()))
@@ -5295,12 +5295,13 @@ func TestPostLinkedAccountHandler(t *testing.T) {
 						},
 						},
 					},
 					},
 				},
 				},
-				linkedAccountManagerParams: linkedAccountManagerParams{
-					insertLinkedAccountParams: insertLinkedAccountParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
 						{
 						{
-							screenName:       state.NewIdentScreenName("test1"),
-							linkedScreenName: state.NewIdentScreenName("test2"),
-							err:              state.ErrLinkExists,
+							screenName: state.NewIdentScreenName("test1"),
+							result: []wire.FeedbagItem{
+								{ClassID: wire.FeedbagClassIdAlInfo, Name: "test2"},
+							},
 						},
 						},
 					},
 					},
 				},
 				},
@@ -5325,12 +5326,20 @@ func TestPostLinkedAccountHandler(t *testing.T) {
 						},
 						},
 					},
 					},
 				},
 				},
-				linkedAccountManagerParams: linkedAccountManagerParams{
-					insertLinkedAccountParams: insertLinkedAccountParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     []wire.FeedbagItem{},
+						},
+					},
+					feedbagUpsertParams: feedbagUpsertParams{
 						{
 						{
-							screenName:       state.NewIdentScreenName("test1"),
-							linkedScreenName: state.NewIdentScreenName("test2"),
-							err:              io.EOF,
+							screenName: state.NewIdentScreenName("test1"),
+							items: []wire.FeedbagItem{
+								{ItemID: 1, ClassID: wire.FeedbagClassIdAlInfo, Name: "test2"},
+							},
+							err: io.EOF,
 						},
 						},
 					},
 					},
 				},
 				},
@@ -5355,11 +5364,19 @@ func TestPostLinkedAccountHandler(t *testing.T) {
 						},
 						},
 					},
 					},
 				},
 				},
-				linkedAccountManagerParams: linkedAccountManagerParams{
-					insertLinkedAccountParams: insertLinkedAccountParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
 						{
 						{
-							screenName:       state.NewIdentScreenName("test1"),
-							linkedScreenName: state.NewIdentScreenName("test2"),
+							screenName: state.NewIdentScreenName("test1"),
+							result:     []wire.FeedbagItem{},
+						},
+					},
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							items: []wire.FeedbagItem{
+								{ItemID: 1, ClassID: wire.FeedbagClassIdAlInfo, Name: "test2"},
+							},
 						},
 						},
 					},
 					},
 				},
 				},
@@ -5380,14 +5397,19 @@ func TestPostLinkedAccountHandler(t *testing.T) {
 					Return(params.result, params.err)
 					Return(params.result, params.err)
 			}
 			}
 
 
-			linkedAccountManager := newMockLinkedAccountManager(t)
-			for _, params := range tc.mockParams.insertLinkedAccountParams {
-				linkedAccountManager.EXPECT().
-					InsertLinkedAccount(matchContext(), params.screenName, params.linkedScreenName).
+			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tc.mockParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(matchContext(), params.screenName).
+					Return(params.result, params.err)
+			}
+			for _, params := range tc.mockParams.feedbagUpsertParams {
+				feedbagManager.EXPECT().
+					FeedbagUpsert(matchContext(), params.screenName, params.items).
 					Return(params.err)
 					Return(params.err)
 			}
 			}
 
 
-			postLinkedAccountHandler(responseRecorder, request, userManager, linkedAccountManager)
+			postLinkedAccountHandler(responseRecorder, request, userManager, feedbagManager, slog.Default(), func(n int) int { return 0 })
 
 
 			assert.Equal(t, tc.statusCode, responseRecorder.Code)
 			assert.Equal(t, tc.statusCode, responseRecorder.Code)
 			assert.Equal(t, tc.want, strings.TrimSpace(responseRecorder.Body.String()))
 			assert.Equal(t, tc.want, strings.TrimSpace(responseRecorder.Body.String()))
@@ -5395,19 +5417,27 @@ func TestPostLinkedAccountHandler(t *testing.T) {
 	}
 	}
 }
 }
 
 
-func TestDeleteAllLinkedAccountsHandler(t *testing.T) {
+func TestDeleteLinkedAccountHandler(t *testing.T) {
+	linkedFeedbagItem := wire.FeedbagItem{
+		ItemID:  1,
+		ClassID: wire.FeedbagClassIdAlInfo,
+		Name:    "test2",
+	}
+
 	tt := []struct {
 	tt := []struct {
-		name       string
-		screenname string
-		statusCode int
-		want       string
-		mockParams mockParams
+		name             string
+		screenname       string
+		linkedScreenname string
+		statusCode       int
+		want             string
+		mockParams       mockParams
 	}{
 	}{
 		{
 		{
-			name:       "user not found returns 404",
-			screenname: "nobody",
-			statusCode: http.StatusNotFound,
-			want:       `{"message":"user not found"}`,
+			name:             "primary user not found returns 404",
+			screenname:       "nobody",
+			linkedScreenname: "test2",
+			statusCode:       http.StatusNotFound,
+			want:             `{"message":"user not found"}`,
 			mockParams: mockParams{
 			mockParams: mockParams{
 				userManagerParams: userManagerParams{
 				userManagerParams: userManagerParams{
 					getUserParams: getUserParams{
 					getUserParams: getUserParams{
@@ -5420,10 +5450,11 @@ func TestDeleteAllLinkedAccountsHandler(t *testing.T) {
 			},
 			},
 		},
 		},
 		{
 		{
-			name:       "delete error returns 500",
-			screenname: "test1",
-			statusCode: http.StatusInternalServerError,
-			want:       `{"message":"internal server error"}`,
+			name:             "linked account not found returns 404",
+			screenname:       "test1",
+			linkedScreenname: "ghost",
+			statusCode:       http.StatusNotFound,
+			want:             `{"message":"linked account not found"}`,
 			mockParams: mockParams{
 			mockParams: mockParams{
 				userManagerParams: userManagerParams{
 				userManagerParams: userManagerParams{
 					getUserParams: getUserParams{
 					getUserParams: getUserParams{
@@ -5433,21 +5464,22 @@ func TestDeleteAllLinkedAccountsHandler(t *testing.T) {
 						},
 						},
 					},
 					},
 				},
 				},
-				linkedAccountManagerParams: linkedAccountManagerParams{
-					deleteAllLinkedAccountParams: deleteAllLinkedAccountParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
 						{
 						{
 							screenName: state.NewIdentScreenName("test1"),
 							screenName: state.NewIdentScreenName("test1"),
-							err:        io.EOF,
+							result:     []wire.FeedbagItem{},
 						},
 						},
 					},
 					},
 				},
 				},
 			},
 			},
 		},
 		},
 		{
 		{
-			name:       "all links deleted returns 204",
-			screenname: "test1",
-			statusCode: http.StatusNoContent,
-			want:       ``,
+			name:             "delete error returns 500",
+			screenname:       "test1",
+			linkedScreenname: "test2",
+			statusCode:       http.StatusInternalServerError,
+			want:             `{"message":"internal server error"}`,
 			mockParams: mockParams{
 			mockParams: mockParams{
 				userManagerParams: userManagerParams{
 				userManagerParams: userManagerParams{
 					getUserParams: getUserParams{
 					getUserParams: getUserParams{
@@ -5457,77 +5489,29 @@ func TestDeleteAllLinkedAccountsHandler(t *testing.T) {
 						},
 						},
 					},
 					},
 				},
 				},
-				linkedAccountManagerParams: linkedAccountManagerParams{
-					deleteAllLinkedAccountParams: deleteAllLinkedAccountParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
 						{
 						{
 							screenName: state.NewIdentScreenName("test1"),
 							screenName: state.NewIdentScreenName("test1"),
+							result:     []wire.FeedbagItem{linkedFeedbagItem},
 						},
 						},
 					},
 					},
-				},
-			},
-		},
-	}
-
-	for _, tc := range tt {
-		t.Run(tc.name, func(t *testing.T) {
-			request := httptest.NewRequest(http.MethodDelete, "/user/"+tc.screenname+"/linked-account", nil)
-			request.SetPathValue("screenname", tc.screenname)
-			responseRecorder := httptest.NewRecorder()
-
-			userManager := newMockUserManager(t)
-			for _, params := range tc.mockParams.getUserParams {
-				userManager.EXPECT().
-					User(matchContext(), params.screenName).
-					Return(params.result, params.err)
-			}
-
-			linkedAccountManager := newMockLinkedAccountManager(t)
-			for _, params := range tc.mockParams.deleteAllLinkedAccountParams {
-				linkedAccountManager.EXPECT().
-					DeleteAllLinkedAccounts(matchContext(), params.screenName).
-					Return(params.err)
-			}
-
-			deleteAllLinkedAccountsHandler(responseRecorder, request, userManager, linkedAccountManager)
-
-			assert.Equal(t, tc.statusCode, responseRecorder.Code)
-			assert.Equal(t, tc.want, strings.TrimSpace(responseRecorder.Body.String()))
-		})
-	}
-}
-
-func TestDeleteLinkedAccountHandler(t *testing.T) {
-	tt := []struct {
-		name             string
-		screenname       string
-		linkedScreenname string
-		statusCode       int
-		want             string
-		mockParams       mockParams
-	}{
-		{
-			name:             "primary user not found returns 404",
-			screenname:       "nobody",
-			linkedScreenname: "test2",
-			statusCode:       http.StatusNotFound,
-			want:             `{"message":"user not found"}`,
-			mockParams: mockParams{
-				userManagerParams: userManagerParams{
-					getUserParams: getUserParams{
+					feedbagDeleteParams: feedbagDeleteParams{
 						{
 						{
-							screenName: state.NewIdentScreenName("nobody"),
-							result:     nil,
+							screenName: state.NewIdentScreenName("test1"),
+							items:      []wire.FeedbagItem{linkedFeedbagItem},
+							err:        io.EOF,
 						},
 						},
 					},
 					},
 				},
 				},
 			},
 			},
 		},
 		},
 		{
 		{
-			name:             "linked account not found returns 404",
+			name:             "upsert error after delete returns 500",
 			screenname:       "test1",
 			screenname:       "test1",
-			linkedScreenname: "ghost",
-			statusCode:       http.StatusNotFound,
-			want:             `{"message":"linked account not found"}`,
+			linkedScreenname: "test2",
+			statusCode:       http.StatusInternalServerError,
+			want:             `{"message":"internal server error"}`,
 			mockParams: mockParams{
 			mockParams: mockParams{
 				userManagerParams: userManagerParams{
 				userManagerParams: userManagerParams{
 					getUserParams: getUserParams{
 					getUserParams: getUserParams{
@@ -5537,38 +5521,24 @@ func TestDeleteLinkedAccountHandler(t *testing.T) {
 						},
 						},
 					},
 					},
 				},
 				},
-				linkedAccountManagerParams: linkedAccountManagerParams{
-					deleteLinkedAccountParams: deleteLinkedAccountParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
 						{
 						{
-							screenName:       state.NewIdentScreenName("test1"),
-							linkedScreenName: state.NewIdentScreenName("ghost"),
-							err:              state.ErrNoUser,
+							screenName: state.NewIdentScreenName("test1"),
+							result:     []wire.FeedbagItem{linkedFeedbagItem},
 						},
 						},
 					},
 					},
-				},
-			},
-		},
-		{
-			name:             "delete error returns 500",
-			screenname:       "test1",
-			linkedScreenname: "test2",
-			statusCode:       http.StatusInternalServerError,
-			want:             `{"message":"internal server error"}`,
-			mockParams: mockParams{
-				userManagerParams: userManagerParams{
-					getUserParams: getUserParams{
+					feedbagDeleteParams: feedbagDeleteParams{
 						{
 						{
 							screenName: state.NewIdentScreenName("test1"),
 							screenName: state.NewIdentScreenName("test1"),
-							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+							items:      []wire.FeedbagItem{linkedFeedbagItem},
 						},
 						},
 					},
 					},
-				},
-				linkedAccountManagerParams: linkedAccountManagerParams{
-					deleteLinkedAccountParams: deleteLinkedAccountParams{
+					feedbagUpsertParams: feedbagUpsertParams{
 						{
 						{
-							screenName:       state.NewIdentScreenName("test1"),
-							linkedScreenName: state.NewIdentScreenName("test2"),
-							err:              io.EOF,
+							screenName: state.NewIdentScreenName("test1"),
+							items:      []wire.FeedbagItem{{ClassID: wire.FeedbagClassIdGroup}},
+							err:        io.EOF,
 						},
 						},
 					},
 					},
 				},
 				},
@@ -5589,11 +5559,23 @@ func TestDeleteLinkedAccountHandler(t *testing.T) {
 						},
 						},
 					},
 					},
 				},
 				},
-				linkedAccountManagerParams: linkedAccountManagerParams{
-					deleteLinkedAccountParams: deleteLinkedAccountParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
 						{
 						{
-							screenName:       state.NewIdentScreenName("test1"),
-							linkedScreenName: state.NewIdentScreenName("test2"),
+							screenName: state.NewIdentScreenName("test1"),
+							result:     []wire.FeedbagItem{linkedFeedbagItem},
+						},
+					},
+					feedbagDeleteParams: feedbagDeleteParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							items:      []wire.FeedbagItem{linkedFeedbagItem},
+						},
+					},
+					feedbagUpsertParams: feedbagUpsertParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							items:      []wire.FeedbagItem{{ClassID: wire.FeedbagClassIdGroup}},
 						},
 						},
 					},
 					},
 				},
 				},
@@ -5615,14 +5597,24 @@ func TestDeleteLinkedAccountHandler(t *testing.T) {
 					Return(params.result, params.err)
 					Return(params.result, params.err)
 			}
 			}
 
 
-			linkedAccountManager := newMockLinkedAccountManager(t)
-			for _, params := range tc.mockParams.deleteLinkedAccountParams {
-				linkedAccountManager.EXPECT().
-					DeleteLinkedAccount(matchContext(), params.screenName, params.linkedScreenName).
+			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tc.mockParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(matchContext(), params.screenName).
+					Return(params.result, params.err)
+			}
+			for _, params := range tc.mockParams.feedbagDeleteParams {
+				feedbagManager.EXPECT().
+					FeedbagDelete(matchContext(), params.screenName, params.items).
+					Return(params.err)
+			}
+			for _, params := range tc.mockParams.feedbagUpsertParams {
+				feedbagManager.EXPECT().
+					FeedbagUpsert(matchContext(), params.screenName, params.items).
 					Return(params.err)
 					Return(params.err)
 			}
 			}
 
 
-			deleteLinkedAccountHandler(responseRecorder, request, userManager, linkedAccountManager)
+			deleteLinkedAccountHandler(responseRecorder, request, userManager, feedbagManager, slog.Default(), nil)
 
 
 			assert.Equal(t, tc.statusCode, responseRecorder.Code)
 			assert.Equal(t, tc.statusCode, responseRecorder.Code)
 			assert.Equal(t, tc.want, strings.TrimSpace(responseRecorder.Body.String()))
 			assert.Equal(t, tc.want, strings.TrimSpace(responseRecorder.Body.String()))

+ 0 - 290
server/http/mock_linked_account_manager_test.go

@@ -1,290 +0,0 @@
-// Code generated by mockery; DO NOT EDIT.
-// github.com/vektra/mockery
-// template: testify
-
-package http
-
-import (
-	"context"
-
-	"github.com/mk6i/open-oscar-server/state"
-	mock "github.com/stretchr/testify/mock"
-)
-
-// newMockLinkedAccountManager creates a new instance of mockLinkedAccountManager. 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 newMockLinkedAccountManager(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockLinkedAccountManager {
-	mock := &mockLinkedAccountManager{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}
-
-// mockLinkedAccountManager is an autogenerated mock type for the LinkedAccountManager type
-type mockLinkedAccountManager struct {
-	mock.Mock
-}
-
-type mockLinkedAccountManager_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockLinkedAccountManager) EXPECT() *mockLinkedAccountManager_Expecter {
-	return &mockLinkedAccountManager_Expecter{mock: &_m.Mock}
-}
-
-// DeleteAllLinkedAccounts provides a mock function for the type mockLinkedAccountManager
-func (_mock *mockLinkedAccountManager) DeleteAllLinkedAccounts(ctx context.Context, screenName state.IdentScreenName) error {
-	ret := _mock.Called(ctx, screenName)
-
-	if len(ret) == 0 {
-		panic("no return value specified for DeleteAllLinkedAccounts")
-	}
-
-	var r0 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) error); ok {
-		r0 = returnFunc(ctx, screenName)
-	} else {
-		r0 = ret.Error(0)
-	}
-	return r0
-}
-
-// mockLinkedAccountManager_DeleteAllLinkedAccounts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAllLinkedAccounts'
-type mockLinkedAccountManager_DeleteAllLinkedAccounts_Call struct {
-	*mock.Call
-}
-
-// DeleteAllLinkedAccounts is a helper method to define mock.On call
-//   - ctx context.Context
-//   - screenName state.IdentScreenName
-func (_e *mockLinkedAccountManager_Expecter) DeleteAllLinkedAccounts(ctx interface{}, screenName interface{}) *mockLinkedAccountManager_DeleteAllLinkedAccounts_Call {
-	return &mockLinkedAccountManager_DeleteAllLinkedAccounts_Call{Call: _e.mock.On("DeleteAllLinkedAccounts", ctx, screenName)}
-}
-
-func (_c *mockLinkedAccountManager_DeleteAllLinkedAccounts_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName)) *mockLinkedAccountManager_DeleteAllLinkedAccounts_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		var arg0 context.Context
-		if args[0] != nil {
-			arg0 = args[0].(context.Context)
-		}
-		var arg1 state.IdentScreenName
-		if args[1] != nil {
-			arg1 = args[1].(state.IdentScreenName)
-		}
-		run(
-			arg0,
-			arg1,
-		)
-	})
-	return _c
-}
-
-func (_c *mockLinkedAccountManager_DeleteAllLinkedAccounts_Call) Return(err error) *mockLinkedAccountManager_DeleteAllLinkedAccounts_Call {
-	_c.Call.Return(err)
-	return _c
-}
-
-func (_c *mockLinkedAccountManager_DeleteAllLinkedAccounts_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName) error) *mockLinkedAccountManager_DeleteAllLinkedAccounts_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// DeleteLinkedAccount provides a mock function for the type mockLinkedAccountManager
-func (_mock *mockLinkedAccountManager) DeleteLinkedAccount(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName) error {
-	ret := _mock.Called(ctx, screenName, linkedScreenName)
-
-	if len(ret) == 0 {
-		panic("no return value specified for DeleteLinkedAccount")
-	}
-
-	var r0 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, state.IdentScreenName) error); ok {
-		r0 = returnFunc(ctx, screenName, linkedScreenName)
-	} else {
-		r0 = ret.Error(0)
-	}
-	return r0
-}
-
-// mockLinkedAccountManager_DeleteLinkedAccount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteLinkedAccount'
-type mockLinkedAccountManager_DeleteLinkedAccount_Call struct {
-	*mock.Call
-}
-
-// DeleteLinkedAccount is a helper method to define mock.On call
-//   - ctx context.Context
-//   - screenName state.IdentScreenName
-//   - linkedScreenName state.IdentScreenName
-func (_e *mockLinkedAccountManager_Expecter) DeleteLinkedAccount(ctx interface{}, screenName interface{}, linkedScreenName interface{}) *mockLinkedAccountManager_DeleteLinkedAccount_Call {
-	return &mockLinkedAccountManager_DeleteLinkedAccount_Call{Call: _e.mock.On("DeleteLinkedAccount", ctx, screenName, linkedScreenName)}
-}
-
-func (_c *mockLinkedAccountManager_DeleteLinkedAccount_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName)) *mockLinkedAccountManager_DeleteLinkedAccount_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 *mockLinkedAccountManager_DeleteLinkedAccount_Call) Return(err error) *mockLinkedAccountManager_DeleteLinkedAccount_Call {
-	_c.Call.Return(err)
-	return _c
-}
-
-func (_c *mockLinkedAccountManager_DeleteLinkedAccount_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName) error) *mockLinkedAccountManager_DeleteLinkedAccount_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// InsertLinkedAccount provides a mock function for the type mockLinkedAccountManager
-func (_mock *mockLinkedAccountManager) InsertLinkedAccount(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName) error {
-	ret := _mock.Called(ctx, screenName, linkedScreenName)
-
-	if len(ret) == 0 {
-		panic("no return value specified for InsertLinkedAccount")
-	}
-
-	var r0 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, state.IdentScreenName) error); ok {
-		r0 = returnFunc(ctx, screenName, linkedScreenName)
-	} else {
-		r0 = ret.Error(0)
-	}
-	return r0
-}
-
-// mockLinkedAccountManager_InsertLinkedAccount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InsertLinkedAccount'
-type mockLinkedAccountManager_InsertLinkedAccount_Call struct {
-	*mock.Call
-}
-
-// InsertLinkedAccount is a helper method to define mock.On call
-//   - ctx context.Context
-//   - screenName state.IdentScreenName
-//   - linkedScreenName state.IdentScreenName
-func (_e *mockLinkedAccountManager_Expecter) InsertLinkedAccount(ctx interface{}, screenName interface{}, linkedScreenName interface{}) *mockLinkedAccountManager_InsertLinkedAccount_Call {
-	return &mockLinkedAccountManager_InsertLinkedAccount_Call{Call: _e.mock.On("InsertLinkedAccount", ctx, screenName, linkedScreenName)}
-}
-
-func (_c *mockLinkedAccountManager_InsertLinkedAccount_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName)) *mockLinkedAccountManager_InsertLinkedAccount_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 *mockLinkedAccountManager_InsertLinkedAccount_Call) Return(err error) *mockLinkedAccountManager_InsertLinkedAccount_Call {
-	_c.Call.Return(err)
-	return _c
-}
-
-func (_c *mockLinkedAccountManager_InsertLinkedAccount_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName) error) *mockLinkedAccountManager_InsertLinkedAccount_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// LinkedAccounts provides a mock function for the type mockLinkedAccountManager
-func (_mock *mockLinkedAccountManager) LinkedAccounts(ctx context.Context, screenName state.IdentScreenName) ([]state.IdentScreenName, error) {
-	ret := _mock.Called(ctx, screenName)
-
-	if len(ret) == 0 {
-		panic("no return value specified for LinkedAccounts")
-	}
-
-	var r0 []state.IdentScreenName
-	var r1 error
-	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) ([]state.IdentScreenName, error)); ok {
-		return returnFunc(ctx, screenName)
-	}
-	if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) []state.IdentScreenName); ok {
-		r0 = returnFunc(ctx, screenName)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).([]state.IdentScreenName)
-		}
-	}
-	if returnFunc, ok := ret.Get(1).(func(context.Context, state.IdentScreenName) error); ok {
-		r1 = returnFunc(ctx, screenName)
-	} else {
-		r1 = ret.Error(1)
-	}
-	return r0, r1
-}
-
-// mockLinkedAccountManager_LinkedAccounts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinkedAccounts'
-type mockLinkedAccountManager_LinkedAccounts_Call struct {
-	*mock.Call
-}
-
-// LinkedAccounts is a helper method to define mock.On call
-//   - ctx context.Context
-//   - screenName state.IdentScreenName
-func (_e *mockLinkedAccountManager_Expecter) LinkedAccounts(ctx interface{}, screenName interface{}) *mockLinkedAccountManager_LinkedAccounts_Call {
-	return &mockLinkedAccountManager_LinkedAccounts_Call{Call: _e.mock.On("LinkedAccounts", ctx, screenName)}
-}
-
-func (_c *mockLinkedAccountManager_LinkedAccounts_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName)) *mockLinkedAccountManager_LinkedAccounts_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		var arg0 context.Context
-		if args[0] != nil {
-			arg0 = args[0].(context.Context)
-		}
-		var arg1 state.IdentScreenName
-		if args[1] != nil {
-			arg1 = args[1].(state.IdentScreenName)
-		}
-		run(
-			arg0,
-			arg1,
-		)
-	})
-	return _c
-}
-
-func (_c *mockLinkedAccountManager_LinkedAccounts_Call) Return(identScreenNames []state.IdentScreenName, err error) *mockLinkedAccountManager_LinkedAccounts_Call {
-	_c.Call.Return(identScreenNames, err)
-	return _c
-}
-
-func (_c *mockLinkedAccountManager_LinkedAccounts_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName) ([]state.IdentScreenName, error)) *mockLinkedAccountManager_LinkedAccounts_Call {
-	_c.Call.Return(run)
-	return _c
-}

+ 0 - 8
server/http/types.go

@@ -195,14 +195,6 @@ type ICQProfileManager interface {
 	SetICQInfo(ctx context.Context, name state.IdentScreenName, info state.ICQInfo) error
 	SetICQInfo(ctx context.Context, name state.IdentScreenName, info state.ICQInfo) error
 }
 }
 
 
-// LinkedAccountManager manages linked account relationships.
-type LinkedAccountManager interface {
-	LinkedAccounts(ctx context.Context, screenName state.IdentScreenName) ([]state.IdentScreenName, error)
-	InsertLinkedAccount(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName) error
-	DeleteLinkedAccount(ctx context.Context, screenName state.IdentScreenName, linkedScreenName state.IdentScreenName) error
-	DeleteAllLinkedAccounts(ctx context.Context, screenName state.IdentScreenName) error
-}
-
 type userWithPassword struct {
 type userWithPassword struct {
 	ScreenName string `json:"screen_name"`
 	ScreenName string `json:"screen_name"`
 	Password   string `json:"password,omitempty"`
 	Password   string `json:"password,omitempty"`

+ 59 - 13
state/feedbag_list.go

@@ -57,19 +57,6 @@ func (f *FeedbagList) AddGroup(name string) wire.FeedbagItem {
 		return *g
 		return *g
 	}
 	}
 
 
-	var root *wire.FeedbagItem
-	for _, item := range f.items {
-		if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID == 0 {
-			root = item
-			break
-		}
-	}
-	if root == nil {
-		root = &wire.FeedbagItem{ClassID: wire.FeedbagClassIdGroup, GroupID: 0}
-		f.items = append(f.items, root)
-		f.trackUpdate(root)
-	}
-
 	group := &wire.FeedbagItem{
 	group := &wire.FeedbagItem{
 		ClassID: wire.FeedbagClassIdGroup,
 		ClassID: wire.FeedbagClassIdGroup,
 		Name:    name,
 		Name:    name,
@@ -77,6 +64,7 @@ func (f *FeedbagList) AddGroup(name string) wire.FeedbagItem {
 	}
 	}
 	f.items = append(f.items, group)
 	f.items = append(f.items, group)
 
 
+	root := f.rootGroup()
 	root.AppendOrderMembers(group.GroupID)
 	root.AppendOrderMembers(group.GroupID)
 	f.trackUpdate(root)
 	f.trackUpdate(root)
 	f.trackUpdate(group)
 	f.trackUpdate(group)
@@ -208,6 +196,46 @@ func (f *FeedbagList) DeleteDeny(screenName string) {
 	})
 	})
 }
 }
 
 
+// AddLinkedScreenName adds a linked screen name.
+func (f *FeedbagList) AddLinkedScreenName(screenName string) {
+	f.upsertItem(wire.FeedbagItem{
+		ClassID: wire.FeedbagClassIdAlInfo,
+		Name:    screenName,
+	})
+}
+
+// DeleteLinkedScreenName deletes a linked screen name.
+func (f *FeedbagList) DeleteLinkedScreenName(screenName string) {
+	_, deleted := f.deleteItem(wire.FeedbagItem{
+		ClassID: wire.FeedbagClassIdAlInfo,
+		Name:    screenName,
+	})
+
+	if deleted {
+		// touch the root group so that the client purges its local
+		// buddy list cache
+		f.trackUpdate(f.rootGroup())
+	}
+}
+
+// LinkedScreenNames returns all linked screen names in the feedbag.
+func (f *FeedbagList) LinkedScreenNames() []IdentScreenName {
+	var names []IdentScreenName
+	for _, item := range f.items {
+		if item.ClassID == wire.FeedbagClassIdAlInfo {
+			names = append(names, NewIdentScreenName(item.Name))
+		}
+	}
+	return names
+}
+
+// HasLinkedScreenName returns whether the feedbag has a linked screen name.
+func (f *FeedbagList) HasLinkedScreenName(screenName string) bool {
+	return slices.ContainsFunc(f.items, func(item *wire.FeedbagItem) bool {
+		return item.ClassID == wire.FeedbagClassIdAlInfo && item.Name == NewIdentScreenName(screenName).String()
+	})
+}
+
 // PendingUpdates returns items that were explicitly upserted via upsertItem
 // PendingUpdates returns items that were explicitly upserted via upsertItem
 // and items that were implicitly created or modified as side effects of other
 // and items that were implicitly created or modified as side effects of other
 // operations (e.g., group order updates from upsertItem, root group updates
 // operations (e.g., group order updates from upsertItem, root group updates
@@ -235,6 +263,23 @@ func (f *FeedbagList) PendingDeletes() []wire.FeedbagItem {
 	return result
 	return result
 }
 }
 
 
+// rootGroup retrieves the root group, creating one if non-existent.
+func (f *FeedbagList) rootGroup() *wire.FeedbagItem {
+	var root *wire.FeedbagItem
+	for _, item := range f.items {
+		if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID == 0 {
+			root = item
+			break
+		}
+	}
+	if root == nil {
+		root = &wire.FeedbagItem{ClassID: wire.FeedbagClassIdGroup, GroupID: 0}
+		f.items = append(f.items, root)
+		f.trackUpdate(root)
+	}
+	return root
+}
+
 // groupByName returns the group item with the given name, or nil if not found.
 // groupByName returns the group item with the given name, or nil if not found.
 func (f *FeedbagList) groupByName(name string) *wire.FeedbagItem {
 func (f *FeedbagList) groupByName(name string) *wire.FeedbagItem {
 	for _, item := range f.items {
 	for _, item := range f.items {
@@ -345,5 +390,6 @@ func (f *FeedbagList) genID() uint16 {
 func hasScreenName(classID uint16) bool {
 func hasScreenName(classID uint16) bool {
 	return classID == wire.FeedbagClassIdBuddy ||
 	return classID == wire.FeedbagClassIdBuddy ||
 		classID == wire.FeedbagClassIDPermit ||
 		classID == wire.FeedbagClassIDPermit ||
+		classID == wire.FeedbagClassIdAlInfo ||
 		classID == wire.FeedbagClassIDDeny
 		classID == wire.FeedbagClassIDDeny
 }
 }

+ 326 - 0
state/feedbag_list_test.go

@@ -471,6 +471,31 @@ func TestFeedbagList_AddGroup(t *testing.T) {
 	})
 	})
 }
 }
 
 
+func TestFeedbagList_SetMode(t *testing.T) {
+	t.Run("upserts pdinfo item with mode TLV", func(t *testing.T) {
+		fl := NewFeedbagList(nil, func(n int) int { return 0 })
+		fl.SetMode(2)
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.Equal(t, wire.FeedbagClassIdPdinfo, upserts[0].ClassID)
+		mode, ok := upserts[0].Uint8(wire.FeedbagAttributesPdMode)
+		assert.True(t, ok)
+		assert.Equal(t, uint8(2), mode)
+	})
+
+	t.Run("second SetMode updates existing item in place", func(t *testing.T) {
+		fl := NewFeedbagList(nil, func(n int) int { return 0 })
+		fl.SetMode(1)
+		_ = fl.PendingUpdates()
+		fl.SetMode(3)
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		mode, ok := upserts[0].Uint8(wire.FeedbagAttributesPdMode)
+		assert.True(t, ok)
+		assert.Equal(t, uint8(3), mode)
+	})
+}
+
 func TestFeedbagList_DeleteGroup(t *testing.T) {
 func TestFeedbagList_DeleteGroup(t *testing.T) {
 	t.Run("updates root group order", func(t *testing.T) {
 	t.Run("updates root group order", func(t *testing.T) {
 		fl := NewFeedbagList([]wire.FeedbagItem{
 		fl := NewFeedbagList([]wire.FeedbagItem{
@@ -529,6 +554,13 @@ func TestFeedbagList_DeleteGroup(t *testing.T) {
 		assert.True(t, ok)
 		assert.True(t, ok)
 		assert.Equal(t, []uint16{1, 3}, order)
 		assert.Equal(t, []uint16{1, 3}, order)
 	})
 	})
+
+	t.Run("deleting non-existent group is a no-op", func(t *testing.T) {
+		fl := NewFeedbagList(nil, nil)
+		fl.DeleteGroup("Nonexistent")
+		assert.Empty(t, fl.PendingDeletes())
+		assert.Nil(t, fl.PendingUpdates())
+	})
 }
 }
 
 
 func TestFeedbagList_AddBuddy(t *testing.T) {
 func TestFeedbagList_AddBuddy(t *testing.T) {
@@ -630,6 +662,46 @@ func TestFeedbagList_AddBuddy(t *testing.T) {
 		assert.Len(t, deletes, 1)
 		assert.Len(t, deletes, 1)
 		assert.Equal(t, "alice", deletes[0].Name)
 		assert.Equal(t, "alice", deletes[0].Name)
 	})
 	})
+
+	t.Run("alias is stored as TLV attribute", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+		}, func(n int) int { return 1 })
+		_, err := fl.AddBuddy("Buddies", "alice", "Al", "")
+		assert.NoError(t, err)
+		upserts := fl.PendingUpdates()
+		var buddy *wire.FeedbagItem
+		for i := range upserts {
+			if upserts[i].ClassID == wire.FeedbagClassIdBuddy {
+				buddy = &upserts[i]
+				break
+			}
+		}
+		assert.NotNil(t, buddy)
+		alias, ok := buddy.Bytes(wire.FeedbagAttributesAlias)
+		assert.True(t, ok)
+		assert.Equal(t, []byte("Al"), alias)
+	})
+
+	t.Run("note is stored as TLV attribute", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+		}, func(n int) int { return 1 })
+		_, err := fl.AddBuddy("Buddies", "alice", "", "call first")
+		assert.NoError(t, err)
+		upserts := fl.PendingUpdates()
+		var buddy *wire.FeedbagItem
+		for i := range upserts {
+			if upserts[i].ClassID == wire.FeedbagClassIdBuddy {
+				buddy = &upserts[i]
+				break
+			}
+		}
+		assert.NotNil(t, buddy)
+		note, ok := buddy.Bytes(wire.FeedbagAttributesNote)
+		assert.True(t, ok)
+		assert.Equal(t, []byte("call first"), note)
+	})
 }
 }
 
 
 func TestFeedbagList_DeleteBuddy(t *testing.T) {
 func TestFeedbagList_DeleteBuddy(t *testing.T) {
@@ -665,6 +737,50 @@ func TestFeedbagList_DeleteBuddy(t *testing.T) {
 		assert.Equal(t, []uint16{10, 30}, order)
 		assert.Equal(t, []uint16{10, 30}, order)
 	})
 	})
 
 
+	t.Run("wildcard removes buddy from all groups", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{
+				Name:    "Buddies",
+				ClassID: wire.FeedbagClassIdGroup,
+				GroupID: 1,
+				TLVLBlock: wire.TLVLBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{10, 20}),
+					},
+				},
+			},
+			{
+				Name:    "Coworkers",
+				ClassID: wire.FeedbagClassIdGroup,
+				GroupID: 2,
+				TLVLBlock: wire.TLVLBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{30, 40}),
+					},
+				},
+			},
+			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 10},
+			{Name: "bob", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 20},
+			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 2, ItemID: 30},
+			{Name: "charlie", ClassID: wire.FeedbagClassIdBuddy, GroupID: 2, ItemID: 40},
+		}, nil)
+
+		err := fl.DeleteBuddy("*", "alice")
+		assert.NoError(t, err)
+
+		deletes := fl.PendingDeletes()
+		assert.Len(t, deletes, 2)
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 2)
+		order1, ok := upserts[0].Uint16SliceBE(wire.FeedbagAttributesOrder)
+		assert.True(t, ok)
+		assert.Equal(t, []uint16{20}, order1)
+		order2, ok := upserts[1].Uint16SliceBE(wire.FeedbagAttributesOrder)
+		assert.True(t, ok)
+		assert.Equal(t, []uint16{40}, order2)
+	})
+
 	t.Run("removes only buddy in specified group when same screen name in two groups", func(t *testing.T) {
 	t.Run("removes only buddy in specified group when same screen name in two groups", func(t *testing.T) {
 		fl := NewFeedbagList([]wire.FeedbagItem{
 		fl := NewFeedbagList([]wire.FeedbagItem{
 			{
 			{
@@ -773,3 +889,213 @@ func TestFeedbagList_PendingUpdates_upsertsOnly(t *testing.T) {
 		assert.Len(t, upserts, 2)
 		assert.Len(t, upserts, 2)
 	})
 	})
 }
 }
+
+func TestFeedbagList_PermitUser(t *testing.T) {
+	t.Run("new permit entry is added", func(t *testing.T) {
+		fl := NewFeedbagList(nil, func(n int) int { return 0 })
+		fl.PermitUser("alice")
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.Equal(t, wire.FeedbagClassIDPermit, upserts[0].ClassID)
+		assert.Equal(t, "alice", upserts[0].Name)
+	})
+
+	t.Run("duplicate permit is not re-added", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{ClassID: wire.FeedbagClassIDPermit, Name: "alice", ItemID: 1},
+		}, nil)
+		fl.PermitUser("alice")
+		assert.Nil(t, fl.PendingUpdates())
+	})
+
+	t.Run("name is normalized", func(t *testing.T) {
+		fl := NewFeedbagList(nil, func(n int) int { return 0 })
+		fl.PermitUser("Alice")
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.Equal(t, "alice", upserts[0].Name)
+	})
+}
+
+func TestFeedbagList_DenyUser(t *testing.T) {
+	t.Run("new deny entry is added", func(t *testing.T) {
+		fl := NewFeedbagList(nil, func(n int) int { return 0 })
+		fl.DenyUser("alice")
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.Equal(t, wire.FeedbagClassIDDeny, upserts[0].ClassID)
+		assert.Equal(t, "alice", upserts[0].Name)
+	})
+
+	t.Run("duplicate deny is not re-added", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{ClassID: wire.FeedbagClassIDDeny, Name: "alice", ItemID: 1},
+		}, nil)
+		fl.DenyUser("alice")
+		assert.Nil(t, fl.PendingUpdates())
+	})
+
+	t.Run("name is normalized", func(t *testing.T) {
+		fl := NewFeedbagList(nil, func(n int) int { return 0 })
+		fl.DenyUser("Alice")
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.Equal(t, "alice", upserts[0].Name)
+	})
+}
+
+func TestFeedbagList_DeletePermit(t *testing.T) {
+	t.Run("existing permit is deleted", func(t *testing.T) {
+		item := wire.FeedbagItem{ClassID: wire.FeedbagClassIDPermit, Name: "alice", ItemID: 1}
+		fl := NewFeedbagList([]wire.FeedbagItem{item}, nil)
+		fl.DeletePermit("alice")
+		deletes := fl.PendingDeletes()
+		assert.Len(t, deletes, 1)
+		assert.Equal(t, item, deletes[0])
+	})
+
+	t.Run("deleting non-existent permit is a no-op", func(t *testing.T) {
+		fl := NewFeedbagList(nil, nil)
+		fl.DeletePermit("alice")
+		assert.Empty(t, fl.PendingDeletes())
+	})
+
+	t.Run("name comparison is case-insensitive", func(t *testing.T) {
+		item := wire.FeedbagItem{ClassID: wire.FeedbagClassIDPermit, Name: "alice", ItemID: 1}
+		fl := NewFeedbagList([]wire.FeedbagItem{item}, nil)
+		fl.DeletePermit("Alice")
+		assert.Len(t, fl.PendingDeletes(), 1)
+	})
+}
+
+func TestFeedbagList_DeleteDeny(t *testing.T) {
+	t.Run("existing deny is deleted", func(t *testing.T) {
+		item := wire.FeedbagItem{ClassID: wire.FeedbagClassIDDeny, Name: "alice", ItemID: 1}
+		fl := NewFeedbagList([]wire.FeedbagItem{item}, nil)
+		fl.DeleteDeny("alice")
+		deletes := fl.PendingDeletes()
+		assert.Len(t, deletes, 1)
+		assert.Equal(t, item, deletes[0])
+	})
+
+	t.Run("deleting non-existent deny is a no-op", func(t *testing.T) {
+		fl := NewFeedbagList(nil, nil)
+		fl.DeleteDeny("alice")
+		assert.Empty(t, fl.PendingDeletes())
+	})
+
+	t.Run("name comparison is case-insensitive", func(t *testing.T) {
+		item := wire.FeedbagItem{ClassID: wire.FeedbagClassIDDeny, Name: "alice", ItemID: 1}
+		fl := NewFeedbagList([]wire.FeedbagItem{item}, nil)
+		fl.DeleteDeny("Alice")
+		assert.Len(t, fl.PendingDeletes(), 1)
+	})
+}
+
+func TestFeedbagList_AddLinkedScreenName(t *testing.T) {
+	t.Run("new linked screen name is added", func(t *testing.T) {
+		fl := NewFeedbagList(nil, func(n int) int { return 0 })
+		fl.AddLinkedScreenName("alice")
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.Equal(t, wire.FeedbagClassIdAlInfo, upserts[0].ClassID)
+		assert.Equal(t, "alice", upserts[0].Name)
+	})
+
+	t.Run("duplicate linked screen name is not re-added", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{ClassID: wire.FeedbagClassIdAlInfo, Name: "alice", ItemID: 1},
+		}, nil)
+		fl.AddLinkedScreenName("alice")
+		assert.Nil(t, fl.PendingUpdates())
+	})
+
+	t.Run("name is normalized", func(t *testing.T) {
+		fl := NewFeedbagList(nil, func(n int) int { return 0 })
+		fl.AddLinkedScreenName("Alice")
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.Equal(t, "alice", upserts[0].Name)
+	})
+}
+
+func TestFeedbagList_DeleteLinkedScreenName(t *testing.T) {
+	t.Run("existing linked screen name is deleted, root group created and pending", func(t *testing.T) {
+		item := wire.FeedbagItem{ClassID: wire.FeedbagClassIdAlInfo, Name: "alice", ItemID: 1}
+		fl := NewFeedbagList([]wire.FeedbagItem{item}, nil)
+		fl.DeleteLinkedScreenName("alice")
+		deletes := fl.PendingDeletes()
+		assert.Len(t, deletes, 1)
+		assert.Equal(t, item, deletes[0])
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.Equal(t, wire.FeedbagClassIdGroup, upserts[0].ClassID)
+		assert.Equal(t, uint16(0), upserts[0].GroupID)
+	})
+
+	t.Run("existing root group is touched on delete", func(t *testing.T) {
+		root := wire.FeedbagItem{ClassID: wire.FeedbagClassIdGroup, GroupID: 0, ItemID: 1}
+		item := wire.FeedbagItem{ClassID: wire.FeedbagClassIdAlInfo, Name: "alice", ItemID: 2}
+		fl := NewFeedbagList([]wire.FeedbagItem{root, item}, nil)
+		fl.DeleteLinkedScreenName("alice")
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.Equal(t, wire.FeedbagClassIdGroup, upserts[0].ClassID)
+		assert.Equal(t, uint16(0), upserts[0].GroupID)
+	})
+
+	t.Run("deleting non-existent linked screen name is a no-op", func(t *testing.T) {
+		fl := NewFeedbagList(nil, nil)
+		fl.DeleteLinkedScreenName("alice")
+		assert.Empty(t, fl.PendingDeletes())
+		assert.Nil(t, fl.PendingUpdates())
+	})
+
+	t.Run("name comparison is case-insensitive", func(t *testing.T) {
+		item := wire.FeedbagItem{ClassID: wire.FeedbagClassIdAlInfo, Name: "alice", ItemID: 1}
+		fl := NewFeedbagList([]wire.FeedbagItem{item}, nil)
+		fl.DeleteLinkedScreenName("Alice")
+		assert.Len(t, fl.PendingDeletes(), 1)
+	})
+}
+
+func TestFeedbagList_LinkedScreenNames(t *testing.T) {
+	t.Run("returns all linked screen names", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{ClassID: wire.FeedbagClassIdAlInfo, Name: "alice", ItemID: 1},
+			{ClassID: wire.FeedbagClassIdAlInfo, Name: "bob", ItemID: 2},
+			{ClassID: wire.FeedbagClassIdBuddy, Name: "carol", ItemID: 3},
+		}, nil)
+		names := fl.LinkedScreenNames()
+		assert.Equal(t, []IdentScreenName{
+			NewIdentScreenName("alice"),
+			NewIdentScreenName("bob"),
+		}, names)
+	})
+
+	t.Run("returns nil when no linked screen names exist", func(t *testing.T) {
+		fl := NewFeedbagList(nil, nil)
+		assert.Nil(t, fl.LinkedScreenNames())
+	})
+}
+
+func TestFeedbagList_HasLinkedScreenName(t *testing.T) {
+	t.Run("returns true when linked screen name exists", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{ClassID: wire.FeedbagClassIdAlInfo, Name: "alice", ItemID: 1},
+		}, nil)
+		assert.True(t, fl.HasLinkedScreenName("alice"))
+	})
+
+	t.Run("returns false when linked screen name does not exist", func(t *testing.T) {
+		fl := NewFeedbagList(nil, nil)
+		assert.False(t, fl.HasLinkedScreenName("alice"))
+	})
+
+	t.Run("match is case-insensitive", func(t *testing.T) {
+		fl := NewFeedbagList([]wire.FeedbagItem{
+			{ClassID: wire.FeedbagClassIdAlInfo, Name: "alice", ItemID: 1},
+		}, nil)
+		assert.True(t, fl.HasLinkedScreenName("Alice"))
+	})
+}

+ 9 - 0
state/migrations/0037_drop_linked_accounts.down.sql

@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS linkedAccounts
+(
+    identScreenName     VARCHAR(16),
+    linkedScreenName    VARCHAR(16),
+    PRIMARY KEY (identScreenName, linkedScreenName),
+    CHECK (identScreenName != linkedScreenName),
+    FOREIGN KEY (identScreenName) REFERENCES users (identScreenName) ON DELETE CASCADE,
+    FOREIGN KEY (linkedScreenName) REFERENCES users (identScreenName) ON DELETE CASCADE
+);

+ 1 - 0
state/migrations/0037_drop_linked_accounts.up.sql

@@ -0,0 +1 @@
+DROP TABLE IF EXISTS linkedAccounts;

+ 0 - 2
state/user.go

@@ -19,8 +19,6 @@ var (
 	ErrNoUser = errors.New("user does not exist")
 	ErrNoUser = errors.New("user does not exist")
 	// ErrNoEmailAddress indicates that a user has not set an email address.
 	// ErrNoEmailAddress indicates that a user has not set an email address.
 	ErrNoEmailAddress = errors.New("user has no email address")
 	ErrNoEmailAddress = errors.New("user has no email address")
-	// ErrLinkExists indicates that a linked account relationship already exists.
-	ErrLinkExists = errors.New("linked account relationship already exists")
 )
 )
 
 
 // IdentScreenName struct stores the normalized version of a user's screen name.
 // IdentScreenName struct stores the normalized version of a user's screen name.

+ 1 - 97
state/user_store.go

@@ -12,7 +12,6 @@ import (
 	"math"
 	"math"
 	"net/http"
 	"net/http"
 	"net/mail"
 	"net/mail"
-	"slices"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
 	"time"
 	"time"
@@ -1032,6 +1031,7 @@ func (f SQLiteUserStore) FeedbagUpsert(ctx context.Context, screenName IdentScre
 
 
 		if item.ClassID == wire.FeedbagClassIdBuddy ||
 		if item.ClassID == wire.FeedbagClassIdBuddy ||
 			item.ClassID == wire.FeedbagClassIDPermit ||
 			item.ClassID == wire.FeedbagClassIDPermit ||
+			item.ClassID == wire.FeedbagClassIdAlInfo ||
 			item.ClassID == wire.FeedbagClassIDDeny {
 			item.ClassID == wire.FeedbagClassIDDeny {
 			// insert screen name identifier
 			// insert screen name identifier
 			item.Name = NewIdentScreenName(item.Name).String()
 			item.Name = NewIdentScreenName(item.Name).String()
@@ -2682,99 +2682,3 @@ func (f SQLiteUserStore) SetOfflineMsgCount(ctx context.Context, screenName Iden
 	}
 	}
 	return nil
 	return nil
 }
 }
-
-// LinkedAccounts returns the linked accounts associated with a screenname
-func (f SQLiteUserStore) LinkedAccounts(ctx context.Context, screenName IdentScreenName) ([]IdentScreenName, error) {
-	q := `
-		SELECT linkedScreenName
-		FROM linkedAccounts
-		WHERE identScreenName = ?
-	`
-	rows, err := f.db.QueryContext(ctx, q, screenName.String())
-	if err != nil {
-		return nil, err
-	}
-	defer rows.Close()
-
-	var items []IdentScreenName
-	for rows.Next() {
-		var sn string
-		err := rows.Scan(&sn)
-		if err != nil {
-			return nil, err
-		}
-		items = append(items, NewIdentScreenName(sn))
-	}
-
-	if err := rows.Err(); err != nil {
-		return nil, err
-	}
-	return items, nil
-}
-
-// CheckLinkedAccount reports whether the proposed screenName->linkedScreenName relationship is valid.
-func (f SQLiteUserStore) CheckLinkedAccount(ctx context.Context, screenName IdentScreenName, linkedScreenName IdentScreenName) (bool, error) {
-	items, err := f.LinkedAccounts(ctx, screenName)
-	if err != nil {
-		return false, err
-	}
-	if slices.Contains(items, linkedScreenName) {
-		return true, nil
-	}
-	return false, nil
-}
-
-// InsertLinkedAccount inserts a new linked account relationship
-func (f SQLiteUserStore) InsertLinkedAccount(ctx context.Context, identScreenName, linkedScreenName IdentScreenName) error {
-	q := `
-		INSERT INTO linkedAccounts (identScreenName, linkedScreenName)
-		VALUES (?, ?)
-		ON CONFLICT (identScreenName, linkedScreenName) DO NOTHING
-	`
-	result, err := f.db.ExecContext(ctx,
-		q,
-		identScreenName.String(),
-		linkedScreenName.String(),
-	)
-	if err != nil {
-		return err
-	}
-
-	rowsAffected, err := result.RowsAffected()
-	if err != nil {
-		return err
-	}
-	if rowsAffected == 0 {
-		return ErrLinkExists
-	}
-	return nil
-}
-
-// DeleteAllLinkedAccounts deletes all linked account relationships for a primary screen name.
-func (f SQLiteUserStore) DeleteAllLinkedAccounts(ctx context.Context, identScreenName IdentScreenName) error {
-	q := `DELETE FROM linkedAccounts WHERE identScreenName = ?`
-	_, err := f.db.ExecContext(ctx, q, identScreenName.String())
-	return err
-}
-
-// DeleteLinkedAccount deletes an existing linked account relationship
-func (f SQLiteUserStore) DeleteLinkedAccount(ctx context.Context, identScreenName, linkedScreenName IdentScreenName) error {
-	q := `
-		DELETE FROM linkedAccounts
-		WHERE identScreenName = ? AND linkedScreenName = ?
-	`
-	result, err := f.db.ExecContext(ctx, q, identScreenName.String(), linkedScreenName.String())
-	if err != nil {
-		return err
-	}
-
-	rowsAffected, err := result.RowsAffected()
-	if err != nil {
-		return err
-	}
-	if rowsAffected == 0 {
-		return ErrNoUser
-	}
-
-	return nil
-}

+ 0 - 187
state/user_store_test.go

@@ -4287,193 +4287,6 @@ func TestSQLiteUserStore_ContactPreAuth(t *testing.T) {
 	assert.Equal(t, 1, count)
 	assert.Equal(t, 1, count)
 }
 }
 
 
-func TestSQLiteUserStore_LinkedAccounts_ReturnsLinkedAccounts(t *testing.T) {
-	defer func() {
-		assert.NoError(t, os.Remove(testFile))
-	}()
-
-	f, err := NewSQLiteUserStore(testFile)
-	require.NoError(t, err)
-
-	ctx := context.Background()
-	require.NoError(t, f.InsertUser(ctx, User{
-		IdentScreenName:   NewIdentScreenName("primary"),
-		DisplayScreenName: "Primary",
-	}))
-	require.NoError(t, f.InsertUser(ctx, User{
-		IdentScreenName:   NewIdentScreenName("linked1"),
-		DisplayScreenName: "Linked1",
-	}))
-	require.NoError(t, f.InsertUser(ctx, User{
-		IdentScreenName:   NewIdentScreenName("linked2"),
-		DisplayScreenName: "Linked2",
-	}))
-	require.NoError(t, f.InsertLinkedAccount(ctx, NewIdentScreenName("primary"), NewIdentScreenName("linked1")))
-	require.NoError(t, f.InsertLinkedAccount(ctx, NewIdentScreenName("primary"), NewIdentScreenName("linked2")))
-
-	have, err := f.LinkedAccounts(ctx, NewIdentScreenName("primary"))
-	require.NoError(t, err)
-
-	want := []IdentScreenName{NewIdentScreenName("linked1"), NewIdentScreenName("linked2")}
-	assert.Equal(t, want, have)
-}
-
-func TestSQLiteUserStore_LinkedAccounts_ReturnsEmptyList(t *testing.T) {
-	defer func() {
-		assert.NoError(t, os.Remove(testFile))
-	}()
-
-	f, err := NewSQLiteUserStore(testFile)
-	require.NoError(t, err)
-
-	ctx := context.Background()
-	require.NoError(t, f.InsertUser(ctx, User{
-		IdentScreenName:   NewIdentScreenName("primary"),
-		DisplayScreenName: "Primary",
-	}))
-
-	have, err := f.LinkedAccounts(ctx, NewIdentScreenName("primary"))
-	require.NoError(t, err)
-	assert.Empty(t, have)
-}
-
-func TestSQLiteUserStore_CheckLinkedAccount_True(t *testing.T) {
-	defer func() {
-		assert.NoError(t, os.Remove(testFile))
-	}()
-
-	f, err := NewSQLiteUserStore(testFile)
-	require.NoError(t, err)
-
-	ctx := context.Background()
-	require.NoError(t, f.InsertUser(ctx, User{
-		IdentScreenName:   NewIdentScreenName("primary"),
-		DisplayScreenName: "Primary",
-	}))
-	require.NoError(t, f.InsertUser(ctx, User{
-		IdentScreenName:   NewIdentScreenName("linked1"),
-		DisplayScreenName: "Linked1",
-	}))
-	require.NoError(t, f.InsertLinkedAccount(ctx, NewIdentScreenName("primary"), NewIdentScreenName("linked1")))
-
-	linked, err := f.CheckLinkedAccount(ctx, NewIdentScreenName("primary"), NewIdentScreenName("linked1"))
-	require.NoError(t, err)
-	assert.True(t, linked)
-}
-
-func TestSQLiteUserStore_CheckLinkedAccount_False(t *testing.T) {
-	defer func() {
-		assert.NoError(t, os.Remove(testFile))
-	}()
-
-	f, err := NewSQLiteUserStore(testFile)
-	require.NoError(t, err)
-
-	ctx := context.Background()
-	require.NoError(t, f.InsertUser(ctx, User{
-		IdentScreenName:   NewIdentScreenName("primary"),
-		DisplayScreenName: "Primary",
-	}))
-
-	linked, err := f.CheckLinkedAccount(ctx, NewIdentScreenName("primary"), NewIdentScreenName("ghost"))
-	require.NoError(t, err)
-	assert.False(t, linked)
-}
-
-func TestSQLiteUserStore_InsertLinkedAccount_Success(t *testing.T) {
-	defer func() {
-		assert.NoError(t, os.Remove(testFile))
-	}()
-
-	f, err := NewSQLiteUserStore(testFile)
-	require.NoError(t, err)
-
-	ctx := context.Background()
-	require.NoError(t, f.InsertUser(ctx, User{
-		IdentScreenName:   NewIdentScreenName("primary"),
-		DisplayScreenName: "Primary",
-	}))
-	require.NoError(t, f.InsertUser(ctx, User{
-		IdentScreenName:   NewIdentScreenName("linked1"),
-		DisplayScreenName: "Linked1",
-	}))
-
-	err = f.InsertLinkedAccount(ctx, NewIdentScreenName("primary"), NewIdentScreenName("linked1"))
-	require.NoError(t, err)
-
-	accounts, err := f.LinkedAccounts(ctx, NewIdentScreenName("primary"))
-	require.NoError(t, err)
-	assert.Equal(t, []IdentScreenName{NewIdentScreenName("linked1")}, accounts)
-}
-
-func TestSQLiteUserStore_InsertLinkedAccount_Duplicate(t *testing.T) {
-	defer func() {
-		assert.NoError(t, os.Remove(testFile))
-	}()
-
-	f, err := NewSQLiteUserStore(testFile)
-	require.NoError(t, err)
-
-	ctx := context.Background()
-	require.NoError(t, f.InsertUser(ctx, User{
-		IdentScreenName:   NewIdentScreenName("primary"),
-		DisplayScreenName: "Primary",
-	}))
-	require.NoError(t, f.InsertUser(ctx, User{
-		IdentScreenName:   NewIdentScreenName("linked1"),
-		DisplayScreenName: "Linked1",
-	}))
-
-	require.NoError(t, f.InsertLinkedAccount(ctx, NewIdentScreenName("primary"), NewIdentScreenName("linked1")))
-	err = f.InsertLinkedAccount(ctx, NewIdentScreenName("primary"), NewIdentScreenName("linked1"))
-	assert.ErrorIs(t, ErrLinkExists, err)
-}
-
-func TestSQLiteUserStore_DeleteLinkedAccount_Success(t *testing.T) {
-	defer func() {
-		assert.NoError(t, os.Remove(testFile))
-	}()
-
-	f, err := NewSQLiteUserStore(testFile)
-	require.NoError(t, err)
-
-	ctx := context.Background()
-	require.NoError(t, f.InsertUser(ctx, User{
-		IdentScreenName:   NewIdentScreenName("primary"),
-		DisplayScreenName: "Primary",
-	}))
-	require.NoError(t, f.InsertUser(ctx, User{
-		IdentScreenName:   NewIdentScreenName("linked1"),
-		DisplayScreenName: "Linked1",
-	}))
-	require.NoError(t, f.InsertLinkedAccount(ctx, NewIdentScreenName("primary"), NewIdentScreenName("linked1")))
-
-	err = f.DeleteLinkedAccount(ctx, NewIdentScreenName("primary"), NewIdentScreenName("linked1"))
-	require.NoError(t, err)
-
-	accounts, err := f.LinkedAccounts(ctx, NewIdentScreenName("primary"))
-	require.NoError(t, err)
-	assert.Empty(t, accounts)
-}
-
-func TestSQLiteUserStore_DeleteLinkedAccount_NotFound(t *testing.T) {
-	defer func() {
-		assert.NoError(t, os.Remove(testFile))
-	}()
-
-	f, err := NewSQLiteUserStore(testFile)
-	require.NoError(t, err)
-
-	ctx := context.Background()
-	require.NoError(t, f.InsertUser(ctx, User{
-		IdentScreenName:   NewIdentScreenName("primary"),
-		DisplayScreenName: "Primary",
-	}))
-
-	err = f.DeleteLinkedAccount(ctx, NewIdentScreenName("primary"), NewIdentScreenName("ghost"))
-	assert.ErrorIs(t, ErrNoUser, err)
-}
-
 func TestSQLiteUserStore_RecordPreAuth_unknownUser(t *testing.T) {
 func TestSQLiteUserStore_RecordPreAuth_unknownUser(t *testing.T) {
 	ctx := context.Background()
 	ctx := context.Background()
 	defer func() {
 	defer func() {