Browse Source

Add linked account support

 - Mgmt API support for adding, deleting, and listing linked screennames
 - Dynamically build FeedbagClassIdAlInfo for Query and QueryIfModified
   containing the linked names
 - Ignore FeedbagUpsert/Delete for ClassIdAlInfo items. Only the REST api
   may be used to manage these
 - Linked account signon cookie inherits the primary's MultiConnFlag
 - Database table for the relationships
 - Auth BOS response includes linked account XML (OServiceTLVTagsLinkedAccounts)
   for clients that read it during the login flow
Josh Knight 1 month ago
parent
commit
9f0a8398df

+ 6 - 0
.mockery.yaml

@@ -107,6 +107,9 @@ packages:
       UserManager:
         config:
           filename: "mock_user_manager_test.go"
+      LinkedAccountManager:
+        config:
+          filename: "mock_linked_account_manager_test.go"
   github.com/mk6i/open-oscar-server/foodgroup:
     interfaces:
       AccountManager:
@@ -169,6 +172,9 @@ packages:
       UserManager:
         config:
           filename: "mock_user_manager_manager_test.go"
+      LinkedAccountManager:
+        config:
+          filename: "mock_linked_account_manager_test.go"
   github.com/mk6i/open-oscar-server/server/toc:
     interfaces:
       AdminService:

+ 146 - 0
api.yml

@@ -1366,6 +1366,152 @@ paths:
                 $ref: '#/components/schemas/MessageResponse'
 
 
+  /user/{screenname}/linked-account:
+    get:
+      summary: List linked accounts for a screen name
+      description: Retrieve all linked accounts associated with a user.
+      parameters:
+        - in: path
+          name: screenname
+          schema:
+            type: string
+          description: User's AIM screen name or ICQ UIN.
+          required: true
+      responses:
+        '200':
+          description: Successful response containing linked accounts.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  linked_accounts:
+                    type: array
+                    items:
+                      type: string
+                    description: List of linked screen names.
+        '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'
+    post:
+      summary: Add a linked account
+      description: Link a screen name to the specified primary user account.
+      parameters:
+        - in: path
+          name: screenname
+          schema:
+            type: string
+          description: User's AIM screen name or ICQ UIN.
+          required: true
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              required:
+                - linked_screen_name
+              properties:
+                linked_screen_name:
+                  type: string
+                  description: The screen name to link to the primary account.
+                  example: "secondaryuser"
+      responses:
+        '201':
+          description: Linked account created successfully.
+        '400':
+          description: Invalid request body.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '404':
+          description: Primary user not found.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '409':
+          description: Linked account relationship already exists.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '500':
+          description: Internal server error.
+          content:
+            application/json:
+              schema:
+                $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}:
+    delete:
+      summary: Remove a linked account
+      description: Remove the linked account relationship between a primary screen name and a linked screen name.
+      parameters:
+        - in: path
+          name: screenname
+          schema:
+            type: string
+          description: User's AIM screen name or ICQ UIN.
+          required: true
+        - in: path
+          name: linked_screenname
+          schema:
+            type: string
+          description: The linked screen name to remove.
+          required: true
+      responses:
+        '204':
+          description: Linked account removed successfully.
+        '404':
+          description: User or linked account not found.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '500':
+          description: Internal server error.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+
+
 components:
   schemas:
     MessageResponse:

+ 11 - 0
cmd/server/factory.go

@@ -93,6 +93,7 @@ func MakeCommonDeps() (Container, error) {
 		c.sqLiteUserStore,
 		c.sqLiteUserStore,
 		c.sqLiteUserStore,
+		c.sqLiteUserStore,
 	)
 
 	c.icbmSvc = foodgroup.NewICBMService(
@@ -264,6 +265,7 @@ func OSCAR(deps Container) *oscar.Server {
 		deps.chatSessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
+		deps.sqLiteUserStore,
 		deps.rateLimitClasses,
 		state.NewAccountCreator(deps.sqLiteUserStore.InsertUser),
 		logger,
@@ -285,6 +287,7 @@ func OSCAR(deps Container) *oscar.Server {
 	)
 	chatService := foodgroup.NewChatService(deps.chatSessionManager)
 	chatNavService := foodgroup.NewChatNavService(logger, deps.sqLiteUserStore)
+
 	permitDenyService := foodgroup.NewPermitDenyService(
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
@@ -313,6 +316,7 @@ func OSCAR(deps Container) *oscar.Server {
 		deps.chatSessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
+		deps.sqLiteUserStore,
 	)
 	userLookupService := foodgroup.NewUserLookupService(deps.sqLiteUserStore)
 	statsService := foodgroup.NewStatsService()
@@ -370,6 +374,7 @@ func KerberosAPI(deps Container) *kerberos.Server {
 		deps.chatSessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
+		deps.sqLiteUserStore,
 		deps.rateLimitClasses,
 		state.NewAccountCreator(deps.sqLiteUserStore.InsertUser),
 		logger,
@@ -412,6 +417,7 @@ func MgmtAPI(deps Container) *http.Server {
 		deps.sqLiteUserStore,        // profileRetriever
 		deps.sqLiteUserStore,        // webAPIKeyManager
 		deps.sqLiteUserStore,        // icqProfileManager
+		deps.sqLiteUserStore,        // linkedAccountManager
 		state.NewAccountCreator(deps.sqLiteUserStore.InsertUser),
 		logger,
 	)
@@ -443,6 +449,7 @@ func TOC(deps Container) *toc.Server {
 				deps.chatSessionManager,
 				deps.sqLiteUserStore,
 				deps.sqLiteUserStore,
+				deps.sqLiteUserStore,
 				deps.rateLimitClasses,
 				state.NewAccountCreator(deps.sqLiteUserStore.InsertUser),
 				logger,
@@ -482,6 +489,7 @@ func TOC(deps Container) *toc.Server {
 				deps.chatSessionManager,
 				deps.sqLiteUserStore,
 				deps.sqLiteUserStore,
+				deps.sqLiteUserStore,
 			),
 			PermitDenyService: foodgroup.NewPermitDenyService(
 				deps.sqLiteUserStore,
@@ -551,6 +559,7 @@ func WebAPI(deps Container) *webapi.Server {
 			deps.chatSessionManager,
 			deps.sqLiteUserStore,
 			deps.sqLiteUserStore,
+			deps.sqLiteUserStore,
 			deps.rateLimitClasses,
 			state.NewAccountCreator(deps.sqLiteUserStore.InsertUser),
 			logger,
@@ -589,6 +598,7 @@ func WebAPI(deps Container) *webapi.Server {
 			deps.chatSessionManager,
 			deps.sqLiteUserStore,
 			deps.sqLiteUserStore,
+			deps.sqLiteUserStore,
 		),
 		PermitDenyService: foodgroup.NewPermitDenyService(
 			deps.sqLiteUserStore,
@@ -650,6 +660,7 @@ func ICQLegacy(deps Container) *icq_legacy.LegacyServer {
 		deps.chatSessionManager,
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
+		deps.sqLiteUserStore,
 		deps.rateLimitClasses,
 		state.NewAccountCreator(deps.sqLiteUserStore.InsertUser),
 		logger,

+ 91 - 12
foodgroup/auth.go

@@ -3,6 +3,7 @@ package foodgroup
 import (
 	"bytes"
 	"context"
+	"encoding/xml"
 	"errors"
 	"fmt"
 	"log/slog"
@@ -32,6 +33,7 @@ func NewAuthService(
 	chatMessageRelayer ChatMessageRelayer,
 	accountManager AccountManager,
 	bartItemManager BARTItemManager,
+	linkedAccountManager LinkedAccountManager,
 	classes wire.RateLimitClasses,
 	createAccount state.CreateAccountFunc,
 	logger *slog.Logger,
@@ -46,6 +48,7 @@ func NewAuthService(
 		chatMessageRelayer:         chatMessageRelayer,
 		accountManager:             accountManager,
 		bartItemManager:            bartItemManager,
+		linkedAccountManager:       linkedAccountManager,
 		rateLimitClasses:           classes,
 		timeNow:                    time.Now,
 		maxConcurrentLoginsPerUser: MaxConcurrentLoginsPerUser,
@@ -68,6 +71,7 @@ type AuthService struct {
 	userManager                UserManager
 	accountManager             AccountManager
 	bartItemManager            BARTItemManager
+	linkedAccountManager       LinkedAccountManager
 	rateLimitClasses           wire.RateLimitClasses
 	timeNow                    func() time.Time
 	maxConcurrentLoginsPerUser int
@@ -547,7 +551,7 @@ func (s AuthService) login(ctx context.Context, tlv wire.TLVList, advertisedHost
 	if s.config.DisableAuth {
 		// user exists, but don't validate
 		s.logger.Debug("login: auth disabled, skipping password validation", "screen_name", props.screenName)
-		return s.loginSuccessResponse(props, advertisedHost)
+		return s.loginSuccessResponse(ctx, props, advertisedHost)
 	}
 
 	var loginOK bool
@@ -600,7 +604,7 @@ func (s AuthService) login(ctx context.Context, tlv wire.TLVList, advertisedHost
 	}
 
 	s.logger.Debug("login: login successful", "screen_name", props.screenName)
-	return s.loginSuccessResponse(props, advertisedHost)
+	return s.loginSuccessResponse(ctx, props, advertisedHost)
 }
 
 func (s AuthService) createUser(ctx context.Context, props loginProperties, advertisedHost string) (wire.TLVRestBlock, error) {
@@ -616,10 +620,10 @@ func (s AuthService) createUser(ctx context.Context, props loginProperties, adve
 		}
 	}
 
-	return s.loginSuccessResponse(props, advertisedHost)
+	return s.loginSuccessResponse(ctx, props, advertisedHost)
 }
 
-func (s AuthService) loginSuccessResponse(props loginProperties, advertisedHost string) (wire.TLVRestBlock, error) {
+func (s AuthService) loginSuccessResponse(ctx context.Context, props loginProperties, advertisedHost string) (wire.TLVRestBlock, error) {
 	loginCookie := state.ServerCookie{
 		Service:       wire.BOS,
 		ScreenName:    props.screenName,
@@ -647,14 +651,40 @@ func (s AuthService) loginSuccessResponse(props loginProperties, advertisedHost
 		"reconnect_host", reconnectHost,
 		"ssl_state", sslState)
 
-	return wire.TLVRestBlock{
-		TLVList: []wire.TLV{
-			wire.NewTLVBE(wire.LoginTLVTagsScreenName, props.screenName),
-			wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, reconnectHost),
-			wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, cookie),
-			wire.NewTLVBE(wire.OServiceTLVTagsSSLState, sslState),
-		},
-	}, nil
+	loginTLVTags := wire.TLVList{
+		wire.NewTLVBE(wire.LoginTLVTagsScreenName, props.screenName),
+		wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, reconnectHost),
+		wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, cookie),
+		wire.NewTLVBE(wire.OServiceTLVTagsSSLState, sslState),
+	}
+
+	if err := s.addLinkedAccountsTLV(ctx, props.screenName, &loginTLVTags); err != nil {
+		return wire.TLVRestBlock{}, err
+	}
+
+	return wire.TLVRestBlock{TLVList: loginTLVTags}, nil
+}
+
+// addLinkedAccountsTLV builds the linked accounts XML and appends the
+// 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 {
+	accounts, err := s.linkedAccountManager.LinkedAccounts(ctx, screenName.IdentScreenName())
+	if err != nil {
+		return fmt.Errorf("failed to get linked accounts: %w", err)
+	}
+
+	if len(accounts) == 0 {
+		return nil
+	}
+
+	linkedNames := append([]state.IdentScreenName{}, accounts...)
+
+	xml, err := buildLinkedAccountsXML(screenName.IdentScreenName(), linkedNames)
+	if err != nil {
+		return fmt.Errorf("failed to build linked accounts xml: %w", err)
+	}
+	*tlvs = append(*tlvs, wire.NewTLVBE(wire.OServiceTLVTagsLinkedAccounts, xml))
+	return nil
 }
 
 func loginFailureResponse(props loginProperties, errCode uint16) wire.TLVRestBlock {
@@ -665,3 +695,52 @@ func loginFailureResponse(props loginProperties, errCode uint16) wire.TLVRestBlo
 		},
 	}
 }
+
+// buildLinkedAccountsXML will return the XML doc expected for wire.OServiceTLVTagsLinkedAccounts
+// Example:
+// <SET SETID="1">
+//
+//	<RESREC TYPE="PRIMARY-ACCOUNT" ID="1"><n>PrimaryName</n></RESREC>
+//	<RESREC TYPE="LINKED-ACCOUNT" ID="2"><n>LinkedName1</n></RESREC>
+//	<RESREC TYPE="LINKED-ACCOUNT" ID="3"><n>LinkedName2</n></RESREC>
+//	<RESREC TYPE="LINKED-ACCOUNT" ID="4"><n>LinkedName3</n></RESREC>
+//	<RESREC TYPE="LINKED-ACCOUNT" ID="5"><n>LinkedName4</n></RESREC>
+//
+// </SET>
+func buildLinkedAccountsXML(screenName state.IdentScreenName, linkedNames []state.IdentScreenName) (string, error) {
+	// ResRec represents the <RESREC> element with its attributes and nested <n> tag
+	type ResRec struct {
+		Type string `xml:"TYPE,attr"`
+		ID   string `xml:"ID,attr"`
+		Name string `xml:"n"`
+	}
+	// Set represents the root <SET> element
+	type Set struct {
+		XMLName xml.Name `xml:"SET"`
+		SetID   string   `xml:"SETID,attr"`
+		Records []ResRec `xml:"RESREC"`
+	}
+
+	data := Set{
+		SetID: "1",
+		Records: []ResRec{
+			{Type: "PRIMARY-ACCOUNT", ID: "1", Name: screenName.String()},
+		},
+	}
+
+	for i, name := range linkedNames {
+		newRec := ResRec{
+			Type: "LINKED-ACCOUNT",
+			// Incrementing ID starting from 2 (since Primary is 1)
+			ID:   strconv.Itoa(i + 2),
+			Name: name.String(),
+		}
+		data.Records = append(data.Records, newRec)
+	}
+
+	output, err := xml.Marshal(data)
+	if err != nil {
+		return "", err
+	}
+	return string(output), nil
+}

+ 324 - 12
foodgroup/auth_test.go

@@ -3,6 +3,7 @@ package foodgroup
 import (
 	"bytes"
 	"context"
+	"errors"
 	"fmt"
 	"io"
 	"log/slog"
@@ -719,6 +720,114 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:           "AIM account exists, correct password, linked accounts in response",
+			advertisedHost: "127.0.0.1:5190",
+			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
+						wire.NewTLVBE(wire.LoginTLVTagsPasswordHash, user.StrongMD5Pass),
+					},
+				},
+			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: user.IdentScreenName,
+							result:     &user,
+						},
+					},
+				},
+				cookieBakerParams: cookieBakerParams{
+					cookieIssueParams: cookieIssueParams{
+						{
+							dataIn: func() []byte {
+								loginCookie := state.ServerCookie{
+									ScreenName: user.DisplayScreenName,
+								}
+								buf := &bytes.Buffer{}
+								assert.NoError(t, wire.MarshalBE(loginCookie, buf))
+								return buf.Bytes()
+							}(),
+							cookieOut: []byte("the-cookie"),
+						},
+					},
+				},
+				linkedAccountManagerParams: linkedAccountManagerParams{
+					linkedAccountsParams: linkedAccountsParams{
+						{
+							screenName: user.IdentScreenName,
+							result:     []state.IdentScreenName{state.NewIdentScreenName("linked1")},
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BUCP,
+					SubGroup:  wire.BUCPLoginResponse,
+				},
+				Body: wire.SNAC_0x17_0x03_BUCPLoginResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
+							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
+							wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
+							wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
+							wire.NewTLVBE(wire.OServiceTLVTagsLinkedAccounts, `<SET SETID="1"><RESREC TYPE="PRIMARY-ACCOUNT" ID="1"><n>screenname</n></RESREC><RESREC TYPE="LINKED-ACCOUNT" ID="2"><n>linked1</n></RESREC></SET>`),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "linked account manager error during login, returns error",
+			advertisedHost: "127.0.0.1:5190",
+			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
+						wire.NewTLVBE(wire.LoginTLVTagsPasswordHash, user.StrongMD5Pass),
+					},
+				},
+			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: user.IdentScreenName,
+							result:     &user,
+						},
+					},
+				},
+				cookieBakerParams: cookieBakerParams{
+					cookieIssueParams: cookieIssueParams{
+						{
+							dataIn: func() []byte {
+								loginCookie := state.ServerCookie{
+									ScreenName: user.DisplayScreenName,
+								}
+								buf := &bytes.Buffer{}
+								assert.NoError(t, wire.MarshalBE(loginCookie, buf))
+								return buf.Bytes()
+							}(),
+							cookieOut: []byte("the-cookie"),
+						},
+					},
+				},
+				linkedAccountManagerParams: linkedAccountManagerParams{
+					linkedAccountsParams: linkedAccountsParams{
+						{
+							screenName: user.IdentScreenName,
+							err:        io.EOF,
+						},
+					},
+				},
+			},
+			wantErr: io.EOF,
+		},
 		{
 			name:           "login with TOC client - failed",
 			advertisedHost: "127.0.0.1:5190",
@@ -779,11 +888,20 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 					Return(params.result)
 			}
 
+			linkedAccountManager := newMockLinkedAccountManager(t)
+			for _, params := range tc.mockParams.linkedAccountsParams {
+				linkedAccountManager.EXPECT().
+					LinkedAccounts(matchContext(), params.screenName).
+					Return(params.result, params.err)
+			}
+			linkedAccountManager.EXPECT().LinkedAccounts(matchContext(), mock.Anything).Return(nil, nil).Maybe()
+
 			svc := AuthService{
 				config:                     tc.cfg,
 				cookieBaker:                cookieBaker,
 				userManager:                userManager,
 				sessionRetriever:           sessionRetriever,
+				linkedAccountManager:       linkedAccountManager,
 				maxConcurrentLoginsPerUser: 2,
 				createAccount:              tc.createAccount,
 				logger:                     slog.Default(),
@@ -1100,6 +1218,52 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:           "linked account manager error during login, returns error",
+			advertisedHost: "127.0.0.1:5190",
+			inputSNAC: wire.FLAPSignonFrame{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.LoginTLVTagsRoastedPassword, wire.RoastOSCARPassword([]byte("the_password"))),
+						wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
+					},
+				},
+			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: user.IdentScreenName,
+							result:     &user,
+						},
+					},
+				},
+				cookieBakerParams: cookieBakerParams{
+					cookieIssueParams: cookieIssueParams{
+						{
+							dataIn: func() []byte {
+								loginCookie := state.ServerCookie{
+									ScreenName: user.DisplayScreenName,
+								}
+								buf := &bytes.Buffer{}
+								assert.NoError(t, wire.MarshalBE(loginCookie, buf))
+								return buf.Bytes()
+							}(),
+							cookieOut: []byte("the-cookie"),
+						},
+					},
+				},
+				linkedAccountManagerParams: linkedAccountManagerParams{
+					linkedAccountsParams: linkedAccountsParams{
+						{
+							screenName: user.IdentScreenName,
+							err:        io.EOF,
+						},
+					},
+				},
+			},
+			wantErr: io.EOF,
+		},
 		{
 			name: "login fails on user manager lookup",
 			inputSNAC: wire.FLAPSignonFrame{
@@ -1214,12 +1378,20 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 					Issue(params.dataIn).
 					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)
+			}
+			linkedAccountManager.EXPECT().LinkedAccounts(matchContext(), mock.Anything).Return(nil, nil).Maybe()
 			svc := AuthService{
-				config:        tc.cfg,
-				cookieBaker:   cookieBaker,
-				userManager:   userManager,
-				createAccount: tc.createAccount,
-				logger:        slog.Default(),
+				config:               tc.cfg,
+				cookieBaker:          cookieBaker,
+				userManager:          userManager,
+				linkedAccountManager: linkedAccountManager,
+				createAccount:        tc.createAccount,
+				logger:               slog.Default(),
 			}
 			outputSNAC, err := svc.FLAPLogin(context.Background(), tc.inputSNAC, tc.advertisedHost)
 			assert.ErrorIs(t, err, tc.wantErr)
@@ -1553,11 +1725,14 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 					RetrieveSession(params.screenName).
 					Return(params.result)
 			}
+			linkedAccountManager := newMockLinkedAccountManager(t)
+			linkedAccountManager.EXPECT().LinkedAccounts(matchContext(), mock.Anything).Return(nil, nil).Maybe()
 			svc := AuthService{
 				config:                     tc.cfg,
 				cookieBaker:                cookieBaker,
 				userManager:                userManager,
 				sessionRetriever:           sessionRetriever,
+				linkedAccountManager:       linkedAccountManager,
 				timeNow:                    tc.timeNow,
 				maxConcurrentLoginsPerUser: 2,
 				createAccount:              tc.createAccount,
@@ -1751,7 +1926,7 @@ func TestAuthService_RegisterChatSession_HappyPath(t *testing.T) {
 	chatCookieBuf := &bytes.Buffer{}
 	assert.NoError(t, wire.MarshalBE(serverCookie, chatCookieBuf))
 
-	svc := NewAuthService(config.Config{}, nil, nil, chatSessionRegistry, nil, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), nil, slog.Default())
+	svc := NewAuthService(config.Config{}, nil, nil, chatSessionRegistry, nil, nil, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), nil, slog.Default())
 
 	have, err := svc.RegisterChatSession(context.Background(), serverCookie, nil)
 	assert.NoError(t, err)
@@ -1773,6 +1948,10 @@ func TestAuthService_RegisterBOSSession(t *testing.T) {
 		name string
 		// cookieOut is the auth cookieOut that contains session information
 		cookie state.ServerCookie
+		// cfg is the server config
+		cfg config.Config
+		// createAccount is called to auto-create a missing user when DisableAuth is set
+		createAccount state.CreateAccountFunc
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		mockParams mockParams
@@ -1932,6 +2111,22 @@ func TestAuthService_RegisterBOSSession(t *testing.T) {
 				return uinMatches && flagsMatch
 			},
 		},
+		{
+			name:    "user not found, DisableAuth false, return error",
+			cookie:  aimAuthCookie,
+			cfg:     config.Config{DisableAuth: false},
+			wantErr: errors.New("user not found"),
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: screenName.IdentScreenName(),
+							result:     nil,
+						},
+					},
+				},
+			},
+		},
 	}
 
 	for _, tc := range cases {
@@ -1946,7 +2141,7 @@ func TestAuthService_RegisterBOSSession(t *testing.T) {
 			for _, params := range tc.mockParams.userManagerParams.getUserParams {
 				userManager.EXPECT().
 					User(matchContext(), params.screenName).
-					Return(params.result, nil)
+					Return(params.result, nil).Once()
 			}
 			accountManager := newMockAccountManager(t)
 			for _, params := range tc.mockParams.accountManagerConfirmStatusParams {
@@ -1961,9 +2156,13 @@ func TestAuthService_RegisterBOSSession(t *testing.T) {
 					Return(params.result, params.err)
 			}
 
-			svc := NewAuthService(config.Config{}, sessionRegistry, nil, nil, userManager, nil, nil, accountManager, bartItemManager, wire.DefaultRateLimitClasses(), nil, slog.Default())
+			svc := NewAuthService(tc.cfg, sessionRegistry, nil, nil, userManager, nil, nil, accountManager, bartItemManager, nil, wire.DefaultRateLimitClasses(), tc.createAccount, slog.Default())
 
 			have, err := svc.RegisterBOSSession(context.Background(), tc.cookie, nil)
+			if tc.wantErr != nil {
+				assert.ErrorContains(t, err, tc.wantErr.Error())
+				return
+			}
 			assert.NoError(t, err)
 
 			if tc.wantSess != nil {
@@ -1992,7 +2191,7 @@ func TestAuthService_RetrieveBOSSession_HappyPath(t *testing.T) {
 		User(matchContext(), instance.IdentScreenName()).
 		Return(&state.User{IdentScreenName: instance.IdentScreenName()}, nil)
 
-	svc := NewAuthService(config.Config{}, nil, sessionRetriever, nil, userManager, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), nil, slog.Default())
+	svc := NewAuthService(config.Config{}, nil, sessionRetriever, nil, userManager, nil, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), nil, slog.Default())
 
 	have, err := svc.RetrieveBOSSession(context.Background(), aimAuthCookie)
 	assert.NoError(t, err)
@@ -2017,7 +2216,7 @@ func TestAuthService_RetrieveBOSSession_SessionNotFound(t *testing.T) {
 		User(matchContext(), instance.IdentScreenName()).
 		Return(&state.User{IdentScreenName: instance.IdentScreenName()}, nil)
 
-	svc := NewAuthService(config.Config{}, nil, sessionRetriever, nil, userManager, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), nil, slog.Default())
+	svc := NewAuthService(config.Config{}, nil, sessionRetriever, nil, userManager, nil, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), nil, slog.Default())
 
 	have, err := svc.RetrieveBOSSession(context.Background(), aimAuthCookie)
 	assert.NoError(t, err)
@@ -2110,7 +2309,7 @@ func TestAuthService_SignoutChat(t *testing.T) {
 					RemoveSession(matchUserSession(params.screenName))
 			}
 
-			svc := NewAuthService(config.Config{}, nil, nil, sessionManager, nil, nil, chatMessageRelayer, nil, nil, wire.DefaultRateLimitClasses(), nil, slog.Default())
+			svc := NewAuthService(config.Config{}, nil, nil, sessionManager, nil, nil, chatMessageRelayer, nil, nil, nil, wire.DefaultRateLimitClasses(), nil, slog.Default())
 			svc.SignoutChat(context.Background(), tt.instance.Session())
 		})
 	}
@@ -2155,9 +2354,122 @@ func TestAuthService_Signout(t *testing.T) {
 			for _, params := range tt.mockParams.removeSessionParams {
 				sessionManager.EXPECT().RemoveSession(matchUserSession(params.screenName))
 			}
-			svc := NewAuthService(config.Config{}, sessionManager, nil, nil, nil, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), nil, slog.Default())
+			svc := NewAuthService(config.Config{}, sessionManager, nil, nil, nil, nil, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), nil, slog.Default())
 
 			svc.Signout(context.Background(), tt.instance.Session())
 		})
 	}
 }
+
+func TestBuildLinkedAccountsXML(t *testing.T) {
+	cases := []struct {
+		name        string
+		screenName  state.IdentScreenName
+		linkedNames []state.IdentScreenName
+		wantXML     string
+		wantErr     bool
+	}{
+		{
+			name:        "primary account with no linked accounts",
+			screenName:  state.NewIdentScreenName("PrimaryUser"),
+			linkedNames: []state.IdentScreenName{},
+			wantXML:     `<SET SETID="1"><RESREC TYPE="PRIMARY-ACCOUNT" ID="1"><n>primaryuser</n></RESREC></SET>`,
+		},
+		{
+			name:       "primary account with one linked account",
+			screenName: state.NewIdentScreenName("PrimaryUser"),
+			linkedNames: []state.IdentScreenName{
+				state.NewIdentScreenName("LinkedUser1"),
+			},
+			wantXML: `<SET SETID="1"><RESREC TYPE="PRIMARY-ACCOUNT" ID="1"><n>primaryuser</n></RESREC><RESREC TYPE="LINKED-ACCOUNT" ID="2"><n>linkeduser1</n></RESREC></SET>`,
+		},
+		{
+			name:       "primary account with multiple linked accounts",
+			screenName: state.NewIdentScreenName("PrimaryUser"),
+			linkedNames: []state.IdentScreenName{
+				state.NewIdentScreenName("LinkedUser1"),
+				state.NewIdentScreenName("LinkedUser2"),
+				state.NewIdentScreenName("LinkedUser3"),
+			},
+			wantXML: `<SET SETID="1"><RESREC TYPE="PRIMARY-ACCOUNT" ID="1"><n>primaryuser</n></RESREC><RESREC TYPE="LINKED-ACCOUNT" ID="2"><n>linkeduser1</n></RESREC><RESREC TYPE="LINKED-ACCOUNT" ID="3"><n>linkeduser2</n></RESREC><RESREC TYPE="LINKED-ACCOUNT" ID="4"><n>linkeduser3</n></RESREC></SET>`,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got, err := buildLinkedAccountsXML(tc.screenName, tc.linkedNames)
+			if tc.wantErr {
+				assert.Error(t, err)
+				return
+			}
+			assert.NoError(t, err)
+			assert.Equal(t, tc.wantXML, got)
+		})
+	}
+}
+
+func TestAuthService_addLinkedAccountsTLV(t *testing.T) {
+	cases := []struct {
+		name       string
+		screenName state.DisplayScreenName
+		// mockLinkedAccounts is what linkedAccountManager.LinkedAccounts returns
+		mockLinkedAccounts []state.IdentScreenName
+		mockErr            error
+		// wantTLVCount is the expected number of TLVs after the call
+		wantTLVCount int
+		wantErr      bool
+	}{
+		{
+			name:               "no linked accounts, TLV list unchanged",
+			screenName:         "PrimaryUser",
+			mockLinkedAccounts: nil,
+			wantTLVCount:       0,
+		},
+		{
+			name:       "one linked account, TLV appended",
+			screenName: "PrimaryUser",
+			mockLinkedAccounts: []state.IdentScreenName{
+				state.NewIdentScreenName("LinkedUser1"),
+			},
+			wantTLVCount: 1,
+		},
+		{
+			name:       "multiple linked accounts, single TLV appended",
+			screenName: "PrimaryUser",
+			mockLinkedAccounts: []state.IdentScreenName{
+				state.NewIdentScreenName("LinkedUser1"),
+				state.NewIdentScreenName("LinkedUser2"),
+			},
+			wantTLVCount: 1,
+		},
+		{
+			name:       "linkedAccountManager returns error, error propagated",
+			screenName: "PrimaryUser",
+			mockErr:    io.EOF,
+			wantErr:    true,
+		},
+	}
+
+	for _, tc := range cases {
+		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)
+
+			svc := AuthService{linkedAccountManager: linkedAccountManager}
+			tlvs := wire.TLVList{}
+			err := svc.addLinkedAccountsTLV(context.Background(), tc.screenName, &tlvs)
+
+			if tc.wantErr {
+				assert.Error(t, err)
+				return
+			}
+			assert.NoError(t, err)
+			assert.Len(t, tlvs, tc.wantTLVCount)
+			if tc.wantTLVCount > 0 {
+				assert.Equal(t, wire.OServiceTLVTagsLinkedAccounts, tlvs[0].Tag)
+			}
+		})
+	}
+}

+ 59 - 5
foodgroup/feedbag.go

@@ -25,6 +25,7 @@ func NewFeedbagService(
 	sessionRetriever SessionRetriever,
 	contactPreAuthorizer ContactPreAuthorizer,
 	userManager UserManager,
+	linkedAccountManager LinkedAccountManager,
 	buddyAddedNotifierDeduper BuddyAddedNotifierDeduper,
 ) *FeedbagService {
 	return &FeedbagService{
@@ -32,6 +33,7 @@ func NewFeedbagService(
 		buddyBroadcaster:          newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		buddyAddedNotifierDeduper: buddyAddedNotifierDeduper,
 		feedbagManager:            feedbagManager,
+		linkedAccountManager:      linkedAccountManager,
 		logger:                    logger,
 		messageRelayer:            messageRelayer,
 		relationshipFetcher:       relationshipFetcher,
@@ -51,6 +53,7 @@ type FeedbagService struct {
 	buddyBroadcaster          buddyBroadcaster
 	buddyAddedNotifierDeduper BuddyAddedNotifierDeduper
 	feedbagManager            FeedbagManager
+	linkedAccountManager      LinkedAccountManager
 	logger                    *slog.Logger
 	messageRelayer            MessageRelayer
 	relationshipFetcher       RelationshipFetcher
@@ -72,7 +75,7 @@ func (s *FeedbagService) BridgeICBMService(service *ICBMService) {
 func (s *FeedbagService) RightsQuery(_ context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
 	// maxItemsByClass defines per-type item limits. Types not listed here are
 	// 0 by default. The slice size is equal to the maximum "enum" value+1.
-	maxItemsByClass := make([]uint16, 21)
+	maxItemsByClass := make([]uint16, wire.FeedbagClassIdAlInfo+1)
 	maxItemsByClass[wire.FeedbagClassIdBuddy] = 61
 	maxItemsByClass[wire.FeedbagClassIdGroup] = 61
 	maxItemsByClass[wire.FeedbagClassIDPermit] = 100
@@ -88,6 +91,7 @@ func (s *FeedbagService) RightsQuery(_ context.Context, inFrame wire.SNACFrame)
 	maxItemsByClass[wire.FeedbagClassIdRootCreator] = 1
 	maxItemsByClass[wire.FeedbagClassIdImportTimestamp] = 1
 	maxItemsByClass[wire.FeedbagClassIdBart] = 200
+	maxItemsByClass[wire.FeedbagClassIdAlInfo] = 4
 
 	return wire.SNACMessage{
 		Frame: wire.SNACFrame{
@@ -118,11 +122,34 @@ func (s *FeedbagService) RightsQuery(_ context.Context, inFrame wire.SNACFrame)
 // Query fetches the user's feedbag (aka buddy list). It returns
 // wire.FeedbagReply, which contains feedbag entries.
 func (s *FeedbagService) Query(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
-	fb, err := s.feedbagManager.Feedbag(ctx, instance.IdentScreenName())
+	rawFb, err := s.feedbagManager.Feedbag(ctx, instance.IdentScreenName())
 	if err != nil {
 		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)
 
 	if len(fb) > 0 {
@@ -151,19 +178,43 @@ func (s *FeedbagService) Query(ctx context.Context, instance *state.SessionInsta
 // inBody.LastUpdate, else return wire.FeedbagReply, which contains feedbag
 // entries.
 func (s *FeedbagService) QueryIfModified(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error) {
-	fb, err := s.feedbagManager.Feedbag(ctx, instance.IdentScreenName())
+	rawFb, err := s.feedbagManager.Feedbag(ctx, instance.IdentScreenName())
 	if err != nil {
 		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)
 
-	if len(fb) > 0 {
+	if len(rawFb) > 0 {
 		lm, err = s.feedbagManager.FeedbagLastModified(ctx, instance.IdentScreenName())
 		if err != nil {
 			return wire.SNACMessage{}, err
 		}
-		if lm.Before(time.Unix(int64(inBody.LastUpdate), 0)) {
+		// 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)) {
 			return wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.Feedbag,
@@ -225,6 +276,9 @@ func (s *FeedbagService) UpsertItem(ctx context.Context, instance *state.Session
 	authRequired := make(map[string]bool)
 
 	for _, item := range items {
+		if item.ClassID == wire.FeedbagClassIdAlInfo {
+			continue
+		}
 		if item.ClassID == wire.FeedbagClassIdBuddy {
 			sn := state.NewIdentScreenName(item.Name)
 			switch {

+ 378 - 14
foodgroup/feedbag_test.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"context"
 	"fmt"
+	"io"
 	"log/slog"
 	"testing"
 	"time"
@@ -130,8 +131,11 @@ func TestFeedbagService_Query(t *testing.T) {
 					Return(params.result, nil)
 			}
 
+			linkedAccountManager := newMockLinkedAccountManager(t)
+			linkedAccountManager.EXPECT().LinkedAccounts(matchContext(), mock.Anything).Return(nil, nil).Maybe()
 			svc := FeedbagService{
-				feedbagManager: feedbagManager,
+				feedbagManager:       feedbagManager,
+				linkedAccountManager: linkedAccountManager,
 			}
 			outputSNAC, err := svc.Query(context.Background(), tc.instance, tc.inputSNAC.Frame)
 			assert.NoError(t, err)
@@ -140,6 +144,107 @@ 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) {
 	cases := []struct {
 		// name is the unit test name
@@ -313,8 +418,11 @@ func TestFeedbagService_QueryIfModified(t *testing.T) {
 			//
 			// send input SNAC
 			//
+			linkedAccountManager := newMockLinkedAccountManager(t)
+			linkedAccountManager.EXPECT().LinkedAccounts(matchContext(), mock.Anything).Return(nil, nil).Maybe()
 			svc := FeedbagService{
-				feedbagManager: feedbagManager,
+				feedbagManager:       feedbagManager,
+				linkedAccountManager: linkedAccountManager,
 			}
 			outputSNAC, err := svc.QueryIfModified(context.Background(), tc.instance, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x13_0x05_FeedbagQueryIfModified))
@@ -327,8 +435,260 @@ 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) {
+	instance := newTestInstance("me")
+	inFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem, RequestID: 1234}
+	items := []wire.FeedbagItem{
+		{ClassID: wire.FeedbagClassIdAlInfo, Name: "linkeduser"},
+	}
+
+	// feedbagManager expects no FeedbagUpsert call since AlInfo items are dropped
+	feedbagManager := newMockFeedbagManager(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)
+	output, err := svc.UpsertItem(context.Background(), instance, inFrame, items)
+	assert.NoError(t, err)
+	assert.Nil(t, output)
+}
+
+func TestFeedbagService_DeleteItem_AlInfoIgnored(t *testing.T) {
+	instance := newTestInstance("me")
+	inFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem, RequestID: 1234}
+	inBody := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+		Items: []wire.FeedbagItem{
+			{ClassID: wire.FeedbagClassIdAlInfo, Name: "linkeduser"},
+		},
+	}
+
+	feedbagManager := newMockFeedbagManager(t)
+	feedbagManager.EXPECT().
+		FeedbagDelete(matchContext(), state.NewIdentScreenName("me"), inBody.Items).
+		Return(nil)
+
+	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.FeedbagDeleteItem, RequestID: wire.ReqIDFromServer},
+			Body:  inBody,
+		})
+
+	buddyBroadcaster := newMockbuddyBroadcaster(t)
+	buddyBroadcaster.EXPECT().
+		BroadcastVisibility(mock.Anything, matchSession(state.NewIdentScreenName("me")), []state.IdentScreenName(nil), true).
+		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{
+		buddyBroadcaster:     buddyBroadcaster,
+		feedbagManager:       feedbagManager,
+		messageRelayer:       messageRelayer,
+		linkedAccountManager: linkedAccountManager,
+	}
+	output, err := svc.DeleteItem(context.Background(), instance, inFrame, inBody)
+	assert.NoError(t, err)
+	assert.Nil(t, output)
+}
+
 func TestFeedbagService_RightsQuery(t *testing.T) {
-	svc := NewFeedbagService(nil, nil, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+	svc := NewFeedbagService(nil, nil, nil, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 
 	outputSNAC := svc.RightsQuery(context.Background(), wire.SNACFrame{RequestID: 1234})
 	expectSNAC := wire.SNACMessage{
@@ -363,6 +723,10 @@ func TestFeedbagService_RightsQuery(t *testing.T) {
 						0x00,
 						1,
 						200,
+						0x00,
+						0x00,
+						0x00,
+						4,
 					}),
 					wire.NewTLVBE(wire.FeedbagRightsMaxClientItems, uint16(200)),
 					wire.NewTLVBE(wire.FeedbagRightsMaxItemNameLen, uint16(200)),
@@ -2636,7 +3000,7 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 				assert.Equal(t, wantBody, haveBody)
 				return nil, nil
 			}
-			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, bartItemManager, nil, sessionRetriever, contactPreAuth, nil, buddyAddedDeduper)
+			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, bartItemManager, nil, sessionRetriever, contactPreAuth, nil, nil, buddyAddedDeduper)
 			svc.buddyBroadcaster = buddyUpdateBroadcaster
 			svc.icbmSender = icbmSender
 			output, err := svc.UpsertItem(context.Background(), tc.instance, tc.inputSNAC.Frame,
@@ -3076,7 +3440,7 @@ func TestFeedbagService_Use(t *testing.T) {
 					Return(params.err)
 			}
 
-			svc := NewFeedbagService(slog.Default(), nil, feedbagManager, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+			svc := NewFeedbagService(slog.Default(), nil, feedbagManager, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 			svc.buddyBroadcaster = buddyUpdateBroadcaster
 
 			haveErr := svc.Use(context.Background(), tt.instance)
@@ -3509,7 +3873,7 @@ func TestFeedbagService_RequestAuthorizeToHost(t *testing.T) {
 					Return(params.result, params.err)
 			}
 
-			svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, sessionRetriever, contactPreAuth, userManager, newMockBuddyAddedNotifierDeduper(t))
+			svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, sessionRetriever, contactPreAuth, userManager, nil, newMockBuddyAddedNotifierDeduper(t))
 			svc.icbmSender = icbmSender
 
 			haveErr := svc.RequestAuthorizeToHost(
@@ -3980,7 +4344,7 @@ func TestFeedbagService_RespondAuthorizeToHost(t *testing.T) {
 				return nil, tt.wantErr
 			}
 
-			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth, nil, newMockBuddyAddedNotifierDeduper(t))
+			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 			svc.buddyBroadcaster = buddyBroadcaster
 			svc.icbmSender = icbmSender
 
@@ -4315,7 +4679,7 @@ func TestFeedbagService_PreAuthorizeBuddy(t *testing.T) {
 				return nil, nil
 			}
 
-			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth, nil, newMockBuddyAddedNotifierDeduper(t))
+			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 			svc.icbmSender = icbmSender
 
 			out, err := svc.PreAuthorizeBuddy(context.Background(), alice, tt.inFrame, tt.inBody)
@@ -4544,7 +4908,7 @@ func TestFeedbagService_StartCluster(t *testing.T) {
 			Body:  inBody,
 		})
 
-	svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+	svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 	svc.StartCluster(context.Background(), instance, inFrame, inBody)
 
 	assert.True(t, instance.InNotifyTxn())
@@ -4621,7 +4985,7 @@ func TestFeedbagService_EndCluster(t *testing.T) {
 					Return(params.err)
 			}
 
-			svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+			svc := NewFeedbagService(slog.Default(), messageRelayer, nil, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 			svc.buddyBroadcaster = buddyBroadcaster
 			err := svc.EndCluster(context.Background(), tc.instance, endFrame)
 			assert.NoError(t, err)
@@ -4676,7 +5040,7 @@ func TestFeedbagService_notifyTxnCluster(t *testing.T) {
 			Return(nil).
 			Once()
 
-		svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+		svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 		svc.buddyBroadcaster = buddyBroadcaster
 
 		svc.StartCluster(context.Background(), instance, startFrame, startBody)
@@ -4718,7 +5082,7 @@ func TestFeedbagService_notifyTxnCluster(t *testing.T) {
 			Return(nil).
 			Once()
 
-		svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+		svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 		svc.buddyBroadcaster = buddyBroadcaster
 
 		svc.StartCluster(context.Background(), instance, startFrame, startBody)
@@ -4757,7 +5121,7 @@ func TestFeedbagService_notifyTxnCluster(t *testing.T) {
 			Return(nil).
 			Once()
 
-		svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
+		svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, nil, nil, nil, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 		svc.buddyBroadcaster = buddyBroadcaster
 
 		svc.StartCluster(context.Background(), instance, startFrame, startBody)
@@ -5052,7 +5416,7 @@ func TestFeedbagService_ForwardICQAuthEvents(t *testing.T) {
 				return nil, tt.wantErr
 			}
 
-			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth, nil, newMockBuddyAddedNotifierDeduper(t))
+			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, relationshipFetcher, sessionRetriever, contactPreAuth, nil, nil, newMockBuddyAddedNotifierDeduper(t))
 			svc.icbmSender = icbmSender
 
 			err := svc.ForwardICQAuthEvents(context.Background(), sender.IdentScreenName(), recipient, tt.authMsg)

+ 22 - 0
foodgroup/helpers_test.go

@@ -34,6 +34,7 @@ type mockParams struct {
 	sessionRegistryParams
 	sessionRetrieverParams
 	userManagerParams
+	linkedAccountManagerParams
 }
 
 // contactPreAuthorizerParams is a helper struct that contains mock parameters for
@@ -801,6 +802,20 @@ type createChatRoomParams []struct {
 	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
 func sessOptWarning(level int16) func(instance *state.SessionInstance) {
 	return func(instance *state.SessionInstance) {
@@ -875,6 +890,13 @@ func sessOptWantTypingEvents(instance *state.SessionInstance) {
 	instance.Session().SetTypingEventsEnabled(true)
 }
 
+// sessOptMultiConnFlag sets the multi-connection flag on the session instance
+func sessOptMultiConnFlag(flag wire.MultiConnFlag) func(instance *state.SessionInstance) {
+	return func(instance *state.SessionInstance) {
+		instance.SetMultiConnFlag(flag)
+	}
+}
+
 // sessOptSetFoodGroupVersion sets food group versions
 func sessOptSetFoodGroupVersion(foodGroup uint16, version uint16) func(instance *state.SessionInstance) {
 	return func(instance *state.SessionInstance) {

+ 305 - 0
foodgroup/mock_linked_account_manager_test.go

@@ -0,0 +1,305 @@
+// 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
+}

+ 28 - 0
foodgroup/oservice.go

@@ -28,6 +28,7 @@ type OServiceService struct {
 	chatMessageRelayer    ChatMessageRelayer
 	profileManager        ProfileManager
 	offlineMessageManager OfflineMessageManager
+	linkedAccountManager  LinkedAccountManager
 }
 
 // NewOServiceService creates a new instance of NewOServiceService.
@@ -44,6 +45,7 @@ func NewOServiceService(
 	chatMessageRelayer ChatMessageRelayer,
 	profileManager ProfileManager,
 	offlineMessageManager OfflineMessageManager,
+	linkedAccountManager LinkedAccountManager,
 ) *OServiceService {
 	return &OServiceService{
 		cookieIssuer:          cookieIssuer,
@@ -57,6 +59,7 @@ func NewOServiceService(
 		chatMessageRelayer:    chatMessageRelayer,
 		profileManager:        profileManager,
 		offlineMessageManager: offlineMessageManager,
+		linkedAccountManager:  linkedAccountManager,
 	}
 }
 
@@ -622,6 +625,31 @@ func (s OServiceService) ServiceRequest(ctx context.Context, service uint16, ins
 				ScreenName: instance.DisplayScreenName(),
 				SessionNum: instance.Num(),
 			})
+		case wire.OService:
+			// Linked Account signon request
+			_, ok := inBody.Bytes(0x0028)
+			if !ok {
+				return nil, errors.New("unknown OService request")
+			}
+			snBytes, ok := inBody.Bytes(0x01)
+			if !ok {
+				return nil, errors.New("new session request missing linked screenname TLV 0x01")
+			}
+			linkedScreenName := state.NewIdentScreenName(string(snBytes))
+			s.logger.Debug("Linked Account signon request", "primary", instance.IdentScreenName(), "linked", linkedScreenName.String())
+
+			linked, err := s.linkedAccountManager.CheckLinkedAccount(ctx, instance.IdentScreenName(), linkedScreenName)
+			if err != nil {
+				return nil, fmt.Errorf("unable to check linked account: %w", err)
+			}
+			if !linked {
+				return nil, errors.New("linked account session requested but accounts are not linked")
+			}
+			return fnIssueCookie(state.ServerCookie{
+				Service:       wire.BOS,
+				ScreenName:    state.DisplayScreenName(snBytes),
+				MultiConnFlag: uint8(instance.MultiConnFlag()),
+			})
 		default:
 			return nil, nil
 		}

+ 189 - 3
foodgroup/oservice_test.go

@@ -827,7 +827,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			//
 			// send input SNAC
 			//
-			svc := NewOServiceService(config.Config{}, nil, slog.Default(), cookieIssuer, chatRoomManager, nil, nil, nil, wire.DefaultSNACRateLimits(), chatMessageRelayer, nil, nil)
+			svc := NewOServiceService(config.Config{}, nil, slog.Default(), cookieIssuer, chatRoomManager, nil, nil, nil, wire.DefaultSNACRateLimits(), chatMessageRelayer, nil, nil, nil)
 
 			outputSNAC, err := svc.ServiceRequest(context.Background(), tc.service, tc.instance, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x01_0x04_OServiceServiceRequest), tc.listener)
@@ -843,6 +843,192 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 	}
 }
 
+func TestOServiceService_ServiceRequest_LinkedAccountSignon(t *testing.T) {
+	primaryUser := state.NewIdentScreenName("PrimaryUser")
+	linkedUser := state.DisplayScreenName("LinkedUser")
+	linkedUserIdent := state.NewIdentScreenName(string(linkedUser))
+
+	// cookieDataFor returns the serialized ServerCookie bytes that fnIssueCookie
+	// will pass to cookieIssuer.Issue, with the given MultiConnFlag.
+	cookieDataFor := func(sn state.DisplayScreenName, flag wire.MultiConnFlag) []byte {
+		buf := &bytes.Buffer{}
+		assert.NoError(t, wire.MarshalBE(state.ServerCookie{
+			Service:       wire.BOS,
+			ScreenName:    sn,
+			MultiConnFlag: uint8(flag),
+		}, buf))
+		return buf.Bytes()
+	}
+
+	makeBody := func(includeSUID bool, screenName string) wire.SNAC_0x01_0x04_OServiceServiceRequest {
+		body := wire.SNAC_0x01_0x04_OServiceServiceRequest{
+			FoodGroup: wire.OService,
+		}
+		if includeSUID {
+			body.TLVList = append(body.TLVList,
+				wire.NewTLVBE(uint16(0x0028), []byte("some-uuid-bytes")))
+		}
+		if screenName != "" {
+			body.TLVList = append(body.TLVList,
+				wire.NewTLVBE(uint16(0x01), []byte(screenName)))
+		}
+		return body
+	}
+
+	cases := []struct {
+		name      string
+		instance  *state.SessionInstance
+		inputBody wire.SNAC_0x01_0x04_OServiceServiceRequest
+		// linked is what CheckLinkedAccount returns
+		linked   bool
+		checkErr error
+		// setupCookie is whether to expect cookie issuance
+		setupCookie     bool
+		expectOutput    wire.SNACMessage
+		wantErrContains string
+		wantErr         error
+	}{
+		{
+			name:        "linked account signon OK, returns BOS cookie with no MultiConnFlag",
+			inputBody:   makeBody(true, string(linkedUser)),
+			linked:      true,
+			setupCookie: true,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceServiceResponse,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.OService),
+							wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, "127.0.0.1:5190"),
+							wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("the-cookie")),
+							wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
+						},
+					},
+				},
+			},
+		},
+		{
+			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,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceServiceResponse,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.OService),
+							wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, "127.0.0.1:5190"),
+							wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("the-cookie")),
+							wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
+						},
+					},
+				},
+			},
+		},
+		{
+			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,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceServiceResponse,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.OService),
+							wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, "127.0.0.1:5190"),
+							wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("the-cookie")),
+							wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:            "missing SUID TLV 0x0028, returns error",
+			inputBody:       makeBody(false, string(linkedUser)),
+			wantErrContains: "unknown OService request",
+		},
+		{
+			name:            "missing screenname TLV 0x01, returns error",
+			inputBody:       makeBody(true, ""),
+			wantErrContains: "new session request missing linked screenname TLV 0x01",
+		},
+		{
+			name:            "accounts not linked, returns error",
+			inputBody:       makeBody(true, string(linkedUser)),
+			linked:          false,
+			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,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			instance := tc.instance
+			if instance == nil {
+				instance = newTestInstance(state.DisplayScreenName(primaryUser.String()))
+			}
+
+			cookieIssuer := newMockCookieBaker(t)
+			if tc.setupCookie {
+				cookieIssuer.EXPECT().
+					Issue(cookieDataFor(linkedUser, instance.MultiConnFlag())).
+					Return([]byte("the-cookie"), nil)
+			}
+
+			linkedAccountManager := newMockLinkedAccountManager(t)
+			// Only set up CheckLinkedAccount if the request has both required TLVs
+			if tc.inputBody.HasTag(0x0028) {
+				if snBytes, ok := tc.inputBody.Bytes(0x01); ok && len(snBytes) > 0 {
+					linkedAccountManager.EXPECT().
+						CheckLinkedAccount(matchContext(), primaryUser, linkedUserIdent).
+						Return(tc.linked, tc.checkErr)
+				}
+			}
+
+			svc := NewOServiceService(config.Config{}, nil, slog.Default(), cookieIssuer, nil, nil, nil, nil,
+				wire.DefaultSNACRateLimits(), nil, nil, nil, linkedAccountManager)
+
+			listener := config.Listener{BOSAdvertisedHostPlain: "127.0.0.1:5190"}
+
+			outputSNAC, err := svc.ServiceRequest(context.Background(), wire.BOS, instance,
+				wire.SNACFrame{RequestID: 1234}, tc.inputBody, listener)
+
+			if tc.wantErrContains != "" {
+				assert.ErrorContains(t, err, tc.wantErrContains)
+				return
+			}
+			if tc.wantErr != nil {
+				assert.ErrorIs(t, err, tc.wantErr)
+				return
+			}
+			assert.NoError(t, err)
+			assert.Equal(t, tc.expectOutput, outputSNAC)
+		})
+	}
+}
+
 func TestOServiceService_SetUserInfoFields(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
@@ -1822,7 +2008,7 @@ func TestOServiceService_HostOnline(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			svc := NewOServiceService(config.Config{}, nil, slog.Default(), nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), nil, nil, nil)
+			svc := NewOServiceService(config.Config{}, nil, slog.Default(), nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), nil, nil, nil, nil)
 			have := svc.HostOnline(tc.service)
 			assert.Equal(t, tc.expectOutput, have)
 		})
@@ -2855,7 +3041,7 @@ func TestOServiceService_ClientOnline(t *testing.T) {
 					Return(params.err)
 			}
 
-			svc := NewOServiceService(tt.cfg, messageRelayer, slog.Default(), nil, chatRoomManager, nil, nil, nil, wire.DefaultSNACRateLimits(), chatMessageRelayer, profileManager, offlineMessageManager)
+			svc := NewOServiceService(tt.cfg, messageRelayer, slog.Default(), nil, chatRoomManager, nil, nil, nil, wire.DefaultSNACRateLimits(), chatMessageRelayer, profileManager, offlineMessageManager, nil)
 			svc.buddyBroadcaster = buddyUpdateBroadcaster
 			haveErr := svc.ClientOnline(context.Background(), tt.service, tt.bodyIn, tt.instance)
 			assert.ErrorIs(t, haveErr, tt.wantErr)

+ 16 - 0
foodgroup/types.go

@@ -429,3 +429,19 @@ type UserManager interface {
 	// 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
 }
+
+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)
+}

+ 41 - 0
server/http/helpers_test.go

@@ -20,6 +20,7 @@ type mockParams struct {
 	directoryManagerParams
 	feedBagRetrieverParams
 	feedbagManagerParams
+	linkedAccountManagerParams
 	messageRelayerParams
 	profileRetrieverParams
 	sessionRetrieverParams
@@ -353,6 +354,46 @@ type setUserPasswordParams []struct {
 	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.
 func matchContext() interface{} {
 	return mock.MatchedBy(func(ctx any) bool {

+ 109 - 1
server/http/mgmt_api.go

@@ -24,7 +24,7 @@ import (
 	"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, 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, linkedAccountManager LinkedAccountManager, createAccount state.CreateAccountFunc, logger *slog.Logger) *Server {
 	mux := http.NewServeMux()
 
 	// Handlers for '/user' route
@@ -182,6 +182,20 @@ func NewManagementAPI(bld config.Build, listener string, userManager UserManager
 		deleteFeedbagBuddyHandler(w, r, buddyBroadcaster, feedbagManager, sessionRetriever, messageRelayer, logger)
 	})
 
+	// Handlers for '/user/{screenname}/linked-account' routes
+	mux.HandleFunc("GET /user/{screenname}/linked-account", func(w http.ResponseWriter, r *http.Request) {
+		getLinkedAccountsHandler(w, r, userManager, linkedAccountManager)
+	})
+	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)
+	})
+	mux.HandleFunc("DELETE /user/{screenname}/linked-account/{linked_screenname}", func(w http.ResponseWriter, r *http.Request) {
+		deleteLinkedAccountHandler(w, r, userManager, linkedAccountManager)
+	})
+
 	return &Server{
 		server: http.Server{
 			Addr:    listener,
@@ -1399,6 +1413,100 @@ func putICQProfileHandler(w http.ResponseWriter, r *http.Request, mgr ICQProfile
 	w.WriteHeader(http.StatusNoContent)
 }
 
+func getLinkedAccountsHandler(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)
+		return
+	}
+	accounts, err := linkedAccountManager.LinkedAccounts(r.Context(), state.NewIdentScreenName(screenName))
+	if err != nil {
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	type response struct {
+		LinkedAccounts []string `json:"linked_accounts"`
+	}
+	out := response{LinkedAccounts: make([]string, 0, len(accounts))}
+	for _, a := range accounts {
+		out.LinkedAccounts = append(out.LinkedAccounts, a.String())
+	}
+	w.Header().Set("Content-Type", "application/json")
+	if err := json.NewEncoder(w).Encode(out); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+	}
+}
+
+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))
+	if err != nil || user == nil {
+		errorMsg(w, "user not found", http.StatusNotFound)
+		return
+	}
+	var body struct {
+		LinkedScreenName string `json:"linked_screen_name"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.LinkedScreenName == "" {
+		errorMsg(w, "invalid request body", http.StatusBadRequest)
+		return
+	}
+	if state.NewIdentScreenName(screenName) == state.NewIdentScreenName(body.LinkedScreenName) {
+		errorMsg(w, "cannot link an account to itself", http.StatusBadRequest)
+		return
+	}
+	linkedUser, err := userManager.User(r.Context(), state.NewIdentScreenName(body.LinkedScreenName))
+	if err != nil || linkedUser == nil {
+		errorMsg(w, "linked user not found", http.StatusNotFound)
+		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)
+		return
+	}
+	if err != nil {
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		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)
+		return
+	}
+	if err := linkedAccountManager.DeleteAllLinkedAccounts(r.Context(), state.NewIdentScreenName(screenName)); err != nil {
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	w.WriteHeader(http.StatusNoContent)
+}
+
+func deleteLinkedAccountHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, linkedAccountManager LinkedAccountManager) {
+	screenName := r.PathValue("screenname")
+	linkedScreenName := r.PathValue("linked_screenname")
+	user, err := userManager.User(r.Context(), state.NewIdentScreenName(screenName))
+	if err != nil || user == nil {
+		errorMsg(w, "user not found", http.StatusNotFound)
+		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
+	}
+	if err != nil {
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	w.WriteHeader(http.StatusNoContent)
+}
+
 // errorMsg sends an error response message and code.
 func errorMsg(w http.ResponseWriter, error string, code int) {
 	msg := messageBody{Message: error}

+ 581 - 0
server/http/mgmt_api_test.go

@@ -4734,3 +4734,584 @@ func TestRandItemID(t *testing.T) {
 		})
 	}
 }
+
+func TestGetLinkedAccountsHandler(t *testing.T) {
+	tt := []struct {
+		name       string
+		screenname string
+		statusCode int
+		want       string
+		mockParams mockParams
+	}{
+		{
+			name:       "user not found returns 404",
+			screenname: "nobody",
+			statusCode: http.StatusNotFound,
+			want:       `{"message":"user not found"}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("nobody"),
+							result:     nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "returns empty linked accounts list",
+			screenname: "test1",
+			statusCode: http.StatusOK,
+			want:       `{"linked_accounts":[]}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+					},
+				},
+				linkedAccountManagerParams: linkedAccountManagerParams{
+					linkedAccountsParams: linkedAccountsParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     []state.IdentScreenName{},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "returns linked accounts",
+			screenname: "test1",
+			statusCode: http.StatusOK,
+			want:       `{"linked_accounts":["test2","test3"]}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+					},
+				},
+				linkedAccountManagerParams: linkedAccountManagerParams{
+					linkedAccountsParams: linkedAccountsParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result: []state.IdentScreenName{
+								state.NewIdentScreenName("test2"),
+								state.NewIdentScreenName("test3"),
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "linkedAccountManager error returns 500",
+			screenname: "test1",
+			statusCode: http.StatusInternalServerError,
+			want:       `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+					},
+				},
+				linkedAccountManagerParams: linkedAccountManagerParams{
+					linkedAccountsParams: linkedAccountsParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							err:        io.EOF,
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodGet, "/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.linkedAccountsParams {
+				linkedAccountManager.EXPECT().
+					LinkedAccounts(matchContext(), params.screenName).
+					Return(params.result, params.err)
+			}
+
+			getLinkedAccountsHandler(responseRecorder, request, userManager, linkedAccountManager)
+
+			assert.Equal(t, tc.statusCode, responseRecorder.Code)
+			assert.Equal(t, tc.want, strings.TrimSpace(responseRecorder.Body.String()))
+		})
+	}
+}
+
+func TestPostLinkedAccountHandler(t *testing.T) {
+	tt := []struct {
+		name       string
+		screenname string
+		body       string
+		statusCode int
+		want       string
+		mockParams mockParams
+	}{
+		{
+			name:       "primary user not found returns 404",
+			screenname: "nobody",
+			body:       `{"linked_screen_name":"test2"}`,
+			statusCode: http.StatusNotFound,
+			want:       `{"message":"user not found"}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("nobody"),
+							result:     nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "invalid request body returns 400",
+			screenname: "test1",
+			body:       `not json`,
+			statusCode: http.StatusBadRequest,
+			want:       `{"message":"invalid request body"}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "missing linked_screen_name returns 400",
+			screenname: "test1",
+			body:       `{}`,
+			statusCode: http.StatusBadRequest,
+			want:       `{"message":"invalid request body"}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "self-link returns 400",
+			screenname: "test1",
+			body:       `{"linked_screen_name":"test1"}`,
+			statusCode: http.StatusBadRequest,
+			want:       `{"message":"cannot link an account to itself"}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "linked user not found returns 404",
+			screenname: "test1",
+			body:       `{"linked_screen_name":"nobody"}`,
+			statusCode: http.StatusNotFound,
+			want:       `{"message":"linked user not found"}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+						{
+							screenName: state.NewIdentScreenName("nobody"),
+							result:     nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "duplicate link returns 409",
+			screenname: "test1",
+			body:       `{"linked_screen_name":"test2"}`,
+			statusCode: http.StatusConflict,
+			want:       `{"message":"linked account already exists"}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+						{
+							screenName: state.NewIdentScreenName("test2"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test2")},
+						},
+					},
+				},
+				linkedAccountManagerParams: linkedAccountManagerParams{
+					insertLinkedAccountParams: insertLinkedAccountParams{
+						{
+							screenName:       state.NewIdentScreenName("test1"),
+							linkedScreenName: state.NewIdentScreenName("test2"),
+							err:              state.ErrLinkExists,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "insert error returns 500",
+			screenname: "test1",
+			body:       `{"linked_screen_name":"test2"}`,
+			statusCode: http.StatusInternalServerError,
+			want:       `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+						{
+							screenName: state.NewIdentScreenName("test2"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test2")},
+						},
+					},
+				},
+				linkedAccountManagerParams: linkedAccountManagerParams{
+					insertLinkedAccountParams: insertLinkedAccountParams{
+						{
+							screenName:       state.NewIdentScreenName("test1"),
+							linkedScreenName: state.NewIdentScreenName("test2"),
+							err:              io.EOF,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "link created returns 201",
+			screenname: "test1",
+			body:       `{"linked_screen_name":"test2"}`,
+			statusCode: http.StatusCreated,
+			want:       ``,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+						{
+							screenName: state.NewIdentScreenName("test2"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test2")},
+						},
+					},
+				},
+				linkedAccountManagerParams: linkedAccountManagerParams{
+					insertLinkedAccountParams: insertLinkedAccountParams{
+						{
+							screenName:       state.NewIdentScreenName("test1"),
+							linkedScreenName: state.NewIdentScreenName("test2"),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodPost, "/user/"+tc.screenname+"/linked-account", strings.NewReader(tc.body))
+			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.insertLinkedAccountParams {
+				linkedAccountManager.EXPECT().
+					InsertLinkedAccount(matchContext(), params.screenName, params.linkedScreenName).
+					Return(params.err)
+			}
+
+			postLinkedAccountHandler(responseRecorder, request, userManager, linkedAccountManager)
+
+			assert.Equal(t, tc.statusCode, responseRecorder.Code)
+			assert.Equal(t, tc.want, strings.TrimSpace(responseRecorder.Body.String()))
+		})
+	}
+}
+
+func TestDeleteAllLinkedAccountsHandler(t *testing.T) {
+	tt := []struct {
+		name       string
+		screenname string
+		statusCode int
+		want       string
+		mockParams mockParams
+	}{
+		{
+			name:       "user not found returns 404",
+			screenname: "nobody",
+			statusCode: http.StatusNotFound,
+			want:       `{"message":"user not found"}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("nobody"),
+							result:     nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "delete error returns 500",
+			screenname: "test1",
+			statusCode: http.StatusInternalServerError,
+			want:       `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+					},
+				},
+				linkedAccountManagerParams: linkedAccountManagerParams{
+					deleteAllLinkedAccountParams: deleteAllLinkedAccountParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							err:        io.EOF,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:       "all links deleted returns 204",
+			screenname: "test1",
+			statusCode: http.StatusNoContent,
+			want:       ``,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+					},
+				},
+				linkedAccountManagerParams: linkedAccountManagerParams{
+					deleteAllLinkedAccountParams: deleteAllLinkedAccountParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	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{
+						{
+							screenName: state.NewIdentScreenName("nobody"),
+							result:     nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:             "linked account not found returns 404",
+			screenname:       "test1",
+			linkedScreenname: "ghost",
+			statusCode:       http.StatusNotFound,
+			want:             `{"message":"linked account not found"}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+					},
+				},
+				linkedAccountManagerParams: linkedAccountManagerParams{
+					deleteLinkedAccountParams: deleteLinkedAccountParams{
+						{
+							screenName:       state.NewIdentScreenName("test1"),
+							linkedScreenName: state.NewIdentScreenName("ghost"),
+							err:              state.ErrNoUser,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:             "delete error returns 500",
+			screenname:       "test1",
+			linkedScreenname: "test2",
+			statusCode:       http.StatusInternalServerError,
+			want:             `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+					},
+				},
+				linkedAccountManagerParams: linkedAccountManagerParams{
+					deleteLinkedAccountParams: deleteLinkedAccountParams{
+						{
+							screenName:       state.NewIdentScreenName("test1"),
+							linkedScreenName: state.NewIdentScreenName("test2"),
+							err:              io.EOF,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:             "link deleted returns 204",
+			screenname:       "test1",
+			linkedScreenname: "test2",
+			statusCode:       http.StatusNoContent,
+			want:             ``,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("test1"),
+							result:     &state.User{IdentScreenName: state.NewIdentScreenName("test1")},
+						},
+					},
+				},
+				linkedAccountManagerParams: linkedAccountManagerParams{
+					deleteLinkedAccountParams: deleteLinkedAccountParams{
+						{
+							screenName:       state.NewIdentScreenName("test1"),
+							linkedScreenName: state.NewIdentScreenName("test2"),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodDelete, "/user/"+tc.screenname+"/linked-account/"+tc.linkedScreenname, nil)
+			request.SetPathValue("screenname", tc.screenname)
+			request.SetPathValue("linked_screenname", tc.linkedScreenname)
+			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.deleteLinkedAccountParams {
+				linkedAccountManager.EXPECT().
+					DeleteLinkedAccount(matchContext(), params.screenName, params.linkedScreenName).
+					Return(params.err)
+			}
+
+			deleteLinkedAccountHandler(responseRecorder, request, userManager, linkedAccountManager)
+
+			assert.Equal(t, tc.statusCode, responseRecorder.Code)
+			assert.Equal(t, tc.want, strings.TrimSpace(responseRecorder.Body.String()))
+		})
+	}
+}

+ 290 - 0
server/http/mock_linked_account_manager_test.go

@@ -0,0 +1,290 @@
+// 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
+}

+ 8 - 0
server/http/types.go

@@ -195,6 +195,14 @@ type ICQProfileManager interface {
 	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 {
 	ScreenName string `json:"screen_name"`
 	Password   string `json:"password,omitempty"`

+ 1 - 0
state/migrations/0034_linked_accounts.down.sql

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

+ 9 - 0
state/migrations/0034_linked_accounts.up.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
+);

+ 2 - 0
state/user.go

@@ -19,6 +19,8 @@ var (
 	ErrNoUser = errors.New("user does not exist")
 	// ErrNoEmailAddress indicates that a user has not set an 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.

+ 97 - 0
state/user_store.go

@@ -12,6 +12,7 @@ import (
 	"math"
 	"net/http"
 	"net/mail"
+	"slices"
 	"strconv"
 	"strings"
 	"time"
@@ -2681,3 +2682,99 @@ func (f SQLiteUserStore) SetOfflineMsgCount(ctx context.Context, screenName Iden
 	}
 	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
+}

+ 187 - 0
state/user_store_test.go

@@ -4287,6 +4287,193 @@ func TestSQLiteUserStore_ContactPreAuth(t *testing.T) {
 	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) {
 	ctx := context.Background()
 	defer func() {

+ 9 - 7
wire/snacs.go

@@ -336,13 +336,14 @@ const (
 	OServicePrivacyFlagIdle   uint32 = 0x00000001
 	OServicePrivacyFlagMember uint32 = 0x00000002
 
-	OServiceTLVTagsReconnectHere uint16 = 0x05
-	OServiceTLVTagsLoginCookie   uint16 = 0x06
-	OServiceTLVTagsGroupID       uint16 = 0x0D
-	OServiceTLVTagsSSLCertName   uint16 = 0x8D
-	OServiceTLVTagsSSLState      uint16 = 0x8E
-	OserviceTLVTagsSSLUseSSL     uint16 = 0x8C
-	OServiceTLVTagsMOTDMessage   uint16 = 0x0B
+	OServiceTLVTagsLinkedAccounts uint16 = 0x02
+	OServiceTLVTagsReconnectHere  uint16 = 0x05
+	OServiceTLVTagsLoginCookie    uint16 = 0x06
+	OServiceTLVTagsGroupID        uint16 = 0x0D
+	OServiceTLVTagsSSLCertName    uint16 = 0x8D
+	OServiceTLVTagsSSLState       uint16 = 0x8E
+	OserviceTLVTagsSSLUseSSL      uint16 = 0x8C
+	OServiceTLVTagsMOTDMessage    uint16 = 0x0B
 
 	OServiceDiscErrNewLogin   uint8 = 0x01
 	OServiceDiscErrAccDeleted uint8 = 0x02
@@ -1696,6 +1697,7 @@ const (
 	FeedbagAttributesFishIgnoreMode          uint16 = 0x00D3
 	FeedbagAttributesCreateTime              uint16 = 0x00D4
 	FeedbagAttributesBartInfo                uint16 = 0x00D5
+	FeedbagAttributesAlPassword              uint16 = 0x00D5 // roasted password in FeedbagClassIdAlInfo items
 	FeedbagAttributesBuddyPrefsValid         uint16 = 0x00D6
 	FeedbagAttributesBuddyPrefs2             uint16 = 0x00D7
 	FeedbagAttributesBuddyPrefs2Valid        uint16 = 0x00D8