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

implement TOC support

wip

so far

wip

toc: fix away messages, set capabilities, remove user, pd settings, warning

toc: chat support

toc: chat support

toc: chat support

toc: chat support

toc_chat_leave

toc: profile lookups

toc: retrieve profile date, set dir info, warning notifs

toc: idle notification

toc: refactoring

TOC: plug TOC server directly into BOS services

toc: make TIK work

toc: set_config

toc: quickbuddy works, support toc_chat_invite

so far

so far

toc: integrate array unmarshal/unmarshal changes

toc: fix login, surface login errors

toc: so far

toc: so far

toc: happy new year! move profile lookup into separate handler

toc: set/retrieve config

toc: complete dir_info and some dir_search

toc: complete toc_dir_search (incl. HTML)

toc: consolidate proxies, impl keyword search, dir lookup tmpl

toc: wip

toc: run all goroutines in errgroup

toc: mux TCP connection into go http server

toc: add auth cookie to GOTO_URL payload

toc: fix profile display window title

toc: refactor toc arg parsing to make it safer

toc: add test helpers, first unit test, support ICBM auto-reply

toc: more tests, more logging context, listener host config

toc: test toc_set_away and toc_set_caps

toc: test toc_evil, toc_get_info. add exchange to chat registry

toc: test toc_chat_join

toc: test toc_chat_join, toc_chat_accept, toc_chat_send, toc_chat_leave

toc: refactor method signatures and docs

TOC: test toc_set_dir and toc_set_info

toc: test toc_set_idle, toc_dir_search, toc_get_dir

toc: test toc_chat_invite

toc: test toc_set_config

toc: extract incoming request code to separate file

toc: unit test /info http handler

toc: unit test /dir_info and /dir_search http handlers

toc: unit test toc_init and toc_signon

toc: refactor method signatures to return string instead of []byte

update mocks

toc: unit test UPDATE_BUDDY

toc: unit test im_in and eviled, fix warning amount

toc: unit test CHAT_IN, CHAT_UPDATE_BUDDY

toc: unit test client signoff, extract client cmd parsing

toc: consolidate error messaging + logging

toc: fix cookie parsing bug with gaim

toc: documentation
Mike 1 год назад
Родитель
Сommit
070a7e611c
92 измененных файлов с 10497 добавлено и 114 удалено
  1. 39 1
      .mockery.yaml
  2. 72 0
      cmd/server/factory.go
  3. 1 0
      cmd/server/main.go
  4. 2 0
      config/config.go
  5. 3 1
      config/ras.service
  6. 6 0
      config/settings.env
  7. 15 4
      foodgroup/auth.go
  8. 1 1
      foodgroup/auth_test.go
  9. 7 3
      foodgroup/locate.go
  10. 2 2
      foodgroup/locate_test.go
  11. 1 1
      foodgroup/mock_account_manager_test.go
  12. 1 1
      foodgroup/mock_bart_manager_test.go
  13. 1 1
      foodgroup/mock_buddy_broadcaster_test.go
  14. 1 1
      foodgroup/mock_buddy_list_retriever_test.go
  15. 3 3
      foodgroup/mock_chat_message_relayer_test.go
  16. 1 1
      foodgroup/mock_chat_room_registry_test.go
  17. 2 2
      foodgroup/mock_chat_session_registry_test.go
  18. 1 1
      foodgroup/mock_cookie_baker_test.go
  19. 1 1
      foodgroup/mock_feedbag_manager_test.go
  20. 1 1
      foodgroup/mock_icq_user_finder_test.go
  21. 1 1
      foodgroup/mock_icq_user_updater_test.go
  22. 1 1
      foodgroup/mock_local_buddy_list_manager_test.go
  23. 3 3
      foodgroup/mock_message_relayer_test.go
  24. 1 1
      foodgroup/mock_offline_message_manager_test.go
  25. 2 2
      foodgroup/mock_profile_manager_test.go
  26. 2 2
      foodgroup/mock_session_registry_test.go
  27. 1 1
      foodgroup/mock_session_retriever_test.go
  28. 1 1
      foodgroup/mock_user_manager_manager_test.go
  29. 1 1
      server/http/mock_bart_retriever_test.go
  30. 1 1
      server/http/mock_chat_account_retriever_test.go
  31. 1 1
      server/http/mock_chat_room_creator_test.go
  32. 1 1
      server/http/mock_chat_room_retriever_test.go
  33. 1 1
      server/http/mock_chat_session_retriever_test.go
  34. 2 2
      server/http/mock_directory_manager_test.go
  35. 1 1
      server/http/mock_feedbag_retriever_test.go
  36. 2 2
      server/http/mock_message_relayer_test.go
  37. 1 1
      server/http/mock_profile_retriever_test.go
  38. 2 2
      server/http/mock_session_retriever_test.go
  39. 2 2
      server/http/mock_user_manager_test.go
  40. 1 1
      server/oscar/auth.go
  41. 5 1
      server/oscar/bos.go
  42. 1 1
      server/oscar/chat.go
  43. 1 1
      server/oscar/chat_test.go
  44. 1 1
      server/oscar/handler/locate_test.go
  45. 1 1
      server/oscar/handler/mock_admin_test.go
  46. 1 1
      server/oscar/handler/mock_bart_test.go
  47. 1 1
      server/oscar/handler/mock_buddy_test.go
  48. 1 1
      server/oscar/handler/mock_chat_nav_test.go
  49. 1 1
      server/oscar/handler/mock_chat_test.go
  50. 2 2
      server/oscar/handler/mock_feedbag_test.go
  51. 1 1
      server/oscar/handler/mock_icbm_test.go
  52. 1 1
      server/oscar/handler/mock_icq_service_test.go
  53. 1 1
      server/oscar/handler/mock_locate_test.go
  54. 1 1
      server/oscar/handler/mock_odir_service_test.go
  55. 4 4
      server/oscar/handler/mock_oservice_test.go
  56. 1 1
      server/oscar/handler/mock_permit_deny_test.go
  57. 7 7
      server/oscar/handler/mock_response_writer_test.go
  58. 1 1
      server/oscar/handler/mock_user_lookup_service_test.go
  59. 18 17
      server/oscar/mock_auth_test.go
  60. 1 1
      server/oscar/mock_handler_test.go
  61. 2 2
      server/oscar/mock_online_notifier_test.go
  62. 1378 0
      server/toc/cmd_client.go
  63. 3425 0
      server/toc/cmd_client_test.go
  64. 292 0
      server/toc/cmd_server.go
  65. 566 0
      server/toc/cmd_server_test.go
  66. 373 0
      server/toc/helpers_test.go
  67. 360 0
      server/toc/http.go
  68. 611 0
      server/toc/http_test.go
  69. 456 0
      server/toc/mock_auth_service_test.go
  70. 127 0
      server/toc/mock_buddy_list_registry_test.go
  71. 229 0
      server/toc/mock_buddy_service_test.go
  72. 261 0
      server/toc/mock_chat_nav_service_test.go
  73. 100 0
      server/toc/mock_chat_service_test.go
  74. 148 0
      server/toc/mock_cookie_baker_test.go
  75. 95 0
      server/toc/mock_dir_search_service_test.go
  76. 304 0
      server/toc/mock_icbm_service_test.go
  77. 263 0
      server/toc/mock_locate_service_test.go
  78. 194 0
      server/toc/mock_oservice_service_test.go
  79. 278 0
      server/toc/mock_permit_deny_service_test.go
  80. 140 0
      server/toc/mock_toc_config_store_test.go
  81. 373 0
      server/toc/server.go
  82. 1 0
      server/toc/server_test.go
  83. 90 0
      server/toc/types.go
  84. 0 0
      state/migrations/0011_toc_config.down.sql
  85. 2 0
      state/migrations/0011_toc_config.up.sql
  86. 13 2
      state/user.go
  87. 32 4
      state/user_store.go
  88. 6 6
      state/user_store_test.go
  89. 15 0
      wire/frames.go
  90. 63 1
      wire/snacs.go
  91. 48 0
      wire/snacs_test.go
  92. 12 0
      wire/user.go

+ 39 - 1
.mockery.yaml

@@ -157,4 +157,42 @@ packages:
           filename: "mock_session_retriever_test.go"
       UserManager:
         config:
-          filename: "mock_user_manager_manager_test.go"
+          filename: "mock_user_manager_manager_test.go"
+  github.com/mk6i/retro-aim-server/server/toc:
+    interfaces:
+      BuddyService:
+        config:
+          filename: "mock_buddy_service_test.go"
+      ChatService:
+        config:
+          filename: "mock_chat_service_test.go"
+      ChatNavService:
+        config:
+          filename: "mock_chat_nav_service_test.go"
+      ICBMService:
+        config:
+          filename: "mock_icbm_service_test.go"
+      OServiceService:
+        config:
+          filename: "mock_oservice_service_test.go"
+      AuthService:
+        config:
+          filename: "mock_auth_service_test.go"
+      LocateService:
+        config:
+          filename: "mock_locate_service_test.go"
+      DirSearchService:
+        config:
+          filename: "mock_dir_search_service_test.go"
+      PermitDenyService:
+        config:
+          filename: "mock_permit_deny_service_test.go"
+      BuddyListRegistry:
+        config:
+          filename: "mock_buddy_list_registry_test.go"
+      TOCConfigStore:
+        config:
+          filename: "mock_toc_config_store_test.go"
+      CookieBaker:
+        config:
+          filename: "mock_cookie_baker_test.go"

+ 72 - 0
cmd/server/factory.go

@@ -14,6 +14,7 @@ import (
 	"github.com/mk6i/retro-aim-server/server/oscar"
 	"github.com/mk6i/retro-aim-server/server/oscar/handler"
 	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/server/toc"
 	"github.com/mk6i/retro-aim-server/state"
 )
 
@@ -409,3 +410,74 @@ func ODir(deps Container) oscar.BOSServer {
 		ListenAddr:     net.JoinHostPort("", deps.cfg.ODirPort),
 	}
 }
+
+// TOC creates a TOC server.
+func TOC(deps Container) toc.Server {
+	logger := deps.logger.With("svc", "TOC")
+	sessionManager := state.NewInMemorySessionManager(logger)
+	return toc.Server{
+		Logger:     logger,
+		ListenAddr: net.JoinHostPort(deps.cfg.TOCHost, deps.cfg.TOCPort),
+		BOSProxy: toc.OSCARProxy{
+			AuthService: foodgroup.NewAuthService(
+				deps.cfg,
+				deps.inMemorySessionManager,
+				deps.chatSessionManager,
+				deps.sqLiteUserStore,
+				deps.hmacCookieBaker,
+				deps.chatSessionManager,
+				deps.sqLiteUserStore,
+				nil,
+			),
+			BuddyListRegistry: deps.sqLiteUserStore,
+			BuddyService: foodgroup.NewBuddyService(
+				deps.inMemorySessionManager,
+				deps.sqLiteUserStore,
+				deps.sqLiteUserStore,
+				deps.inMemorySessionManager,
+			),
+			CookieBaker:      deps.hmacCookieBaker,
+			DirSearchService: foodgroup.NewODirService(logger, deps.sqLiteUserStore),
+			ICBMService: foodgroup.NewICBMService(
+				deps.inMemorySessionManager,
+				deps.sqLiteUserStore,
+				deps.sqLiteUserStore,
+				deps.inMemorySessionManager,
+			),
+			LocateService: foodgroup.NewLocateService(
+				deps.inMemorySessionManager,
+				deps.sqLiteUserStore,
+				deps.sqLiteUserStore,
+				deps.inMemorySessionManager,
+			),
+			Logger: logger,
+			OServiceServiceBOS: foodgroup.NewOServiceServiceForBOS(
+				deps.cfg,
+				deps.inMemorySessionManager,
+				logger,
+				deps.hmacCookieBaker,
+				deps.sqLiteUserStore,
+				deps.sqLiteUserStore,
+				deps.inMemorySessionManager,
+			),
+			PermitDenyService: foodgroup.NewPermitDenyService(
+				deps.sqLiteUserStore,
+				deps.sqLiteUserStore,
+				deps.inMemorySessionManager,
+				deps.inMemorySessionManager,
+			),
+			TOCConfigStore: deps.sqLiteUserStore,
+			ChatService:    foodgroup.NewChatService(deps.chatSessionManager),
+			OServiceServiceChat: foodgroup.NewOServiceServiceForChat(
+				deps.cfg,
+				logger,
+				sessionManager,
+				deps.sqLiteUserStore,
+				deps.chatSessionManager,
+				deps.sqLiteUserStore,
+				sessionManager,
+			),
+			ChatNavService: foodgroup.NewChatNavService(logger, deps.sqLiteUserStore),
+		},
+	}
+}

+ 1 - 0
cmd/server/main.go

@@ -73,6 +73,7 @@ func main() {
 	start(ChatNav(deps))
 	start(MgmtAPI(deps))
 	start(ODir(deps))
+	start(TOC(deps))
 
 	if err := g.Wait(); err != nil {
 		fmt.Println(err.Error())

+ 2 - 0
config/config.go

@@ -16,6 +16,8 @@ type Config struct {
 	DisableAuth bool   `envconfig:"DISABLE_AUTH" required:"true" val:"true" description:"Disable password check and auto-create new users at login time. Useful for quickly creating new accounts during development without having to register new users via the management API."`
 	LogLevel    string `envconfig:"LOG_LEVEL" required:"true" val:"info" description:"Set logging granularity. Possible values: 'trace', 'debug', 'info', 'warn', 'error'."`
 	OSCARHost   string `envconfig:"OSCAR_HOST" required:"true" val:"127.0.0.1" description:"The hostname that AIM clients connect to in order to reach OSCAR services (auth, BOS, BUCP, etc). Make sure the hostname is reachable by all clients. For local development, the default loopback address should work provided the server and AIM client(s) are running on the same machine. For LAN-only clients, a private IP address (e.g. 192.168..) or hostname should suffice. For clients connecting over the Internet, specify your public IP address and ensure that TCP ports 5190-5197 are open on your firewall."`
+	TOCHost     string `envconfig:"TOC_HOST" require:"true" val:"127.0.0.1" description:"The hostname or address the TOC service listens on."`
+	TOCPort     string `envconfig:"TOC_PORT" required:"true" val:"9898" description:"The port that the TOC service binds to."`
 }
 
 type Build struct {

+ 3 - 1
config/ras.service

@@ -7,8 +7,8 @@ User=ras
 Group=ras
 Environment="ADMIN_PORT=5196"
 Environment="ALERT_PORT=5194"
-Environment="AUTH_PORT=5190"
 Environment="API_PORT=8080"
+Environment="AUTH_PORT=5190"
 Environment="BART_PORT=5195"
 Environment="BOS_PORT=5191"
 Environment="CHAT_NAV_PORT=5193"
@@ -18,6 +18,8 @@ Environment="DISABLE_AUTH=true"
 Environment="LOG_LEVEL=info"
 Environment="ODIR_PORT=5197"
 Environment="OSCAR_HOST=127.0.0.1"
+Environment="TOC_HOST=127.0.0.1"
+Environment="TOC_PORT=9898"
 ExecStart=/opt/ras/retro_aim_server
 Restart=on-failure
 

+ 6 - 0
config/settings.env

@@ -50,3 +50,9 @@ export LOG_LEVEL=info
 # ensure that TCP ports 5190-5197 are open on your firewall.
 export OSCAR_HOST=127.0.0.1
 
+# The hostname or address the TOC service listens on.
+export TOC_HOST=127.0.0.1
+
+# The port that the TOC service binds to.
+export TOC_PORT=9898
+

+ 15 - 4
foodgroup/auth.go

@@ -61,7 +61,7 @@ type AuthService struct {
 // This method does not verify that the user and chat room exist because it
 // implicitly trusts the contents of the token signed by
 // {{OServiceService.ServiceRequest}}.
-func (s AuthService) RegisterChatSession(authCookie []byte) (*state.Session, error) {
+func (s AuthService) RegisterChatSession(ctx context.Context, authCookie []byte) (*state.Session, error) {
 	token, err := s.cookieBaker.Crack(authCookie)
 	if err != nil {
 		return nil, err
@@ -70,7 +70,7 @@ func (s AuthService) RegisterChatSession(authCookie []byte) (*state.Session, err
 	if err := wire.UnmarshalBE(&c, bytes.NewBuffer(token)); err != nil {
 		return nil, err
 	}
-	sess, err := s.chatSessionRegistry.AddSession(nil, c.ChatCookie, c.ScreenName)
+	sess, err := s.chatSessionRegistry.AddSession(ctx, c.ChatCookie, c.ScreenName)
 	if err != nil {
 		return nil, fmt.Errorf("AddSession: %w", err)
 	}
@@ -282,6 +282,7 @@ type loginProperties struct {
 	screenName   state.DisplayScreenName
 	clientID     string
 	isBUCPAuth   bool
+	isTOCAuth    bool
 	passwordHash []byte
 	roastedPass  []byte
 }
@@ -315,6 +316,12 @@ func (l *loginProperties) fromTLV(list wire.TLVList) error {
 		l.roastedPass = roastedPass
 	}
 
+	// extract roasted password for TOC FLAP login
+	if roastedPass, found := list.Bytes(wire.LoginTLVTagsRoastedTOCPassword); found {
+		l.roastedPass = roastedPass
+		l.isTOCAuth = true
+	}
+
 	return nil
 }
 
@@ -355,11 +362,15 @@ func (s AuthService) login(
 	}
 
 	var loginOK bool
-	if props.isBUCPAuth {
+	switch {
+	case props.isBUCPAuth:
 		loginOK = user.ValidateHash(props.passwordHash)
-	} else {
+	case props.isTOCAuth:
+		loginOK = user.ValidateRoastedTOCPass(props.roastedPass)
+	default:
 		loginOK = user.ValidateRoastedPass(props.roastedPass)
 	}
+
 	if !loginOK {
 		return loginFailureResponse(props, wire.LoginErrInvalidPassword), nil
 	}

+ 1 - 1
foodgroup/auth_test.go

@@ -1102,7 +1102,7 @@ func TestAuthService_RegisterChatSession_HappyPath(t *testing.T) {
 
 	svc := NewAuthService(config.Config{}, nil, chatSessionRegistry, nil, cookieBaker, nil, nil, nil)
 
-	have, err := svc.RegisterChatSession(authCookie)
+	have, err := svc.RegisterChatSession(context.Background(), authCookie)
 	assert.NoError(t, err)
 	assert.Equal(t, sess, have)
 }

+ 7 - 3
foodgroup/locate.go

@@ -104,6 +104,11 @@ func (s LocateService) SetInfo(ctx context.Context, sess *state.Session, inBody
 			caps = append(caps, c)
 		}
 		sess.SetCaps(caps)
+		if sess.SignonComplete() {
+			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+				return err
+			}
+		}
 	}
 
 	return nil
@@ -124,8 +129,7 @@ func newLocateErr(requestID uint32, errCode uint16) wire.SNACMessage {
 
 // UserInfoQuery fetches display information about an arbitrary user (not the
 // current user). It returns wire.LocateUserInfoReply, which contains the
-// profile, if requested, and/or the away message, if requested. This is a v2
-// of UserInfoQuery.
+// profile, if requested, and/or the away message, if requested.
 func (s LocateService) UserInfoQuery(_ context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
 	identScreenName := state.NewIdentScreenName(inBody.ScreenName)
 
@@ -243,7 +247,7 @@ func (s LocateService) DirInfo(ctx context.Context, inFrame wire.SNACFrame, body
 		},
 	}
 
-	user, err := s.profileManager.User(state.NewIdentScreenName(body.WatcherScreenNames))
+	user, err := s.profileManager.User(state.NewIdentScreenName(body.ScreenName))
 	if err != nil {
 		return wire.SNACMessage{}, fmt.Errorf("User: %w", err)
 	}

+ 2 - 2
foodgroup/locate_test.go

@@ -836,7 +836,7 @@ func TestLocateService_DirInfo(t *testing.T) {
 					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x02_0x0B_LocateGetDirInfo{
-					WatcherScreenNames: "test-user",
+					ScreenName: "test-user",
 				},
 			},
 			expectOutput: wire.SNACMessage{
@@ -895,7 +895,7 @@ func TestLocateService_DirInfo(t *testing.T) {
 					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x02_0x0B_LocateGetDirInfo{
-					WatcherScreenNames: "test-user",
+					ScreenName: "test-user",
 				},
 			},
 			expectOutput: wire.SNACMessage{

+ 1 - 1
foodgroup/mock_account_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 

+ 1 - 1
foodgroup/mock_bart_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 

+ 1 - 1
foodgroup/mock_buddy_broadcaster_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 

+ 1 - 1
foodgroup/mock_buddy_list_retriever_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 

+ 3 - 3
foodgroup/mock_chat_message_relayer_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 
@@ -104,7 +104,7 @@ func (_c *mockChatMessageRelayer_RelayToAllExcept_Call) Return() *mockChatMessag
 }
 
 func (_c *mockChatMessageRelayer_RelayToAllExcept_Call) RunAndReturn(run func(context.Context, string, state.IdentScreenName, wire.SNACMessage)) *mockChatMessageRelayer_RelayToAllExcept_Call {
-	_c.Call.Return(run)
+	_c.Run(run)
 	return _c
 }
 
@@ -140,7 +140,7 @@ func (_c *mockChatMessageRelayer_RelayToScreenName_Call) Return() *mockChatMessa
 }
 
 func (_c *mockChatMessageRelayer_RelayToScreenName_Call) RunAndReturn(run func(context.Context, string, state.IdentScreenName, wire.SNACMessage)) *mockChatMessageRelayer_RelayToScreenName_Call {
-	_c.Call.Return(run)
+	_c.Run(run)
 	return _c
 }
 

+ 1 - 1
foodgroup/mock_chat_room_registry_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 

+ 2 - 2
foodgroup/mock_chat_session_registry_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 
@@ -111,7 +111,7 @@ func (_c *mockChatSessionRegistry_RemoveSession_Call) Return() *mockChatSessionR
 }
 
 func (_c *mockChatSessionRegistry_RemoveSession_Call) RunAndReturn(run func(*state.Session)) *mockChatSessionRegistry_RemoveSession_Call {
-	_c.Call.Return(run)
+	_c.Run(run)
 	return _c
 }
 

+ 1 - 1
foodgroup/mock_cookie_baker_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 

+ 1 - 1
foodgroup/mock_feedbag_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 

+ 1 - 1
foodgroup/mock_icq_user_finder_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 

+ 1 - 1
foodgroup/mock_icq_user_updater_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 

+ 1 - 1
foodgroup/mock_local_buddy_list_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 

+ 3 - 3
foodgroup/mock_message_relayer_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 
@@ -55,7 +55,7 @@ func (_c *mockMessageRelayer_RelayToScreenName_Call) Return() *mockMessageRelaye
 }
 
 func (_c *mockMessageRelayer_RelayToScreenName_Call) RunAndReturn(run func(context.Context, state.IdentScreenName, wire.SNACMessage)) *mockMessageRelayer_RelayToScreenName_Call {
-	_c.Call.Return(run)
+	_c.Run(run)
 	return _c
 }
 
@@ -90,7 +90,7 @@ func (_c *mockMessageRelayer_RelayToScreenNames_Call) Return() *mockMessageRelay
 }
 
 func (_c *mockMessageRelayer_RelayToScreenNames_Call) RunAndReturn(run func(context.Context, []state.IdentScreenName, wire.SNACMessage)) *mockMessageRelayer_RelayToScreenNames_Call {
-	_c.Call.Return(run)
+	_c.Run(run)
 	return _c
 }
 

+ 1 - 1
foodgroup/mock_offline_message_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 

+ 2 - 2
foodgroup/mock_profile_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 
@@ -194,7 +194,7 @@ func (_c *mockProfileManager_FindByAIMNameAndAddr_Call) RunAndReturn(run func(st
 	return _c
 }
 
-// InterestList provides a mock function with given fields:
+// InterestList provides a mock function with no fields
 func (_m *mockProfileManager) InterestList() ([]wire.ODirKeywordListItem, error) {
 	ret := _m.Called()
 

+ 2 - 2
foodgroup/mock_session_registry_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 
@@ -110,7 +110,7 @@ func (_c *mockSessionRegistry_RemoveSession_Call) Return() *mockSessionRegistry_
 }
 
 func (_c *mockSessionRegistry_RemoveSession_Call) RunAndReturn(run func(*state.Session)) *mockSessionRegistry_RemoveSession_Call {
-	_c.Call.Return(run)
+	_c.Run(run)
 	return _c
 }
 

+ 1 - 1
foodgroup/mock_session_retriever_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 

+ 1 - 1
foodgroup/mock_user_manager_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package foodgroup
 

+ 1 - 1
server/http/mock_bart_retriever_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package http
 

+ 1 - 1
server/http/mock_chat_account_retriever_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package http
 

+ 1 - 1
server/http/mock_chat_room_creator_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package http
 

+ 1 - 1
server/http/mock_chat_room_retriever_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package http
 

+ 1 - 1
server/http/mock_chat_session_retriever_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package http
 

+ 2 - 2
server/http/mock_directory_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package http
 
@@ -20,7 +20,7 @@ func (_m *mockDirectoryManager) EXPECT() *mockDirectoryManager_Expecter {
 	return &mockDirectoryManager_Expecter{mock: &_m.Mock}
 }
 
-// Categories provides a mock function with given fields:
+// Categories provides a mock function with no fields
 func (_m *mockDirectoryManager) Categories() ([]state.Category, error) {
 	ret := _m.Called()
 

+ 1 - 1
server/http/mock_feedbag_retriever_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package http
 

+ 2 - 2
server/http/mock_message_relayer_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package http
 
@@ -55,7 +55,7 @@ func (_c *mockMessageRelayer_RelayToScreenName_Call) Return() *mockMessageRelaye
 }
 
 func (_c *mockMessageRelayer_RelayToScreenName_Call) RunAndReturn(run func(context.Context, state.IdentScreenName, wire.SNACMessage)) *mockMessageRelayer_RelayToScreenName_Call {
-	_c.Call.Return(run)
+	_c.Run(run)
 	return _c
 }
 

+ 1 - 1
server/http/mock_profile_retriever_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package http
 

+ 2 - 2
server/http/mock_session_retriever_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package http
 
@@ -20,7 +20,7 @@ func (_m *mockSessionRetriever) EXPECT() *mockSessionRetriever_Expecter {
 	return &mockSessionRetriever_Expecter{mock: &_m.Mock}
 }
 
-// AllSessions provides a mock function with given fields:
+// AllSessions provides a mock function with no fields
 func (_m *mockSessionRetriever) AllSessions() []*state.Session {
 	ret := _m.Called()
 

+ 2 - 2
server/http/mock_user_manager_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package http
 
@@ -20,7 +20,7 @@ func (_m *mockUserManager) EXPECT() *mockUserManager_Expecter {
 	return &mockUserManager_Expecter{mock: &_m.Mock}
 }
 
-// AllUsers provides a mock function with given fields:
+// AllUsers provides a mock function with no fields
 func (_m *mockUserManager) AllUsers() ([]state.User, error) {
 	ret := _m.Called()
 

+ 1 - 1
server/oscar/auth.go

@@ -22,7 +22,7 @@ type AuthService interface {
 	FLAPLogin(frame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error)
 	RegisterBOSSession(ctx context.Context, authCookie []byte) (*state.Session, error)
 	RetrieveBOSSession(authCookie []byte) (*state.Session, error)
-	RegisterChatSession(authCookie []byte) (*state.Session, error)
+	RegisterChatSession(ctx context.Context, authCookie []byte) (*state.Session, error)
 	Signout(ctx context.Context, sess *state.Session)
 	SignoutChat(ctx context.Context, sess *state.Session)
 }

+ 5 - 1
server/oscar/bos.go

@@ -124,6 +124,10 @@ func waitForShutdown(wg *sync.WaitGroup) bool {
 }
 
 func (rt BOSServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser) error {
+	defer func() {
+		rwc.Close()
+	}()
+
 	flapc := wire.NewFlapClient(100, rwc, rwc)
 
 	if err := flapc.SendSignonFrame(nil); err != nil {
@@ -148,6 +152,7 @@ func (rt BOSServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteClo
 	}
 
 	if rt.BuddyListRegistry != nil { // nil check is a hack until server refactor
+		// todo should this check be below defer()?
 		if err := rt.BuddyListRegistry.RegisterBuddyList(sess.IdentScreenName()); err != nil {
 			return fmt.Errorf("unable to init buddy list: %w", err)
 		}
@@ -155,7 +160,6 @@ func (rt BOSServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteClo
 
 	defer func() {
 		sess.Close()
-		rwc.Close()
 		if rt.DepartureNotifier != nil {
 			if err := rt.DepartureNotifier.BroadcastBuddyDeparted(ctx, sess); err != nil {
 				rt.Logger.ErrorContext(ctx, "error sending buddy departure notifications", "err", err.Error())

+ 1 - 1
server/oscar/chat.go

@@ -85,7 +85,7 @@ func (rt ChatServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteCl
 		return errors.New("unable to get login cookie from payload")
 	}
 
-	chatSess, err := rt.RegisterChatSession(authCookie)
+	chatSess, err := rt.RegisterChatSession(ctx, authCookie)
 	if err != nil {
 		return err
 	}

+ 1 - 1
server/oscar/chat_test.go

@@ -59,7 +59,7 @@ func TestChatService_handleNewConnection(t *testing.T) {
 
 	authService := newMockAuthService(t)
 	authService.EXPECT().
-		RegisterChatSession([]byte(`the-chat-login-cookie`)).
+		RegisterChatSession(mock.Anything, []byte(`the-chat-login-cookie`)).
 		Return(sess, nil)
 	authService.EXPECT().
 		SignoutChat(mock.Anything, sess)

+ 1 - 1
server/oscar/handler/locate_test.go

@@ -18,7 +18,7 @@ func TestLocateHandler_GetDirInfo(t *testing.T) {
 			SubGroup:  wire.LocateGetDirInfo,
 		},
 		Body: wire.SNAC_0x02_0x0B_LocateGetDirInfo{
-			WatcherScreenNames: "screen-name",
+			ScreenName: "screen-name",
 		},
 	}
 	output := wire.SNACMessage{

+ 1 - 1
server/oscar/handler/mock_admin_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package handler
 

+ 1 - 1
server/oscar/handler/mock_bart_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package handler
 

+ 1 - 1
server/oscar/handler/mock_buddy_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package handler
 

+ 1 - 1
server/oscar/handler/mock_chat_nav_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package handler
 

+ 1 - 1
server/oscar/handler/mock_chat_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package handler
 

+ 2 - 2
server/oscar/handler/mock_feedbag_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package handler
 
@@ -327,7 +327,7 @@ func (_c *mockFeedbagService_StartCluster_Call) Return() *mockFeedbagService_Sta
 }
 
 func (_c *mockFeedbagService_StartCluster_Call) RunAndReturn(run func(context.Context, wire.SNACFrame, wire.SNAC_0x13_0x11_FeedbagStartCluster)) *mockFeedbagService_StartCluster_Call {
-	_c.Call.Return(run)
+	_c.Run(run)
 	return _c
 }
 

+ 1 - 1
server/oscar/handler/mock_icbm_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package handler
 

+ 1 - 1
server/oscar/handler/mock_icq_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package handler
 

+ 1 - 1
server/oscar/handler/mock_locate_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package handler
 

+ 1 - 1
server/oscar/handler/mock_odir_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package handler
 

+ 4 - 4
server/oscar/handler/mock_oservice_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package handler
 
@@ -120,7 +120,7 @@ func (_c *mockOServiceService_ClientVersions_Call) RunAndReturn(run func(context
 	return _c
 }
 
-// HostOnline provides a mock function with given fields:
+// HostOnline provides a mock function with no fields
 func (_m *mockOServiceService) HostOnline() wire.SNACMessage {
 	ret := _m.Called()
 
@@ -291,7 +291,7 @@ func (_c *mockOServiceService_RateParamsSubAdd_Call) Return() *mockOServiceServi
 }
 
 func (_c *mockOServiceService_RateParamsSubAdd_Call) RunAndReturn(run func(context.Context, wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd)) *mockOServiceService_RateParamsSubAdd_Call {
-	_c.Call.Return(run)
+	_c.Run(run)
 	return _c
 }
 
@@ -384,7 +384,7 @@ func (_c *mockOServiceService_SetPrivacyFlags_Call) Return() *mockOServiceServic
 }
 
 func (_c *mockOServiceService_SetPrivacyFlags_Call) RunAndReturn(run func(context.Context, wire.SNAC_0x01_0x14_OServiceSetPrivacyFlags)) *mockOServiceService_SetPrivacyFlags_Call {
-	_c.Call.Return(run)
+	_c.Run(run)
 	return _c
 }
 

+ 1 - 1
server/oscar/handler/mock_permit_deny_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package handler
 

+ 7 - 7
server/oscar/handler/mock_response_writer_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package handler
 
@@ -22,7 +22,7 @@ func (_m *mockResponseWriter) EXPECT() *mockResponseWriter_Expecter {
 }
 
 // SendSNAC provides a mock function with given fields: frame, body
-func (_m *mockResponseWriter) SendSNAC(frame wire.SNACFrame, body any) error {
+func (_m *mockResponseWriter) SendSNAC(frame wire.SNACFrame, body interface{}) error {
 	ret := _m.Called(frame, body)
 
 	if len(ret) == 0 {
@@ -30,7 +30,7 @@ func (_m *mockResponseWriter) SendSNAC(frame wire.SNACFrame, body any) error {
 	}
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(wire.SNACFrame, any) error); ok {
+	if rf, ok := ret.Get(0).(func(wire.SNACFrame, interface{}) error); ok {
 		r0 = rf(frame, body)
 	} else {
 		r0 = ret.Error(0)
@@ -46,14 +46,14 @@ type mockResponseWriter_SendSNAC_Call struct {
 
 // SendSNAC is a helper method to define mock.On call
 //   - frame wire.SNACFrame
-//   - body any
+//   - body interface{}
 func (_e *mockResponseWriter_Expecter) SendSNAC(frame interface{}, body interface{}) *mockResponseWriter_SendSNAC_Call {
 	return &mockResponseWriter_SendSNAC_Call{Call: _e.mock.On("SendSNAC", frame, body)}
 }
 
-func (_c *mockResponseWriter_SendSNAC_Call) Run(run func(frame wire.SNACFrame, body any)) *mockResponseWriter_SendSNAC_Call {
+func (_c *mockResponseWriter_SendSNAC_Call) Run(run func(frame wire.SNACFrame, body interface{})) *mockResponseWriter_SendSNAC_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(wire.SNACFrame), args[1].(any))
+		run(args[0].(wire.SNACFrame), args[1].(interface{}))
 	})
 	return _c
 }
@@ -63,7 +63,7 @@ func (_c *mockResponseWriter_SendSNAC_Call) Return(_a0 error) *mockResponseWrite
 	return _c
 }
 
-func (_c *mockResponseWriter_SendSNAC_Call) RunAndReturn(run func(wire.SNACFrame, any) error) *mockResponseWriter_SendSNAC_Call {
+func (_c *mockResponseWriter_SendSNAC_Call) RunAndReturn(run func(wire.SNACFrame, interface{}) error) *mockResponseWriter_SendSNAC_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 1 - 1
server/oscar/handler/mock_user_lookup_service_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package handler
 

+ 18 - 17
server/oscar/mock_auth_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package oscar
 
@@ -256,9 +256,9 @@ func (_c *mockAuthService_RegisterBOSSession_Call) RunAndReturn(run func(context
 	return _c
 }
 
-// RegisterChatSession provides a mock function with given fields: authCookie
-func (_m *mockAuthService) RegisterChatSession(authCookie []byte) (*state.Session, error) {
-	ret := _m.Called(authCookie)
+// RegisterChatSession provides a mock function with given fields: ctx, authCookie
+func (_m *mockAuthService) RegisterChatSession(ctx context.Context, authCookie []byte) (*state.Session, error) {
+	ret := _m.Called(ctx, authCookie)
 
 	if len(ret) == 0 {
 		panic("no return value specified for RegisterChatSession")
@@ -266,19 +266,19 @@ func (_m *mockAuthService) RegisterChatSession(authCookie []byte) (*state.Sessio
 
 	var r0 *state.Session
 	var r1 error
-	if rf, ok := ret.Get(0).(func([]byte) (*state.Session, error)); ok {
-		return rf(authCookie)
+	if rf, ok := ret.Get(0).(func(context.Context, []byte) (*state.Session, error)); ok {
+		return rf(ctx, authCookie)
 	}
-	if rf, ok := ret.Get(0).(func([]byte) *state.Session); ok {
-		r0 = rf(authCookie)
+	if rf, ok := ret.Get(0).(func(context.Context, []byte) *state.Session); ok {
+		r0 = rf(ctx, authCookie)
 	} else {
 		if ret.Get(0) != nil {
 			r0 = ret.Get(0).(*state.Session)
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func([]byte) error); ok {
-		r1 = rf(authCookie)
+	if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
+		r1 = rf(ctx, authCookie)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -292,14 +292,15 @@ type mockAuthService_RegisterChatSession_Call struct {
 }
 
 // RegisterChatSession is a helper method to define mock.On call
+//   - ctx context.Context
 //   - authCookie []byte
-func (_e *mockAuthService_Expecter) RegisterChatSession(authCookie interface{}) *mockAuthService_RegisterChatSession_Call {
-	return &mockAuthService_RegisterChatSession_Call{Call: _e.mock.On("RegisterChatSession", authCookie)}
+func (_e *mockAuthService_Expecter) RegisterChatSession(ctx interface{}, authCookie interface{}) *mockAuthService_RegisterChatSession_Call {
+	return &mockAuthService_RegisterChatSession_Call{Call: _e.mock.On("RegisterChatSession", ctx, authCookie)}
 }
 
-func (_c *mockAuthService_RegisterChatSession_Call) Run(run func(authCookie []byte)) *mockAuthService_RegisterChatSession_Call {
+func (_c *mockAuthService_RegisterChatSession_Call) Run(run func(ctx context.Context, authCookie []byte)) *mockAuthService_RegisterChatSession_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].([]byte))
+		run(args[0].(context.Context), args[1].([]byte))
 	})
 	return _c
 }
@@ -309,7 +310,7 @@ func (_c *mockAuthService_RegisterChatSession_Call) Return(_a0 *state.Session, _
 	return _c
 }
 
-func (_c *mockAuthService_RegisterChatSession_Call) RunAndReturn(run func([]byte) (*state.Session, error)) *mockAuthService_RegisterChatSession_Call {
+func (_c *mockAuthService_RegisterChatSession_Call) RunAndReturn(run func(context.Context, []byte) (*state.Session, error)) *mockAuthService_RegisterChatSession_Call {
 	_c.Call.Return(run)
 	return _c
 }
@@ -402,7 +403,7 @@ func (_c *mockAuthService_Signout_Call) Return() *mockAuthService_Signout_Call {
 }
 
 func (_c *mockAuthService_Signout_Call) RunAndReturn(run func(context.Context, *state.Session)) *mockAuthService_Signout_Call {
-	_c.Call.Return(run)
+	_c.Run(run)
 	return _c
 }
 
@@ -436,7 +437,7 @@ func (_c *mockAuthService_SignoutChat_Call) Return() *mockAuthService_SignoutCha
 }
 
 func (_c *mockAuthService_SignoutChat_Call) RunAndReturn(run func(context.Context, *state.Session)) *mockAuthService_SignoutChat_Call {
-	_c.Call.Return(run)
+	_c.Run(run)
 	return _c
 }
 

+ 1 - 1
server/oscar/mock_handler_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package oscar
 

+ 2 - 2
server/oscar/mock_online_notifier_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.46.3. DO NOT EDIT.
+// Code generated by mockery v2.51.1. DO NOT EDIT.
 
 package oscar
 
@@ -20,7 +20,7 @@ func (_m *mockOnlineNotifier) EXPECT() *mockOnlineNotifier_Expecter {
 	return &mockOnlineNotifier_Expecter{mock: &_m.Mock}
 }
 
-// HostOnline provides a mock function with given fields:
+// HostOnline provides a mock function with no fields
 func (_m *mockOnlineNotifier) HostOnline() wire.SNACMessage {
 	ret := _m.Called()
 

+ 1378 - 0
server/toc/cmd_client.go

@@ -0,0 +1,1378 @@
+package toc
+
+import (
+	"bytes"
+	"context"
+	"encoding/csv"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"log/slog"
+	"net/url"
+	"strconv"
+	"strings"
+	"sync"
+
+	"github.com/google/uuid"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+var (
+	// capChat is the UUID that represents an OSCAR client's ability to chat
+	capChat = uuid.MustParse("748F2420-6287-11D1-8222-444553540000")
+)
+
+// NewChatRegistry creates a new ChatRegistry instances.
+func NewChatRegistry() *ChatRegistry {
+	chatRegistry := &ChatRegistry{
+		lookup:   make(map[int]wire.ICBMRoomInfo),
+		sessions: make(map[int]*state.Session),
+		m:        sync.RWMutex{},
+	}
+	return chatRegistry
+}
+
+// ChatRegistry manages the chat rooms that a user is connected to during a TOC
+// session. It maintains mappings between chat room identifiers, metadata, and
+// active chat sessions.
+//
+// This struct provides thread-safe operations for adding, retrieving, and managing
+// chat room metadata and associated sessions.
+type ChatRegistry struct {
+	lookup   map[int]wire.ICBMRoomInfo // Maps chat room IDs to their metadata.
+	sessions map[int]*state.Session    // Tracks active chat sessions by chat room ID.
+	nextID   int                       // Incremental identifier for newly added chat rooms.
+	m        sync.RWMutex              // Synchronization primitive for concurrent access.
+}
+
+// Add registers metadata for a newly joined chat room and returns a unique
+// identifier for it. If the room is already registered, it returns the existing ID.
+func (c *ChatRegistry) Add(room wire.ICBMRoomInfo) int {
+	c.m.Lock()
+	defer c.m.Unlock()
+	for chatID, r := range c.lookup {
+		if r == room {
+			return chatID
+		}
+	}
+	id := c.nextID
+	c.lookup[id] = room
+	c.nextID++
+	return id
+}
+
+// LookupRoom retrieves metadata for the chat room registered with chatID.
+// It returns the room metadata and a boolean indicating whether the chat ID
+// was found.
+func (c *ChatRegistry) LookupRoom(chatID int) (wire.ICBMRoomInfo, bool) {
+	c.m.RLock()
+	defer c.m.RUnlock()
+	room, found := c.lookup[chatID]
+	return room, found
+}
+
+// RegisterSess associates a chat session with a chat room. If a session is
+// already registered for the given chat ID, it will be overwritten.
+func (c *ChatRegistry) RegisterSess(chatID int, sess *state.Session) {
+	c.m.Lock()
+	defer c.m.Unlock()
+	c.sessions[chatID] = sess
+}
+
+// RetrieveSess retrieves the chat session associated with the given chat ID.
+// If no session is registered for the chat ID, it returns nil.
+func (c *ChatRegistry) RetrieveSess(chatID int) *state.Session {
+	c.m.RLock()
+	defer c.m.RUnlock()
+	return c.sessions[chatID]
+}
+
+// OSCARProxy acts as a bridge between TOC clients and the OSCAR server,
+// translating protocol messages between the two.
+//
+// It performs the following functions:
+//   - Receives TOC messages from the client, converts them into SNAC messages,
+//     and forwards them to the OSCAR server. The SNAC response is then converted
+//     back into a TOC response for the client.
+//   - Receives incoming messages from the OSCAR server and translates them into
+//     TOC responses for the client.
+type OSCARProxy struct {
+	AuthService         AuthService
+	BuddyListRegistry   BuddyListRegistry
+	BuddyService        BuddyService
+	ChatNavService      ChatNavService
+	ChatService         ChatService
+	CookieBaker         CookieBaker
+	DirSearchService    DirSearchService
+	ICBMService         ICBMService
+	LocateService       LocateService
+	Logger              *slog.Logger
+	OServiceServiceBOS  OServiceService
+	OServiceServiceChat OServiceService
+	PermitDenyService   PermitDenyService
+	TOCConfigStore      TOCConfigStore
+}
+
+// RecvClientCmd processes a client TOC command and returns a server reply.
+//
+// * sessBOS is the current user's session.
+// * chatRegistry manages the current user's chat sessions
+// * payload is the command + arguments
+// * toCh is the channel that transports messages to client
+// * doAsync performs async tasks, is auto-cleaned up by caller
+//
+// It returns true if the server can continue processing commands.
+func (s OSCARProxy) RecvClientCmd(
+	ctx context.Context,
+	sessBOS *state.Session,
+	chatRegistry *ChatRegistry,
+	payload []byte,
+	toCh chan<- []byte,
+	doAsync func(f func() error),
+) (reply string, ok bool) {
+	cmd := payload
+	if idx := bytes.IndexByte(payload, ' '); idx > -1 {
+		cmd = cmd[:idx]
+	}
+
+	if s.Logger.Enabled(ctx, slog.LevelDebug) {
+		s.Logger.InfoContext(ctx, "client request", "command", payload)
+	} else {
+		s.Logger.InfoContext(ctx, "client request", "command", cmd)
+	}
+
+	switch string(cmd) {
+	case "toc_send_im":
+		return s.SendIM(ctx, sessBOS, payload), true
+	case "toc_init_done":
+		return s.InitDone(ctx, sessBOS, payload), true
+	case "toc_add_buddy":
+		return s.AddBuddy(ctx, sessBOS, payload), true
+	case "toc_remove_buddy":
+		return s.RemoveBuddy(ctx, sessBOS, payload), true
+	case "toc_add_permit":
+		return s.AddPermit(ctx, sessBOS, payload), true
+	case "toc_add_deny":
+		return s.AddDeny(ctx, sessBOS, payload), true
+	case "toc_set_away":
+		return s.SetAway(ctx, sessBOS, payload), true
+	case "toc_set_caps":
+		return s.SetCaps(ctx, sessBOS, payload), true
+	case "toc_evil":
+		return s.Evil(ctx, sessBOS, payload), true
+	case "toc_get_info":
+		return s.GetInfoURL(ctx, sessBOS, payload), true
+	case "toc_chat_join", "toc_chat_accept":
+		var chatID int
+		var msg string
+
+		if string(cmd) == "toc_chat_join" {
+			chatID, msg = s.ChatJoin(ctx, sessBOS, chatRegistry, payload)
+		} else {
+			chatID, msg = s.ChatAccept(ctx, sessBOS, chatRegistry, payload)
+		}
+
+		if msg == cmdInternalSvcErr {
+			// todo idk if this is worth cancelling the connection over
+			return "", false
+		}
+
+		doAsync(func() error {
+			sess := chatRegistry.RetrieveSess(chatID)
+			s.RecvChat(ctx, sess, chatID, toCh)
+			return nil
+		})
+
+		return msg, true
+	case "toc_chat_send":
+		return s.ChatSend(ctx, chatRegistry, payload), true
+	case "toc_chat_leave":
+		return s.ChatLeave(ctx, chatRegistry, payload), true
+	case "toc_set_info":
+		return s.SetInfo(ctx, sessBOS, payload), true
+	case "toc_set_dir":
+		return s.SetDir(ctx, sessBOS, payload), true
+	case "toc_set_idle":
+		return s.SetIdle(ctx, sessBOS, payload), true
+	case "toc_set_config":
+		return s.SetConfig(ctx, sessBOS, payload), true
+	case "toc_chat_invite":
+		return s.ChatInvite(ctx, sessBOS, chatRegistry, payload), true
+	case "toc_dir_search":
+		return s.GetDirSearchURL(ctx, sessBOS, payload), true
+	case "toc_get_dir":
+		return s.GetDirURL(ctx, sessBOS, payload), true
+	}
+
+	s.Logger.ErrorContext(ctx, fmt.Sprintf("unsupported TOC command %s", cmd))
+	return "", true
+}
+
+// AddBuddy handles the toc_add_buddy TOC command.
+//
+// From the TiK documentation:
+//
+//	Add buddies to your buddy list. This does not change your saved config.
+//
+// Command syntax: toc_add_buddy <Buddy User 1> [<Buddy User2> [<Buddy User 3> [...]]]
+func (s OSCARProxy) AddBuddy(ctx context.Context, me *state.Session, cmd []byte) string {
+	users, err := parseArgs(cmd, "toc_add_buddy")
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	snac := wire.SNAC_0x03_0x04_BuddyAddBuddies{}
+	for _, sn := range users {
+		snac.Buddies = append(snac.Buddies, struct {
+			ScreenName string `oscar:"len_prefix=uint8"`
+		}{ScreenName: sn})
+	}
+
+	if err := s.BuddyService.AddBuddies(ctx, me, snac); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("BuddyService.AddBuddies: %w", err))
+	}
+
+	return ""
+}
+
+// AddPermit handles the toc_add_permit TOC command.
+//
+// From the TiK documentation:
+//
+//	ADD the following people to your permit mode. If you are in deny mode it
+//	will switch you to permit mode first. With no arguments and in deny mode
+//	this will switch you to permit none. If already in permit mode, no
+//	arguments does nothing and your permit list remains the same.
+//
+// Command syntax: toc_add_permit [ <User 1> [<User 2> [...]]]
+func (s OSCARProxy) AddPermit(ctx context.Context, me *state.Session, cmd []byte) string {
+	users, err := parseArgs(cmd, "toc_add_permit")
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	snac := wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries{}
+	for _, sn := range users {
+		snac.Users = append(snac.Users, struct {
+			ScreenName string `oscar:"len_prefix=uint8"`
+		}{ScreenName: sn})
+	}
+
+	if err := s.PermitDenyService.AddPermListEntries(ctx, me, snac); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("PermitDenyService.AddPermListEntries: %w", err))
+	}
+	return ""
+}
+
+// AddDeny handles the toc_chat_join TOC command.
+//
+// From the TiK documentation:
+//
+//	ADD the following people to your deny mode. If you are in permit mode it
+//	will switch you to deny mode first. With no arguments and in permit mode,
+//	this will switch you to deny none. If already in deny mode, no arguments
+//	does nothing and your deny list remains unchanged.
+//
+// Command syntax: toc_add_deny [ <User 1> [<User 2> [...]]]
+func (s OSCARProxy) AddDeny(ctx context.Context, me *state.Session, cmd []byte) string {
+	users, err := parseArgs(cmd, "toc_add_deny")
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	snac := wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries{}
+	for _, sn := range users {
+		snac.Users = append(snac.Users, struct {
+			ScreenName string `oscar:"len_prefix=uint8"`
+		}{ScreenName: sn})
+	}
+
+	if err := s.PermitDenyService.AddDenyListEntries(ctx, me, snac); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("PermitDenyService.AddDenyListEntries: %w", err))
+	}
+	return ""
+}
+
+// ChatAccept handles the toc_chat_accept TOC command.
+//
+// From the TiK documentation:
+//
+//	Accept a CHAT_INVITE message from TOC. The server will send a CHAT_JOIN in
+//	response.
+//
+// Command syntax: toc_chat_accept <Chat Room ID>
+func (s OSCARProxy) ChatAccept(
+	ctx context.Context,
+	me *state.Session,
+	chatRegistry *ChatRegistry,
+	cmd []byte,
+) (int, string) {
+	var chatIDStr string
+
+	if _, err := parseArgs(cmd, "toc_chat_accept", &chatIDStr); err != nil {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	chatID, err := strconv.Atoi(chatIDStr)
+	if err != nil {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("strconv.Atoi: %w", err))
+	}
+	chatInfo, found := chatRegistry.LookupRoom(chatID)
+	if !found {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("chatRegistry.LookupRoom: no chat found for ID %d", chatID))
+	}
+
+	reqRoomSNAC := wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
+		Cookie:         chatInfo.Cookie,
+		Exchange:       chatInfo.Exchange,
+		InstanceNumber: chatInfo.Instance,
+	}
+	reqRoomReply, err := s.ChatNavService.RequestRoomInfo(ctx, wire.SNACFrame{}, reqRoomSNAC)
+	if err != nil {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("ChatNavService.RequestRoomInfo: %w", err))
+	}
+
+	reqRoomReplyBody, ok := reqRoomReply.Body.(wire.SNAC_0x0D_0x09_ChatNavNavInfo)
+	if !ok {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("chatNavService.RequestRoomInfo: unexpected response type %v", reqRoomReplyBody))
+	}
+	b, hasInfo := reqRoomReplyBody.Bytes(wire.ChatNavTLVRoomInfo)
+	if !hasInfo {
+		return 0, s.runtimeErr(ctx, errors.New("reqRoomReplyBody.Bytes: missing wire.ChatNavTLVRoomInfo"))
+	}
+
+	roomInfo := wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{}
+	if err := wire.UnmarshalBE(&roomInfo, bytes.NewReader(b)); err != nil {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))
+	}
+
+	roomName, hasName := roomInfo.Bytes(wire.ChatRoomTLVRoomName)
+	if !hasName {
+		return 0, s.runtimeErr(ctx, errors.New("roomInfo.Bytes: missing wire.ChatRoomTLVRoomName"))
+	}
+
+	svcReqSNAC := wire.SNAC_0x01_0x04_OServiceServiceRequest{
+		FoodGroup: wire.Chat,
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(0x01, wire.SNAC_0x01_0x04_TLVRoomInfo{
+					Cookie: chatInfo.Cookie,
+				}),
+			},
+		},
+	}
+	svcReqReply, err := s.OServiceServiceBOS.ServiceRequest(ctx, me, wire.SNACFrame{}, svcReqSNAC)
+	if err != nil {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("OServiceServiceBOS.ServiceRequest: %w", err))
+	}
+
+	svcReqReplyBody, ok := svcReqReply.Body.(wire.SNAC_0x01_0x05_OServiceServiceResponse)
+	if !ok {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("OServiceServiceBOS.ServiceRequest: unexpected response type %v", svcReqReplyBody))
+	}
+
+	loginCookie, hasCookie := svcReqReplyBody.Bytes(wire.OServiceTLVTagsLoginCookie)
+	if !hasCookie {
+		return 0, s.runtimeErr(ctx, errors.New("missing wire.OServiceTLVTagsLoginCookie"))
+	}
+
+	chatSess, err := s.AuthService.RegisterChatSession(ctx, loginCookie)
+	if err != nil {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("AuthService.RegisterChatSession: %w", err))
+	}
+
+	chatRegistry.RegisterSess(chatID, chatSess)
+
+	if err := s.OServiceServiceChat.ClientOnline(ctx, wire.SNAC_0x01_0x02_OServiceClientOnline{}, chatSess); err != nil {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("OServiceServiceChat.ClientOnline: %w", err))
+	}
+
+	return chatID, fmt.Sprintf("CHAT_JOIN:%d:%s", chatID, roomName)
+}
+
+// ChatInvite handles the toc_chat_invite TOC command.
+//
+// From the TiK documentation:
+//
+//	Once you are inside a chat room you can invite other people into that room.
+//	Remember to quote and encode the invite message.
+//
+// Command syntax: toc_chat_invite <Chat Room ID> <Invite Msg> <buddy1> [<buddy2> [<buddy3> [...]]]
+func (s OSCARProxy) ChatInvite(ctx context.Context, me *state.Session, chatRegistry *ChatRegistry, cmd []byte) string {
+	var chatRoomIDStr, msg string
+
+	users, err := parseArgs(cmd, "toc_chat_invite", &chatRoomIDStr, &msg)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	chatID, err := strconv.Atoi(chatRoomIDStr)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("strconv.Atoi: %w", err))
+	}
+
+	roomInfo, found := chatRegistry.LookupRoom(chatID)
+	if !found {
+		return s.runtimeErr(ctx, fmt.Errorf("chatRegistry.LookupRoom: chat ID `%d` not found", chatID))
+	}
+
+	for _, guest := range users {
+		snac := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+			ChannelID:  wire.ICBMChannelRendezvous,
+			ScreenName: guest,
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLVBE(0x05, wire.ICBMCh2Fragment{
+						Type:       0,
+						Capability: capChat,
+						TLVRestBlock: wire.TLVRestBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(10, uint16(1)),
+								wire.NewTLVBE(12, msg),
+								wire.NewTLVBE(13, "us-ascii"),
+								wire.NewTLVBE(14, "en"),
+								wire.NewTLVBE(10001, roomInfo),
+							},
+						},
+					}),
+				},
+			},
+		}
+
+		if _, err := s.ICBMService.ChannelMsgToHost(ctx, me, wire.SNACFrame{}, snac); err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("ICBMService.ChannelMsgToHost: %w", err))
+		}
+	}
+
+	return ""
+}
+
+// ChatJoin handles the toc_chat_join TOC command.
+//
+// From the TiK documentation:
+//
+//	Join a chat room in the given exchange. Exchange is an integer that
+//	represents a group of chat rooms. Different exchanges have different
+//	properties. For example some exchanges might have room replication (ie a
+//	room never fills up, there are just multiple instances.) and some exchanges
+//	might have navigational information. Currently, exchange should always be
+//	4, however this may change in the future. You will either receive an ERROR
+//	if the room couldn't be joined or a CHAT_JOIN message. The Chat Room Name
+//	is case-insensitive and consecutive spaces are removed.
+//
+// Command syntax: toc_chat_join <Exchange> <Chat Room Name>
+func (s OSCARProxy) ChatJoin(
+	ctx context.Context,
+	me *state.Session,
+	chatRegistry *ChatRegistry,
+	cmd []byte,
+) (int, string) {
+	var exchangeStr, roomName string
+
+	if _, err := parseArgs(cmd, "toc_chat_join", &exchangeStr, &roomName); err != nil {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	// create room or retrieve the room if it already exists
+	exchange, err := strconv.Atoi(exchangeStr)
+	if err != nil {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("strconv.Atoi: %w", err))
+	}
+
+	mkRoomReq := wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+		Exchange: uint16(exchange),
+		Cookie:   "create",
+		TLVBlock: wire.TLVBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.ChatRoomTLVRoomName, roomName),
+			},
+		},
+	}
+	mkRoomReply, err := s.ChatNavService.CreateRoom(ctx, me, wire.SNACFrame{}, mkRoomReq)
+	if err != nil {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("ChatNavService.CreateRoom: %w", err))
+	}
+
+	mkRoomReplyBody, ok := mkRoomReply.Body.(wire.SNAC_0x0D_0x09_ChatNavNavInfo)
+	if !ok {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("chatNavService.CreateRoom: unexpected response type %v", mkRoomReplyBody))
+	}
+	buf, ok := mkRoomReplyBody.Bytes(wire.ChatNavTLVRoomInfo)
+	if !ok {
+		return 0, s.runtimeErr(ctx, errors.New("mkRoomReplyBody.Bytes: missing wire.ChatNavTLVRoomInfo"))
+	}
+
+	inBody := wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{}
+	if err := wire.UnmarshalBE(&inBody, bytes.NewReader(buf)); err != nil {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))
+	}
+
+	svcReqSNAC := wire.SNAC_0x01_0x04_OServiceServiceRequest{
+		FoodGroup: wire.Chat,
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(0x01, wire.SNAC_0x01_0x04_TLVRoomInfo{
+					Cookie: inBody.Cookie,
+				}),
+			},
+		},
+	}
+	svcReqReply, err := s.OServiceServiceBOS.ServiceRequest(ctx, me, wire.SNACFrame{}, svcReqSNAC)
+	if err != nil {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("OServiceServiceBOS.ServiceRequest: %w", err))
+	}
+
+	svcReqReplyBody, ok := svcReqReply.Body.(wire.SNAC_0x01_0x05_OServiceServiceResponse)
+	if !ok {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("OServiceServiceBOS.ServiceRequest: unexpected response type %v", svcReqReplyBody))
+	}
+
+	loginCookie, hasCookie := svcReqReplyBody.Bytes(wire.OServiceTLVTagsLoginCookie)
+	if !hasCookie {
+		return 0, s.runtimeErr(ctx, errors.New("svcReqReplyBody.Bytes: missing wire.OServiceTLVTagsLoginCookie"))
+	}
+
+	chatSess, err := s.AuthService.RegisterChatSession(ctx, loginCookie)
+	if err != nil {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("AuthService.RegisterChatSession: %w", err))
+	}
+
+	roomInfo := wire.ICBMRoomInfo{
+		Exchange: inBody.Exchange,
+		Cookie:   inBody.Cookie,
+		Instance: inBody.InstanceNumber,
+	}
+	chatID := chatRegistry.Add(roomInfo)
+	chatRegistry.RegisterSess(chatID, chatSess)
+
+	if err := s.OServiceServiceChat.ClientOnline(ctx, wire.SNAC_0x01_0x02_OServiceClientOnline{}, chatSess); err != nil {
+		return 0, s.runtimeErr(ctx, fmt.Errorf("OServiceServiceChat.ClientOnline: %w", err))
+	}
+
+	return chatID, fmt.Sprintf("CHAT_JOIN:%d:%s", chatID, roomName)
+}
+
+// ChatLeave handles the toc_chat_leave TOC command.
+//
+// From the TiK documentation:
+//
+//	Leave the chat room.
+//
+// Command syntax: toc_chat_leave <Chat Room ID>
+func (s OSCARProxy) ChatLeave(ctx context.Context, chatRegistry *ChatRegistry, cmd []byte) string {
+	var chatIDStr string
+
+	if _, err := parseArgs(cmd, "toc_chat_leave", &chatIDStr); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	chatID, err := strconv.Atoi(chatIDStr)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("strconv.Atoi: %w", err))
+	}
+
+	me := chatRegistry.RetrieveSess(chatID)
+	if me == nil {
+		return s.runtimeErr(ctx, fmt.Errorf("chatRegistry.RetrieveSess: chat session `%d` not found", chatID))
+	}
+
+	s.AuthService.SignoutChat(ctx, me)
+
+	me.Close() // stop async server SNAC reply handler for this chat room
+
+	return fmt.Sprintf("CHAT_LEFT:%d", chatID)
+}
+
+// ChatSend handles the toc_chat_send TOC command.
+//
+// From the TiK documentation:
+//
+//	Send a message in a chat room using the chat room id from CHAT_JOIN. Since
+//	reflection is always on in TOC, you do not need to add the message to your
+//	chat UI, since you will get a CHAT_IN with the message. Remember to quote
+//	and encode the message.
+//
+// Command syntax: toc_chat_send <Chat Room ID> <Message>
+func (s OSCARProxy) ChatSend(ctx context.Context, chatRegistry *ChatRegistry, cmd []byte) string {
+	var chatIDStr, msg string
+
+	if _, err := parseArgs(cmd, "toc_chat_send", &chatIDStr, &msg); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	chatID, err := strconv.Atoi(chatIDStr)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("strconv.Atoi: %w", err))
+	}
+
+	me := chatRegistry.RetrieveSess(chatID)
+	if me == nil {
+		return s.runtimeErr(ctx, fmt.Errorf("chatRegistry.RetrieveSess: session for chat ID `%d` not found", chatID))
+	}
+
+	block := wire.TLVRestBlock{}
+	// the order of these TLVs matters for AIM 2.x. if out of order, screen
+	// names do not appear with each chat message.
+	block.Append(wire.NewTLVBE(wire.ChatTLVEnableReflectionFlag, uint8(1)))
+	block.Append(wire.NewTLVBE(wire.ChatTLVSenderInformation, me.TLVUserInfo()))
+	block.Append(wire.NewTLVBE(wire.ChatTLVPublicWhisperFlag, []byte{}))
+	block.Append(wire.NewTLVBE(wire.ChatTLVMessageInfo, wire.TLVRestBlock{
+		TLVList: wire.TLVList{
+			wire.NewTLVBE(wire.ChatTLVMessageInfoText, msg),
+		},
+	}))
+
+	snac := wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+		Channel:      wire.ICBMChannelMIME,
+		TLVRestBlock: block,
+	}
+	if _, err := s.ChatService.ChannelMsgToHost(ctx, me, wire.SNACFrame{}, snac); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("ChatService.ChannelMsgToHost: %w", err))
+	}
+
+	return fmt.Sprintf("CHAT_IN:%d:%s:F:%s", chatID, me.DisplayScreenName(), msg)
+}
+
+// Evil handles the toc_evil TOC command.
+//
+// From the TiK documentation:
+//
+//	Evil/Warn someone else. The 2nd argument is either the string "norm" for a
+//	normal warning, or "anon" for an anonymous warning. You can only evil
+//	people who have recently sent you ims. The higher someones evil level, the
+//	slower they can send message.
+//
+// Command syntax: toc_evil <User> <norm|anon>
+func (s OSCARProxy) Evil(ctx context.Context, me *state.Session, cmd []byte) string {
+	var user, scope string
+
+	if _, err := parseArgs(cmd, "toc_evil", &user, &scope); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	snac := wire.SNAC_0x04_0x08_ICBMEvilRequest{
+		ScreenName: user,
+	}
+
+	switch scope {
+	case "anon":
+		snac.SendAs = 1
+	case "norm":
+		snac.SendAs = 0
+	default:
+		return s.runtimeErr(ctx, fmt.Errorf("incorrect warning type `%s`. allowed values: anon, norm", scope))
+	}
+
+	response, err := s.ICBMService.EvilRequest(ctx, me, wire.SNACFrame{}, snac)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("ICBMService.EvilRequest: %w", err))
+	}
+
+	switch v := response.Body.(type) {
+	case wire.SNAC_0x04_0x09_ICBMEvilReply:
+		return ""
+	case wire.SNACError:
+		s.Logger.InfoContext(ctx, "unable to warn user", "code", v.Code)
+	default:
+		return s.runtimeErr(ctx, errors.New("unexpected response"))
+	}
+
+	return ""
+}
+
+// GetDirSearchURL handles the toc_dir_search TOC command.
+//
+// From the TiK documentation:
+//
+//	Perform a search of the Oscar Directory, using colon separated fields as in:
+//
+//		"first name":"middle name":"last name":"maiden name":"city":"state":"country":"email"
+//
+// You can search by keyword by setting search terms in the 11th position (this
+// feature is not in the TiK docs but is present in the code):
+//
+//	::::::::::"search kw"
+//
+//	Returns either a GOTO_URL or ERROR msg.
+//
+// Command syntax: toc_dir_search <info information>
+func (s OSCARProxy) GetDirSearchURL(ctx context.Context, me *state.Session, cmd []byte) string {
+	var info string
+
+	if _, err := parseArgs(cmd, "toc_dir_search", &info); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	params := strings.Split(info, ":")
+	labels := []string{
+		"first_name",
+		"middle_name",
+		"last_name",
+		"maiden_name",
+		"city",
+		"state",
+		"country",
+		"email",
+		"nop", // unused placeholder
+		"nop",
+		"keyword",
+	}
+
+	// map labels to param values at their corresponding positions
+	p := url.Values{}
+	i := 0
+	for i < len(params) && i < len(labels) {
+		if len(params[i]) > 0 {
+			p.Add(labels[i], strings.Trim(params[i], "\""))
+		}
+		i++
+	}
+
+	if len(p) == 0 {
+		return s.runtimeErr(ctx, errors.New("no search fields found"))
+	}
+
+	cookie, err := s.newHTTPAuthToken(me.IdentScreenName())
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("newHTTPAuthToken: %w", err))
+	}
+	p.Add("cookie", cookie)
+
+	return fmt.Sprintf("GOTO_URL:search results:dir_search?%s", p.Encode())
+}
+
+// GetDirURL handles the toc_get_dir TOC command.
+//
+// From the TiK documentation:
+//
+//	Gets a user's dir info a GOTO_URL or ERROR message will be sent back to the client.
+//
+// Command syntax: toc_get_dir <username>
+func (s OSCARProxy) GetDirURL(ctx context.Context, me *state.Session, cmd []byte) string {
+	var user string
+
+	if _, err := parseArgs(cmd, "toc_get_dir", &user); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	cookie, err := s.newHTTPAuthToken(me.IdentScreenName())
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("newHTTPAuthToken: %w", err))
+	}
+
+	p := url.Values{}
+	p.Add("cookie", cookie)
+	p.Add("user", user)
+
+	return fmt.Sprintf("GOTO_URL:directory info:dir_info?%s", p.Encode())
+}
+
+// GetInfoURL handles the toc_get_info TOC command.
+//
+// From the TiK documentation:
+//
+//	Gets a user's info a GOTO_URL or ERROR message will be sent back to the client.
+//
+// Command syntax: toc_get_info <username>
+func (s OSCARProxy) GetInfoURL(ctx context.Context, me *state.Session, cmd []byte) string {
+	var user string
+
+	if _, err := parseArgs(cmd, "toc_get_info", &user); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	cookie, err := s.newHTTPAuthToken(me.IdentScreenName())
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("newHTTPAuthToken: %w", err))
+	}
+
+	p := url.Values{}
+	p.Add("cookie", cookie)
+	p.Add("from", me.IdentScreenName().String())
+	p.Add("user", user)
+
+	return fmt.Sprintf("GOTO_URL:profile:info?%s", p.Encode())
+}
+
+// InitDone handles the toc_init_done TOC command.
+//
+// From the TiK documentation:
+//
+//	Tells TOC that we are ready to go online. TOC clients should first send TOC
+//	the buddy list and any permit/deny lists. However, toc_init_done must be
+//	called within 30 seconds after toc_signon, or the connection will be
+//	dropped. Remember, it can't be called until after the SIGN_ON message is
+//	received. Calling this before or multiple times after a SIGN_ON will cause
+//	the connection to be dropped.
+//
+// Note: The business logic described in the last 3 sentences are not yet
+// implemented.
+//
+// Command syntax: toc_init_done
+func (s OSCARProxy) InitDone(ctx context.Context, sess *state.Session, cmd []byte) string {
+	if _, err := parseArgs(cmd, "toc_init_done"); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+	if err := s.OServiceServiceBOS.ClientOnline(ctx, wire.SNAC_0x01_0x02_OServiceClientOnline{}, sess); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("OServiceServiceBOS.ClientOnliney: %w", err))
+	}
+	return ""
+}
+
+// RemoveBuddy handles the toc_remove_buddy TOC command.
+//
+// From the TiK documentation:
+//
+//	Remove buddies from your buddy list. This does not change your saved config.
+//
+// Command syntax:
+func (s OSCARProxy) RemoveBuddy(ctx context.Context, me *state.Session, cmd []byte) string {
+	users, err := parseArgs(cmd, "toc_remove_buddy")
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	snac := wire.SNAC_0x03_0x05_BuddyDelBuddies{}
+	for _, sn := range users {
+		snac.Buddies = append(snac.Buddies, struct {
+			ScreenName string `oscar:"len_prefix=uint8"`
+		}{ScreenName: sn})
+	}
+
+	if err := s.BuddyService.DelBuddies(ctx, me, snac); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("BuddyService.DelBuddies: %w", err))
+	}
+	return ""
+}
+
+// SendIM handles the toc_send_im TOC command.
+//
+// From the TiK documentation:
+//
+//	Send a message to a remote user. Remember to quote and encode the message.
+//	If the optional string "auto" is the last argument, then the auto response
+//	flag will be turned on for the IM.
+//
+// Command syntax: toc_send_im <Destination User> <Message> [auto]
+func (s OSCARProxy) SendIM(ctx context.Context, sender *state.Session, cmd []byte) string {
+	var recip, msg string
+
+	autoReply, err := parseArgs(cmd, "toc_send_im", &recip, &msg)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	frags, err := wire.ICBMFragmentList(msg)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("wire.ICBMFragmentList: %w", err))
+	}
+
+	snac := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+		ChannelID:  wire.ICBMChannelIM,
+		ScreenName: recip,
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.ICBMTLVAOLIMData, frags),
+			},
+		},
+	}
+
+	if len(autoReply) > 0 && autoReply[0] == "auto" {
+		snac.Append(wire.NewTLVBE(wire.ICBMTLVAutoResponse, []byte{}))
+	}
+
+	// send message and ignore response since there is no TOC error code to
+	// handle errors such as "user is offline", etc.
+	_, err = s.ICBMService.ChannelMsgToHost(ctx, sender, wire.SNACFrame{}, snac)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("ICBMService.ChannelMsgToHost: %w", err))
+	}
+
+	return ""
+}
+
+// SetAway handles the toc_chat_join TOC command.
+//
+// From the TiK documentation:
+//
+//	If the away message is present, then the unavailable status flag is set for
+//	the user. If the away message is not present, then the unavailable status
+//	flag is unset. The away message is basic HTML, remember to encode the
+//	information.
+//
+// Command syntax: toc_set_away [<away message>]
+func (s OSCARProxy) SetAway(ctx context.Context, me *state.Session, cmd []byte) string {
+	maybeMsg, err := parseArgs(cmd, "toc_set_away")
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	var msg string
+	if len(maybeMsg) > 0 {
+		msg = maybeMsg[0]
+	}
+
+	snac := wire.SNAC_0x02_0x04_LocateSetInfo{
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, msg),
+			},
+		},
+	}
+
+	if err := s.LocateService.SetInfo(ctx, me, snac); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("LocateService.SetInfo: %w", err))
+	}
+
+	return ""
+}
+
+// SetCaps handles the toc_set_caps TOC command.
+//
+// From the TiK documentation:
+//
+//	Set my capabilities. All capabilities that we support need to be sent at
+//	the same time. Capabilities are represented by UUIDs.
+//
+// This method automatically adds the "chat" capability since it doesn't seem
+// to be sent explicitly by the official clients, even though they support
+// chat.
+//
+// Command syntax: toc_set_caps [ <Capability 1> [<Capability 2> [...]]]
+func (s OSCARProxy) SetCaps(ctx context.Context, me *state.Session, cmd []byte) string {
+	params, err := parseArgs(cmd, "toc_set_caps")
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	caps := make([]uuid.UUID, 0, 16*(len(params)+1))
+	for _, capStr := range params {
+		uid, err := uuid.Parse(capStr)
+		if err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("UUID.Parse: %w", err))
+		}
+		caps = append(caps, uid)
+	}
+	caps = append(caps, capChat)
+
+	snac := wire.SNAC_0x02_0x04_LocateSetInfo{
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.LocateTLVTagsInfoCapabilities, caps),
+			},
+		},
+	}
+
+	if err := s.LocateService.SetInfo(ctx, me, snac); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("LocateService.SetInfo: %w", err))
+	}
+
+	return ""
+}
+
+// SetConfig handles the toc_set_config TOC command.
+//
+// From the TiK documentation:
+//
+//	Set the config information for this user. The config information is line
+//	oriented with the first character being the item type, followed by a space,
+//	with the rest of the line being the item value. Only letters, numbers, and
+//	spaces should be used. Remember you will have to enclose the entire config
+//	in quotes.
+//
+//	Item Types:
+//		- g - Buddy Group (All Buddies until the next g or the end of config are in this group.)
+//		- b - A Buddy
+//		- p - Person on permit list
+//		- d - Person on deny list
+//		- m - Permit/Deny Mode. Possible values are
+//		- 1 - Permit All
+//		- 2 - Deny All
+//		- 3 - Permit Some
+//		- 4 - Deny Some
+//
+// Command syntax: toc_set_config <Config Info>
+func (s OSCARProxy) SetConfig(ctx context.Context, me *state.Session, cmd []byte) string {
+	// replace curly braces with quotes so that the string can be properly
+	// split up by the space-delimited reader
+	for i, c := range cmd {
+		if c == '{' || c == '}' {
+			cmd[i] = '"'
+		}
+	}
+	cmd = bytes.TrimSpace(cmd)
+
+	var info string
+	if _, err := parseArgs(cmd, "toc_set_config", &info); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	config := strings.Split(info, "\n")
+
+	var cfg [][2]string
+	for _, item := range config {
+		parts := strings.Split(item, " ")
+		if len(parts) != 2 {
+			s.Logger.InfoContext(ctx, "invalid config item", "item", item, "user", me.DisplayScreenName())
+			continue
+		}
+		cfg = append(cfg, [2]string{parts[0], parts[1]})
+	}
+
+	mode := wire.FeedbagPDModePermitAll
+	for _, c := range cfg {
+		if c[0] != "m" {
+			continue
+		}
+		switch c[1] {
+		case "1":
+			mode = wire.FeedbagPDModePermitAll
+		case "2":
+			mode = wire.FeedbagPDModeDenyAll
+		case "3":
+			mode = wire.FeedbagPDModePermitSome
+		case "4":
+			mode = wire.FeedbagPDModeDenySome
+		default:
+			return s.runtimeErr(ctx, fmt.Errorf("config: invalid mode `%s`", c[1]))
+		}
+	}
+
+	switch mode {
+	case wire.FeedbagPDModePermitAll:
+		snac := wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries{
+			Users: []struct {
+				ScreenName string `oscar:"len_prefix=uint8"`
+			}{
+				{
+					ScreenName: me.IdentScreenName().String(),
+				},
+			},
+		}
+		if err := s.PermitDenyService.AddDenyListEntries(ctx, me, snac); err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("PermitDenyService.AddDenyListEntries: %w", err))
+		}
+	case wire.FeedbagPDModeDenyAll:
+		snac := wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries{
+			Users: []struct {
+				ScreenName string `oscar:"len_prefix=uint8"`
+			}{
+				{
+					ScreenName: me.IdentScreenName().String(),
+				},
+			},
+		}
+		if err := s.PermitDenyService.AddPermListEntries(ctx, me, snac); err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("PermitDenyService.AddPermListEntrie: %w", err))
+		}
+	case wire.FeedbagPDModePermitSome:
+		snac := wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries{}
+		for _, c := range cfg {
+			if c[0] != "p" {
+				continue
+			}
+			snac.Users = append(snac.Users, struct {
+				ScreenName string `oscar:"len_prefix=uint8"`
+			}{ScreenName: c[1]})
+		}
+		if err := s.PermitDenyService.AddPermListEntries(ctx, me, snac); err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("PermitDenyService.AddPermListEntrie: %w", err))
+		}
+	case wire.FeedbagPDModeDenySome:
+		snac := wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries{}
+		for _, c := range cfg {
+			if c[0] != "d" {
+				continue
+			}
+			snac.Users = append(snac.Users, struct {
+				ScreenName string `oscar:"len_prefix=uint8"`
+			}{ScreenName: c[1]})
+		}
+		if err := s.PermitDenyService.AddDenyListEntries(ctx, me, snac); err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("PermitDenyService.AddDenyListEntries: %w", err))
+		}
+	}
+
+	snac := wire.SNAC_0x03_0x04_BuddyAddBuddies{}
+	for _, c := range cfg {
+		if c[0] != "b" {
+			continue
+		}
+		snac.Buddies = append(snac.Buddies, struct {
+			ScreenName string `oscar:"len_prefix=uint8"`
+		}{ScreenName: c[1]})
+	}
+
+	if err := s.BuddyService.AddBuddies(ctx, me, snac); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("BuddyService.AddBuddies: %w", err))
+	}
+
+	if err := s.TOCConfigStore.SetTOCConfig(me.IdentScreenName(), info); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("TOCConfigStore.SaveTOCConfig: %w", err))
+	}
+
+	return ""
+}
+
+// SetDir handles the toc_set_dir TOC command.
+//
+// From the TiK documentation:
+//
+//	Set the DIR user information. This is a colon separated fields as in:
+//
+//		"first name":"middle name":"last name":"maiden name":"city":"state":"country":"email":"allow web searches".
+//
+//	Should return a DIR_STATUS msg. Having anything in the "allow web searches"
+//	field allows people to use web-searches to find your directory info.
+//	Otherwise, they'd have to use the client.
+//
+// The fields "email" and "allow web searches" are ignored by this method.
+//
+// Command syntax: toc_set_dir <info information>
+func (s OSCARProxy) SetDir(ctx context.Context, me *state.Session, cmd []byte) string {
+	var info string
+
+	if _, err := parseArgs(cmd, "toc_set_dir", &info); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	rawFields := strings.Split(info, ":")
+
+	var finalFields [9]string
+
+	if len(rawFields) > len(finalFields) {
+		return s.runtimeErr(ctx, fmt.Errorf("expected at most %d params, got %d", len(finalFields), len(rawFields)))
+	}
+	for i, a := range rawFields {
+		finalFields[i] = strings.Trim(a, "\"")
+	}
+
+	snac := wire.SNAC_0x02_0x09_LocateSetDirInfo{
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.ODirTLVFirstName, finalFields[0]),
+				wire.NewTLVBE(wire.ODirTLVMiddleName, finalFields[1]),
+				wire.NewTLVBE(wire.ODirTLVLastName, finalFields[2]),
+				wire.NewTLVBE(wire.ODirTLVMaidenName, finalFields[3]),
+				wire.NewTLVBE(wire.ODirTLVCountry, finalFields[6]),
+				wire.NewTLVBE(wire.ODirTLVState, finalFields[5]),
+				wire.NewTLVBE(wire.ODirTLVCity, finalFields[4]),
+			},
+		},
+	}
+	if _, err := s.LocateService.SetDirInfo(ctx, me, wire.SNACFrame{}, snac); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("LocateService.SetDirInfo: %w", err))
+	}
+
+	return ""
+}
+
+// SetIdle handles the toc_set_idle TOC command.
+//
+// From the TiK documentation:
+//
+//	Set idle information. If <idle secs> is 0 then the user isn't idle at all.
+//	If <idle secs> is greater than 0 then the user has already been idle for
+//	<idle secs> number of seconds. The server will automatically keep
+//	incrementing this number, so do not repeatedly call with new idle times.
+//
+// Command syntax: toc_set_idle <idle secs>
+func (s OSCARProxy) SetIdle(ctx context.Context, me *state.Session, cmd []byte) string {
+	var idleTimeStr string
+
+	if _, err := parseArgs(cmd, "toc_set_idle", &idleTimeStr); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	time, err := strconv.Atoi(idleTimeStr)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("strconv.Atoi: %w", err))
+	}
+
+	snac := wire.SNAC_0x01_0x11_OServiceIdleNotification{
+		IdleTime: uint32(time),
+	}
+	if err := s.OServiceServiceBOS.IdleNotification(ctx, me, snac); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("OServiceServiceBOS.IdleNotification: %w", err))
+	}
+
+	return ""
+}
+
+// SetInfo handles the toc_set_info TOC command.
+//
+// From the TiK documentation:
+//
+//	Set the LOCATE user information. This is basic HTML. Remember to encode the info.
+//
+// Command syntax: toc_set_info <info information>
+func (s OSCARProxy) SetInfo(ctx context.Context, me *state.Session, cmd []byte) string {
+	var info string
+
+	if _, err := parseArgs(cmd, "toc_set_info", &info); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))
+	}
+
+	snac := wire.SNAC_0x02_0x04_LocateSetInfo{
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, info),
+			},
+		},
+	}
+	if err := s.LocateService.SetInfo(ctx, me, snac); err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("LocateService.SetInfo: %w", err))
+	}
+
+	return ""
+}
+
+// Signon handles the toc_signon TOC command.
+//
+// From the TiK documentation:
+//
+//	The password needs to be roasted with the Roasting String if coming over a
+//	FLAP connection, CP connections don't use roasted passwords. The language
+//	specified will be used when generating web pages, such as the get info
+//	pages. Currently, the only supported language is "english". If the language
+//	sent isn't found, the default "english" language will be used. The version
+//	string will be used for the client identity, and must be less than 50
+//	characters.
+//
+//	Passwords are roasted when sent to the host. This is done so they aren't
+//	sent in "clear text" over the wire, although they are still trivial to
+//	decode. Roasting is performed by first xoring each byte in the password
+//	with the equivalent modulo byte in the roasting string. The result is then
+//	converted to ascii hex, and prepended with "0x". So for example the
+//	password "password" roasts to "0x2408105c23001130".
+//
+//	The Roasting String is Tic/Toc.
+//
+// Command syntax: toc_signon <authorizer host> <authorizer port> <User Name> <Password> <language> <version>
+func (s OSCARProxy) Signon(ctx context.Context, cmd []byte) (*state.Session, []string) {
+	var userName, password string
+
+	if _, err := parseArgs(cmd, "toc_signon", nil, nil, &userName, &password); err != nil {
+		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("parseArgs: %w", err))}
+	}
+
+	passwordHash, err := hex.DecodeString(password[2:])
+	if err != nil {
+		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("hex.DecodeString: %w", err))}
+	}
+
+	signonFrame := wire.FLAPSignonFrame{}
+	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsScreenName, userName))
+	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsRoastedTOCPassword, passwordHash))
+
+	block, err := s.AuthService.FLAPLogin(signonFrame, state.NewStubUser)
+	if err != nil {
+		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("AuthService.FLAPLogin: %w", err))}
+	}
+
+	if block.HasTag(wire.LoginTLVTagsErrorSubcode) {
+		s.Logger.DebugContext(ctx, "login failed")
+		return nil, []string{"ERROR:980"} // bad username/password
+	}
+
+	authCookie, ok := block.Bytes(wire.OServiceTLVTagsLoginCookie)
+	if !ok {
+		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("unable to get session id from payload"))}
+	}
+
+	sess, err := s.AuthService.RegisterBOSSession(ctx, authCookie)
+	if err != nil {
+		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("AuthService.RegisterBOSSession: %w", err))}
+	}
+
+	// set chat capability so that... tk
+	sess.SetCaps([][16]byte{capChat})
+
+	if err := s.BuddyListRegistry.RegisterBuddyList(sess.IdentScreenName()); err != nil {
+		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("BuddyListRegistry.RegisterBuddyList: %w", err))}
+	}
+
+	u, err := s.TOCConfigStore.User(sess.IdentScreenName())
+	if err != nil {
+		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("TOCConfigStore.User: %w", err))}
+	}
+	if u == nil {
+		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("TOCConfigStore.User: user not found"))}
+	}
+
+	return sess, []string{"SIGN_ON:TOC1.0", fmt.Sprintf("CONFIG:%s", u.TOCConfig)}
+}
+
+// Signout terminates a TOC session. It sends departure notifications to
+// buddies, de-registers buddy list and session.
+func (s OSCARProxy) Signout(ctx context.Context, me *state.Session) {
+	if err := s.BuddyService.BroadcastBuddyDeparted(ctx, me); err != nil {
+		s.Logger.ErrorContext(ctx, "error sending departure notifications", "err", err.Error())
+	}
+	if err := s.BuddyListRegistry.UnregisterBuddyList(me.IdentScreenName()); err != nil {
+		s.Logger.ErrorContext(ctx, "error removing buddy list entry", "err", err.Error())
+	}
+	s.AuthService.Signout(ctx, me)
+}
+
+// newHTTPAuthToken creates a HMAC token for authenticating TOC HTTP requests
+func (s OSCARProxy) newHTTPAuthToken(me state.IdentScreenName) (string, error) {
+	cookie, err := s.CookieBaker.Issue([]byte(me.String()))
+	if err != nil {
+		return "", err
+	}
+	// trim padding so that gaim doesn't choke on the long value
+	cookie = bytes.TrimRight(cookie, "\x00")
+	return hex.EncodeToString(cookie), nil
+}
+
+// parseArgs extracts arguments from a TOC command. Each positional argument is
+// assigned to its corresponding args pointer. It returns the remaining
+// arguments as varargs.
+func parseArgs(payload []byte, cmd string, args ...*string) (varArgs []string, err error) {
+	reader := csv.NewReader(bytes.NewReader(payload))
+	reader.Comma = ' '
+	reader.LazyQuotes = true
+	reader.TrimLeadingSpace = true
+
+	segs, err := reader.Read()
+	if err != nil {
+		return nil, fmt.Errorf("CSV reader error: %w", err)
+	}
+
+	// sanity check the command name
+	if segs[0] != cmd {
+		return nil, fmt.Errorf("command mismatch. expected %s, got %s", cmd, segs[0])
+	}
+
+	// all elements after the command are arguments
+	segs = segs[1:]
+	if len(segs) < len(args) {
+		return nil, fmt.Errorf("command contains fewer arguments than expected")
+	}
+
+	i := 0
+	// populate placeholder pointers with their corresponding values
+	for ; i < len(args); i++ {
+		if args[i] == nil {
+			// ignore argument, don't populate its corresponding pointer
+			continue
+		}
+		*args[i] = strings.TrimSpace(segs[i])
+	}
+
+	// dump remaining arguments as varargs
+	for _, param := range segs[i:] {
+		varArgs = append(varArgs, param)
+	}
+
+	return varArgs, err
+}
+
+// runtimeErr is a convenience function that logs an error and returns a TOC
+// internal server error.
+func (s OSCARProxy) runtimeErr(ctx context.Context, err error) string {
+	s.Logger.ErrorContext(ctx, "internal service error", "err", err.Error())
+	return cmdInternalSvcErr
+}

+ 3425 - 0
server/toc/cmd_client_test.go

@@ -0,0 +1,3425 @@
+package toc
+
+import (
+	"context"
+	"encoding/hex"
+	"io"
+	"log/slog"
+	"testing"
+
+	"github.com/google/uuid"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func TestOSCARProxy_AddBuddy(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully add buddies",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_add_buddy friend1 friend2 friend3"),
+			mockParams: mockParams{
+				buddyParams: buddyParams{
+					addBuddiesParams: addBuddiesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+								Buddies: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend1"},
+									{ScreenName: "friend2"},
+									{ScreenName: "friend3"},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "add buddies, receive error from buddy service",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_add_buddy friend1"),
+			mockParams: mockParams{
+				buddyParams: buddyParams{
+					addBuddiesParams: addBuddiesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+								Buddies: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend1"},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_add_buddy_bad`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			buddySvc := newMockBuddyService(t)
+			for _, params := range tc.mockParams.addBuddiesParams {
+				buddySvc.EXPECT().
+					AddBuddies(ctx, matchSession(params.me), params.inBody).
+					Return(params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:       slog.Default(),
+				BuddyService: buddySvc,
+			}
+			msg := svc.AddBuddy(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_AddPermit(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully permit buddies",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_add_permit friend1 friend2 friend3"),
+			mockParams: mockParams{
+				permitDenyParams: permitDenyParams{
+					addPermListEntriesParams: addPermListEntriesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							body: wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries{
+								Users: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend1"},
+									{ScreenName: "friend2"},
+									{ScreenName: "friend3"},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "permit buddies, receive error from buddy service",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_add_permit friend1"),
+			mockParams: mockParams{
+				permitDenyParams: permitDenyParams{
+					addPermListEntriesParams: addPermListEntriesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							body: wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries{
+								Users: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend1"},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_remove_buddy_bad`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			pdSvc := newMockPermitDenyService(t)
+			for _, params := range tc.mockParams.addPermListEntriesParams {
+				pdSvc.EXPECT().
+					AddPermListEntries(ctx, matchSession(params.me), params.body).
+					Return(params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:            slog.Default(),
+				PermitDenyService: pdSvc,
+			}
+			msg := svc.AddPermit(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_AddDeny(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully deny buddies",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_add_deny friend1 friend2 friend3"),
+			mockParams: mockParams{
+				permitDenyParams: permitDenyParams{
+					addDenyListEntriesParams: addDenyListEntriesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							body: wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries{
+								Users: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend1"},
+									{ScreenName: "friend2"},
+									{ScreenName: "friend3"},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "deny buddies, receive error from buddy service",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_add_deny friend1"),
+			mockParams: mockParams{
+				permitDenyParams: permitDenyParams{
+					addDenyListEntriesParams: addDenyListEntriesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							body: wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries{
+								Users: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend1"},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_add_deny_bad`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			pdSvc := newMockPermitDenyService(t)
+			for _, params := range tc.mockParams.addDenyListEntriesParams {
+				pdSvc.EXPECT().
+					AddDenyListEntries(ctx, matchSession(params.me), params.body).
+					Return(params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:            slog.Default(),
+				PermitDenyService: pdSvc,
+			}
+			msg := svc.AddDeny(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_ChatAccept(t *testing.T) {
+	fnNewChatNavParams := func(err error) chatNavParams {
+		ret := chatNavParams{
+			requestRoomInfoParams: requestRoomInfoParams{
+				{
+					inBody: wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
+						Cookie:         "the-cookie",
+						Exchange:       4,
+						InstanceNumber: 0,
+					},
+				},
+			},
+		}
+		if err != nil {
+			ret.requestRoomInfoParams[0].err = err
+		} else {
+			ret.requestRoomInfoParams[0].msg = wire.SNACMessage{
+				Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ChatNavTLVRoomInfo, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+								Cookie: "the-cookie",
+								TLVBlock: wire.TLVBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ChatRoomTLVRoomName, "cool room"),
+									},
+								},
+							}),
+						},
+					},
+				},
+			}
+		}
+		return ret
+	}
+
+	fnNewOServiceBOSParams := func(err error) oServiceParams {
+		ret := oServiceParams{
+			serviceRequestParams: serviceRequestParams{
+				{
+					me: state.NewIdentScreenName("me"),
+					bodyIn: wire.SNAC_0x01_0x04_OServiceServiceRequest{
+						FoodGroup: wire.Chat,
+						TLVRestBlock: wire.TLVRestBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(0x01, wire.SNAC_0x01_0x04_TLVRoomInfo{
+									Cookie: "the-cookie",
+								}),
+							},
+						},
+					},
+				},
+			},
+		}
+		if err != nil {
+			ret.serviceRequestParams[0].err = err
+		} else {
+			ret.serviceRequestParams[0].msg = wire.SNACMessage{
+				Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, "chat-auth-cookie"),
+						},
+					},
+				},
+			}
+		}
+		return ret
+	}
+
+	fnNewAuthParams := func(err error) authParams {
+		ret := authParams{
+			registerChatSessionParams: registerChatSessionParams{
+				{
+					authCookie: []byte("chat-auth-cookie"),
+				},
+			},
+		}
+		if err != nil {
+			ret.registerChatSessionParams[0].err = err
+		} else {
+			ret.registerChatSessionParams[0].sess = newTestSession("me-chat")
+		}
+		return ret
+	}
+
+	fnNewOServiceChatParams := func(err error) oServiceParams {
+		ret := oServiceParams{
+			clientOnlineParams: clientOnlineParams{
+				{
+					body: wire.SNAC_0x01_0x02_OServiceClientOnline{},
+					me:   state.NewIdentScreenName("me-chat"),
+				},
+			},
+		}
+		if err != nil {
+			ret.clientOnlineParams[0].err = err
+		}
+		return ret
+	}
+
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// givenChatRegistry is the chat registry passed to the function
+		givenChatRegistry *ChatRegistry
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// wantChatID is the expected chat ID
+		wantChatID int
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully accept chat",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_chat_accept 0`),
+			givenChatRegistry: func() *ChatRegistry {
+				reg := NewChatRegistry()
+				reg.Add(wire.ICBMRoomInfo{
+					Cookie:   "the-cookie",
+					Exchange: 4,
+					Instance: 0,
+				})
+				return reg
+			}(),
+			mockParams: mockParams{
+				chatNavParams:      fnNewChatNavParams(nil),
+				oServiceBOSParams:  fnNewOServiceBOSParams(nil),
+				authParams:         fnNewAuthParams(nil),
+				oServiceChatParams: fnNewOServiceChatParams(nil),
+			},
+			wantMsg: "CHAT_JOIN:0:cool room",
+		},
+		{
+			name:     "accept chat, receive error from chat oservice svc",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_chat_accept 0`),
+			givenChatRegistry: func() *ChatRegistry {
+				reg := NewChatRegistry()
+				reg.Add(wire.ICBMRoomInfo{
+					Cookie:   "the-cookie",
+					Exchange: 4,
+					Instance: 0,
+				})
+				return reg
+			}(),
+			mockParams: mockParams{
+				chatNavParams:      fnNewChatNavParams(nil),
+				oServiceBOSParams:  fnNewOServiceBOSParams(nil),
+				authParams:         fnNewAuthParams(nil),
+				oServiceChatParams: fnNewOServiceChatParams(io.EOF),
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "accept chat, receive error from auth svc",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_chat_accept 0`),
+			givenChatRegistry: func() *ChatRegistry {
+				reg := NewChatRegistry()
+				reg.Add(wire.ICBMRoomInfo{
+					Cookie:   "the-cookie",
+					Exchange: 4,
+					Instance: 0,
+				})
+				return reg
+			}(),
+			mockParams: mockParams{
+				chatNavParams:     fnNewChatNavParams(nil),
+				oServiceBOSParams: fnNewOServiceBOSParams(nil),
+				authParams:        fnNewAuthParams(io.EOF),
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "accept chat, receive error from BOS oservice svc",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_chat_accept 0`),
+			givenChatRegistry: func() *ChatRegistry {
+				reg := NewChatRegistry()
+				reg.Add(wire.ICBMRoomInfo{
+					Cookie:   "the-cookie",
+					Exchange: 4,
+					Instance: 0,
+				})
+				return reg
+			}(),
+			mockParams: mockParams{
+				chatNavParams:     fnNewChatNavParams(nil),
+				oServiceBOSParams: fnNewOServiceBOSParams(io.EOF),
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "accept chat, receive error from chat nav svc",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_chat_accept 0`),
+			givenChatRegistry: func() *ChatRegistry {
+				reg := NewChatRegistry()
+				reg.Add(wire.ICBMRoomInfo{
+					Cookie:   "the-cookie",
+					Exchange: 4,
+					Instance: 0,
+				})
+				return reg
+			}(),
+			mockParams: mockParams{
+				chatNavParams: fnNewChatNavParams(io.EOF),
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:              "chat doesn't exist",
+			givenCmd:          []byte(`toc_chat_accept 0`),
+			givenChatRegistry: NewChatRegistry(),
+			wantMsg:           cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_chat_accept`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+		{
+			name:     "bad exchange number",
+			givenCmd: []byte(`toc_chat_accept four`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			chatNavSvc := newMockChatNavService(t)
+			for _, params := range tc.mockParams.requestRoomInfoParams {
+				chatNavSvc.EXPECT().
+					RequestRoomInfo(ctx, wire.SNACFrame{}, params.inBody).
+					Return(params.msg, params.err)
+			}
+			bosOServiceSvc := newMockOServiceService(t)
+			for _, params := range tc.mockParams.oServiceBOSParams.serviceRequestParams {
+				bosOServiceSvc.EXPECT().
+					ServiceRequest(ctx, matchSession(params.me), wire.SNACFrame{}, params.bodyIn).
+					Return(params.msg, params.err)
+			}
+			chatOServiceSvc := newMockOServiceService(t)
+			for _, params := range tc.mockParams.oServiceChatParams.clientOnlineParams {
+				chatOServiceSvc.EXPECT().
+					ClientOnline(ctx, params.body, matchSession(params.me)).
+					Return(params.err)
+			}
+			authSvc := newMockAuthService(t)
+			for _, params := range tc.mockParams.authParams.registerChatSessionParams {
+				authSvc.EXPECT().
+					RegisterChatSession(ctx, params.authCookie).
+					Return(params.sess, params.err)
+			}
+
+			svc := OSCARProxy{
+				AuthService:         authSvc,
+				ChatNavService:      chatNavSvc,
+				Logger:              slog.Default(),
+				OServiceServiceBOS:  bosOServiceSvc,
+				OServiceServiceChat: chatOServiceSvc,
+			}
+			chatID, msg := svc.ChatAccept(ctx, tc.me, tc.givenChatRegistry, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+			assert.Equal(t, tc.wantChatID, chatID)
+		})
+	}
+}
+
+func TestOSCARProxy_ChatInvite(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// givenChatRegistry is the chat registry passed to the function
+		givenChatRegistry *ChatRegistry
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully send chat invitation",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_chat_invite 0 "join my chat!" friend1`),
+			givenChatRegistry: func() *ChatRegistry {
+				reg := NewChatRegistry()
+				reg.Add(wire.ICBMRoomInfo{
+					Exchange: 4,
+					Cookie:   "the-cookie",
+					Instance: 0,
+				})
+				return reg
+			}(),
+			mockParams: mockParams{
+				icbmParams: icbmParams{
+					channelMsgToHostParamsICBM: channelMsgToHostParamsICBM{
+						{
+							sender: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+								ChannelID:  wire.ICBMChannelRendezvous,
+								ScreenName: "friend1",
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(0x05, wire.ICBMCh2Fragment{
+											Type:       0,
+											Capability: capChat,
+											TLVRestBlock: wire.TLVRestBlock{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(10, uint16(1)),
+													wire.NewTLVBE(12, "join my chat!"),
+													wire.NewTLVBE(13, "us-ascii"),
+													wire.NewTLVBE(14, "en"),
+													wire.NewTLVBE(10001, wire.ICBMRoomInfo{
+														Exchange: 4,
+														Cookie:   "the-cookie",
+														Instance: 0,
+													}),
+												},
+											},
+										}),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "send chat invitation, receive error from ICBM svc",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_chat_invite 0 "join my chat!" friend1`),
+			givenChatRegistry: func() *ChatRegistry {
+				reg := NewChatRegistry()
+				reg.Add(wire.ICBMRoomInfo{
+					Exchange: 4,
+					Cookie:   "the-cookie",
+					Instance: 0,
+				})
+				return reg
+			}(),
+			mockParams: mockParams{
+				icbmParams: icbmParams{
+					channelMsgToHostParamsICBM: channelMsgToHostParamsICBM{
+						{
+							sender: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+								ChannelID:  wire.ICBMChannelRendezvous,
+								ScreenName: "friend1",
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(0x05, wire.ICBMCh2Fragment{
+											Type:       0,
+											Capability: capChat,
+											TLVRestBlock: wire.TLVRestBlock{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(10, uint16(1)),
+													wire.NewTLVBE(12, "join my chat!"),
+													wire.NewTLVBE(13, "us-ascii"),
+													wire.NewTLVBE(14, "en"),
+													wire.NewTLVBE(10001, wire.ICBMRoomInfo{
+														Exchange: 4,
+														Cookie:   "the-cookie",
+														Instance: 0,
+													}),
+												},
+											},
+										}),
+									},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:              "send chat invitation to non-existent room",
+			me:                newTestSession("me"),
+			givenCmd:          []byte(`toc_chat_invite 0 "join my chat!" friend1`),
+			givenChatRegistry: NewChatRegistry(),
+			wantMsg:           cmdInternalSvcErr,
+		},
+		{
+			name:     "bad chat room ID",
+			givenCmd: []byte(`toc_chat_invite zero "join my chat!" friend1`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_chat_invite`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			icbmSvc := newMockICBMService(t)
+			for _, params := range tc.mockParams.channelMsgToHostParamsICBM {
+				icbmSvc.EXPECT().
+					ChannelMsgToHost(ctx, matchSession(params.sender), wire.SNACFrame{}, params.inBody).
+					Return(nil, params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:      slog.Default(),
+				ICBMService: icbmSvc,
+			}
+			msg := svc.ChatInvite(ctx, tc.me, tc.givenChatRegistry, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_ChatJoin(t *testing.T) {
+	fnNewChatNavParams := func(err error) chatNavParams {
+		ret := chatNavParams{
+			createRoomParams: createRoomParams{
+				{
+					me: state.NewIdentScreenName("me"),
+					inBody: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+						Exchange: 4,
+						Cookie:   "create",
+						TLVBlock: wire.TLVBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.ChatRoomTLVRoomName, "cool room"),
+							},
+						},
+					},
+				},
+			},
+		}
+		if err != nil {
+			ret.createRoomParams[0].err = err
+		} else {
+			ret.createRoomParams[0].msg = wire.SNACMessage{
+				Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ChatNavTLVRoomInfo, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+								Cookie: "the-cookie",
+							}),
+						},
+					},
+				},
+			}
+		}
+		return ret
+	}
+
+	fnNewOServiceBOSParams := func(err error) oServiceParams {
+		ret := oServiceParams{
+			serviceRequestParams: serviceRequestParams{
+				{
+					me: state.NewIdentScreenName("me"),
+					bodyIn: wire.SNAC_0x01_0x04_OServiceServiceRequest{
+						FoodGroup: wire.Chat,
+						TLVRestBlock: wire.TLVRestBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(0x01, wire.SNAC_0x01_0x04_TLVRoomInfo{
+									Cookie: "the-cookie",
+								}),
+							},
+						},
+					},
+				},
+			},
+		}
+		if err != nil {
+			ret.serviceRequestParams[0].err = err
+		} else {
+			ret.serviceRequestParams[0].msg = wire.SNACMessage{
+				Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, "chat-auth-cookie"),
+						},
+					},
+				},
+			}
+		}
+		return ret
+	}
+
+	fnNewAuthParams := func(err error) authParams {
+		ret := authParams{
+			registerChatSessionParams: registerChatSessionParams{
+				{
+					authCookie: []byte("chat-auth-cookie"),
+				},
+			},
+		}
+		if err != nil {
+			ret.registerChatSessionParams[0].err = err
+		} else {
+			ret.registerChatSessionParams[0].sess = newTestSession("me-chat")
+		}
+		return ret
+	}
+
+	fnNewOServiceChatParams := func(err error) oServiceParams {
+		ret := oServiceParams{
+			clientOnlineParams: clientOnlineParams{
+				{
+					body: wire.SNAC_0x01_0x02_OServiceClientOnline{},
+					me:   state.NewIdentScreenName("me-chat"),
+				},
+			},
+		}
+		if err != nil {
+			ret.clientOnlineParams[0].err = err
+		}
+		return ret
+	}
+
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// givenChatRegistry is the chat registry passed to the function
+		givenChatRegistry *ChatRegistry
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// wantChatID is the expected chat ID
+		wantChatID int
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:              "successfully join chat",
+			me:                newTestSession("me"),
+			givenCmd:          []byte(`toc_chat_join 4 "cool room"`),
+			givenChatRegistry: NewChatRegistry(),
+			mockParams: mockParams{
+				chatNavParams:      fnNewChatNavParams(nil),
+				oServiceBOSParams:  fnNewOServiceBOSParams(nil),
+				authParams:         fnNewAuthParams(nil),
+				oServiceChatParams: fnNewOServiceChatParams(nil),
+			},
+			wantMsg: "CHAT_JOIN:0:cool room",
+		},
+		{
+			name:              "join chat, receive error from chat oservice svc",
+			me:                newTestSession("me"),
+			givenCmd:          []byte(`toc_chat_join 4 "cool room"`),
+			givenChatRegistry: NewChatRegistry(),
+			mockParams: mockParams{
+				chatNavParams:      fnNewChatNavParams(nil),
+				oServiceBOSParams:  fnNewOServiceBOSParams(nil),
+				authParams:         fnNewAuthParams(nil),
+				oServiceChatParams: fnNewOServiceChatParams(io.EOF),
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:              "join chat, receive error from auth svc",
+			me:                newTestSession("me"),
+			givenCmd:          []byte(`toc_chat_join 4 "cool room"`),
+			givenChatRegistry: NewChatRegistry(),
+			mockParams: mockParams{
+				chatNavParams:     fnNewChatNavParams(nil),
+				oServiceBOSParams: fnNewOServiceBOSParams(nil),
+				authParams:        fnNewAuthParams(io.EOF),
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:              "join chat, receive error from BOS oservice svc",
+			me:                newTestSession("me"),
+			givenCmd:          []byte(`toc_chat_join 4 "cool room"`),
+			givenChatRegistry: NewChatRegistry(),
+			mockParams: mockParams{
+				chatNavParams:     fnNewChatNavParams(nil),
+				oServiceBOSParams: fnNewOServiceBOSParams(io.EOF),
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:              "join chat, receive error from chat nav svc",
+			me:                newTestSession("me"),
+			givenCmd:          []byte(`toc_chat_join 4 "cool room"`),
+			givenChatRegistry: NewChatRegistry(),
+			mockParams: mockParams{
+				chatNavParams: fnNewChatNavParams(io.EOF),
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_chat_join`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+		{
+			name:     "bad exchange number",
+			givenCmd: []byte(`toc_chat_join four "cool room"`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			chatNavSvc := newMockChatNavService(t)
+			for _, params := range tc.mockParams.createRoomParams {
+				chatNavSvc.EXPECT().
+					CreateRoom(ctx, matchSession(params.me), wire.SNACFrame{}, params.inBody).
+					Return(params.msg, params.err)
+			}
+			bosOServiceSvc := newMockOServiceService(t)
+			for _, params := range tc.mockParams.oServiceBOSParams.serviceRequestParams {
+				bosOServiceSvc.EXPECT().
+					ServiceRequest(ctx, matchSession(params.me), wire.SNACFrame{}, params.bodyIn).
+					Return(params.msg, params.err)
+			}
+			chatOServiceSvc := newMockOServiceService(t)
+			for _, params := range tc.mockParams.oServiceChatParams.clientOnlineParams {
+				chatOServiceSvc.EXPECT().
+					ClientOnline(ctx, params.body, matchSession(params.me)).
+					Return(params.err)
+			}
+			authSvc := newMockAuthService(t)
+			for _, params := range tc.mockParams.authParams.registerChatSessionParams {
+				authSvc.EXPECT().
+					RegisterChatSession(ctx, params.authCookie).
+					Return(params.sess, params.err)
+			}
+
+			svc := OSCARProxy{
+				AuthService:         authSvc,
+				ChatNavService:      chatNavSvc,
+				Logger:              slog.Default(),
+				OServiceServiceBOS:  bosOServiceSvc,
+				OServiceServiceChat: chatOServiceSvc,
+			}
+			chatID, msg := svc.ChatJoin(ctx, tc.me, tc.givenChatRegistry, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+			assert.Equal(t, tc.wantChatID, chatID)
+		})
+	}
+}
+
+func TestOSCARProxy_ChatLeave(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// givenChatRegistry is the chat registry passed to the function
+		givenChatRegistry *ChatRegistry
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully leave chat",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_chat_leave 0`),
+			givenChatRegistry: func() *ChatRegistry {
+				reg := NewChatRegistry()
+				reg.RegisterSess(0, newTestSession("me"))
+				return reg
+			}(),
+			mockParams: mockParams{
+				authParams: authParams{
+					signoutChatParams: signoutChatParams{
+						{
+							me: state.NewIdentScreenName("me"),
+						},
+					},
+				},
+			},
+			wantMsg: "CHAT_LEFT:0",
+		},
+		{
+			name:     "chat room ID with invalid format",
+			givenCmd: []byte(`toc_chat_leave zero`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+		{
+			name:              "missing chat session",
+			givenCmd:          []byte(`toc_chat_leave 0`),
+			givenChatRegistry: NewChatRegistry(),
+			wantMsg:           cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_chat_leave`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			authSvc := newMockAuthService(t)
+			for _, params := range tc.mockParams.signoutChatParams {
+				authSvc.EXPECT().SignoutChat(ctx, matchSession(params.me))
+			}
+
+			svc := OSCARProxy{
+				Logger:      slog.Default(),
+				AuthService: authSvc,
+			}
+			msg := svc.ChatLeave(ctx, tc.givenChatRegistry, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_ChatSend(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// givenChatRegistry is the chat registry passed to the function
+		givenChatRegistry *ChatRegistry
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully send chat message",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_chat_send 0 "Hello world!"`),
+			givenChatRegistry: func() *ChatRegistry {
+				reg := NewChatRegistry()
+				reg.RegisterSess(0, newTestSession("me"))
+				return reg
+			}(),
+			mockParams: mockParams{
+				chatParams: chatParams{
+					channelMsgToHostParamsChat: channelMsgToHostParamsChat{
+						{
+							sender: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+								Channel: wire.ICBMChannelMIME,
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ChatTLVEnableReflectionFlag, uint8(1)),
+										wire.NewTLVBE(wire.ChatTLVSenderInformation, newTestSession("me").TLVUserInfo()),
+										wire.NewTLVBE(wire.ChatTLVPublicWhisperFlag, []byte{}),
+										wire.NewTLVBE(wire.ChatTLVMessageInfo, wire.TLVRestBlock{
+											TLVList: wire.TLVList{
+												wire.NewTLVBE(wire.ChatTLVMessageInfoText, "Hello world!"),
+											},
+										}),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			wantMsg: "CHAT_IN:0:me:F:Hello world!",
+		},
+		{
+			name:     "send chat message, receive error from chat svc",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_chat_send 0 "Hello world!"`),
+			givenChatRegistry: func() *ChatRegistry {
+				reg := NewChatRegistry()
+				reg.RegisterSess(0, newTestSession("me"))
+				return reg
+			}(),
+			mockParams: mockParams{
+				chatParams: chatParams{
+					channelMsgToHostParamsChat: channelMsgToHostParamsChat{
+						{
+							sender: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+								Channel: wire.ICBMChannelMIME,
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ChatTLVEnableReflectionFlag, uint8(1)),
+										wire.NewTLVBE(wire.ChatTLVSenderInformation, newTestSession("me").TLVUserInfo()),
+										wire.NewTLVBE(wire.ChatTLVPublicWhisperFlag, []byte{}),
+										wire.NewTLVBE(wire.ChatTLVMessageInfo, wire.TLVRestBlock{
+											TLVList: wire.TLVList{
+												wire.NewTLVBE(wire.ChatTLVMessageInfoText, "Hello world!"),
+											},
+										}),
+									},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "chat room ID with invalid format",
+			givenCmd: []byte(`toc_chat_send zero "Hello world!"`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+		{
+			name:              "missing chat session",
+			givenCmd:          []byte(`toc_chat_send 0 "Hello world!"`),
+			givenChatRegistry: NewChatRegistry(),
+			wantMsg:           cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_chat_send`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			chatSvc := newMockChatService(t)
+			for _, params := range tc.mockParams.channelMsgToHostParamsChat {
+				chatSvc.EXPECT().
+					ChannelMsgToHost(ctx, matchSession(params.sender), wire.SNACFrame{}, params.inBody).
+					Return(params.result, params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:      slog.Default(),
+				ChatService: chatSvc,
+			}
+			msg := svc.ChatSend(ctx, tc.givenChatRegistry, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_Evil(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully warn normally",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_evil them norm`),
+			mockParams: mockParams{
+				icbmParams: icbmParams{
+					evilRequestParams: evilRequestParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x04_0x08_ICBMEvilRequest{
+								SendAs:     0,
+								ScreenName: "them",
+							},
+							msg: wire.SNACMessage{
+								Body: wire.SNAC_0x04_0x09_ICBMEvilReply{},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "successfully warn anonymously",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_evil them anon`),
+			mockParams: mockParams{
+				icbmParams: icbmParams{
+					evilRequestParams: evilRequestParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x04_0x08_ICBMEvilRequest{
+								SendAs:     1,
+								ScreenName: "them",
+							},
+							msg: wire.SNACMessage{
+								Body: wire.SNAC_0x04_0x09_ICBMEvilReply{},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "warn, receive error from ICBM service",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_evil them anon`),
+			mockParams: mockParams{
+				icbmParams: icbmParams{
+					evilRequestParams: evilRequestParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x04_0x08_ICBMEvilRequest{
+								SendAs:     1,
+								ScreenName: "them",
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "warn, receive snac err",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_evil them anon`),
+			mockParams: mockParams{
+				icbmParams: icbmParams{
+					evilRequestParams: evilRequestParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x04_0x08_ICBMEvilRequest{
+								SendAs:     1,
+								ScreenName: "them",
+							},
+							msg: wire.SNACMessage{
+								Body: wire.SNACError{},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "warn, ICBM svc returns unexpected snac type",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_evil them anon`),
+			mockParams: mockParams{
+				icbmParams: icbmParams{
+					evilRequestParams: evilRequestParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x04_0x08_ICBMEvilRequest{
+								SendAs:     1,
+								ScreenName: "them",
+							},
+							msg: wire.SNACMessage{
+								Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{},
+							},
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "warn with incorrect type",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_evil them blah`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_evil`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			icbmSvc := newMockICBMService(t)
+			for _, params := range tc.mockParams.evilRequestParams {
+				icbmSvc.EXPECT().
+					EvilRequest(ctx, matchSession(params.me), wire.SNACFrame{}, params.inBody).
+					Return(params.msg, params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:      slog.Default(),
+				ICBMService: icbmSvc,
+			}
+			msg := svc.Evil(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_GetDirSearchURL(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully request user info",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_dir_search "first name":"middle name":"last name":"maiden name":"city":"state":"country":"email"`),
+			mockParams: mockParams{
+				cookieBakerParams: cookieBakerParams{
+					issueParams: issueParams{
+						{
+							data:       []byte("me"),
+							returnData: []byte("monster"),
+						},
+					},
+				},
+			},
+			wantMsg: "GOTO_URL:search results:dir_search?city=city&cookie=6d6f6e73746572&country=country&email=email&first_name=first+name&last_name=last+name&maiden_name=maiden+name&middle_name=middle+name&state=state",
+		},
+		{
+			name:     "successfully request user info by keywords",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_dir_search ::::::::::"searchkw"`),
+			mockParams: mockParams{
+				cookieBakerParams: cookieBakerParams{
+					issueParams: issueParams{
+						{
+							data:       []byte("me"),
+							returnData: []byte("monster"),
+						},
+					},
+				},
+			},
+			wantMsg: "GOTO_URL:search results:dir_search?cookie=6d6f6e73746572&keyword=searchkw",
+		},
+		{
+			name:     "request user info with too many params",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_dir_search ::::::::::::::::::::"searchkw"`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+		{
+			name:     "request user info, get cookie issue error",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_dir_search them`),
+			mockParams: mockParams{
+				cookieBakerParams: cookieBakerParams{
+					issueParams: issueParams{
+						{
+							data:      []byte("me"),
+							returnErr: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_dir_search`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			cookieBaker := newMockCookieBaker(t)
+			for _, params := range tc.mockParams.issueParams {
+				cookieBaker.EXPECT().
+					Issue(params.data).
+					Return(params.returnData, params.returnErr)
+			}
+
+			svc := OSCARProxy{
+				Logger:      slog.Default(),
+				CookieBaker: cookieBaker,
+			}
+			msg := svc.GetDirSearchURL(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_GetDirURL(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully request user dir info",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_get_dir them`),
+			mockParams: mockParams{
+				cookieBakerParams: cookieBakerParams{
+					issueParams: issueParams{
+						{
+							data:       []byte("me"),
+							returnData: []byte("monster"),
+						},
+					},
+				},
+			},
+			wantMsg: "GOTO_URL:directory info:dir_info?cookie=6d6f6e73746572&user=them",
+		},
+		{
+			name:     "request user info, get cookie issue error",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_get_dir them`),
+			mockParams: mockParams{
+				cookieBakerParams: cookieBakerParams{
+					issueParams: issueParams{
+						{
+							data:      []byte("me"),
+							returnErr: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_get_dir`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			cookieBaker := newMockCookieBaker(t)
+			for _, params := range tc.mockParams.issueParams {
+				cookieBaker.EXPECT().
+					Issue(params.data).
+					Return(params.returnData, params.returnErr)
+			}
+
+			svc := OSCARProxy{
+				Logger:      slog.Default(),
+				CookieBaker: cookieBaker,
+			}
+			msg := svc.GetDirURL(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_GetInfoURL(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully request user info",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_get_info them`),
+			mockParams: mockParams{
+				cookieBakerParams: cookieBakerParams{
+					issueParams: issueParams{
+						{
+							data:       []byte("me"),
+							returnData: []byte("monster"),
+						},
+					},
+				},
+			},
+			wantMsg: "GOTO_URL:profile:info?cookie=6d6f6e73746572&from=me&user=them",
+		},
+		{
+			name:     "request user info, get cookie issue error",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_get_info them`),
+			mockParams: mockParams{
+				cookieBakerParams: cookieBakerParams{
+					issueParams: issueParams{
+						{
+							data:      []byte("me"),
+							returnErr: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_get_info`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			cookieBaker := newMockCookieBaker(t)
+			for _, params := range tc.mockParams.issueParams {
+				cookieBaker.EXPECT().
+					Issue(params.data).
+					Return(params.returnData, params.returnErr)
+			}
+
+			svc := OSCARProxy{
+				Logger:      slog.Default(),
+				CookieBaker: cookieBaker,
+			}
+			msg := svc.GetInfoURL(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_InitDone(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully initialize connection",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_init_done`),
+			mockParams: mockParams{
+				oServiceBOSParams: oServiceParams{
+					clientOnlineParams: clientOnlineParams{
+						{
+							me:   state.NewIdentScreenName("me"),
+							body: wire.SNAC_0x01_0x02_OServiceClientOnline{},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "initialize connection, receive err from BOS oservice svc",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_init_done`),
+			mockParams: mockParams{
+				oServiceBOSParams: oServiceParams{
+					clientOnlineParams: clientOnlineParams{
+						{
+							me:   state.NewIdentScreenName("me"),
+							body: wire.SNAC_0x01_0x02_OServiceClientOnline{},
+							err:  io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_init_done_diff`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			oSvc := newMockOServiceService(t)
+			for _, params := range tc.mockParams.oServiceBOSParams.clientOnlineParams {
+				oSvc.EXPECT().
+					ClientOnline(ctx, params.body, matchSession(params.me)).
+					Return(params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:             slog.Default(),
+				OServiceServiceBOS: oSvc,
+			}
+			msg := svc.InitDone(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_RemoveBuddy(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully remove buddies",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_remove_buddy friend1 friend2 friend3"),
+			mockParams: mockParams{
+				buddyParams: buddyParams{
+					delBuddiesParams: delBuddiesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x03_0x05_BuddyDelBuddies{
+								Buddies: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend1"},
+									{ScreenName: "friend2"},
+									{ScreenName: "friend3"},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "remove buddies, receive error from buddy service",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_remove_buddy friend1"),
+			mockParams: mockParams{
+				buddyParams: buddyParams{
+					delBuddiesParams: delBuddiesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x03_0x05_BuddyDelBuddies{
+								Buddies: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend1"},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_remove_buddy_bad`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			buddySvc := newMockBuddyService(t)
+			for _, params := range tc.mockParams.delBuddiesParams {
+				buddySvc.EXPECT().
+					DelBuddies(ctx, matchSession(params.me), params.inBody).
+					Return(params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:       slog.Default(),
+				BuddyService: buddySvc,
+			}
+			msg := svc.RemoveBuddy(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_SendIM(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully send instant message",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_send_im chattingChuck "hello world!"`),
+			mockParams: mockParams{
+				icbmParams: icbmParams{
+					channelMsgToHostParamsICBM: channelMsgToHostParamsICBM{
+						{
+							sender: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+								ChannelID:  wire.ICBMChannelIM,
+								ScreenName: "chattingChuck",
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ICBMTLVAOLIMData, []wire.ICBMCh1Fragment{
+											{
+												ID:      5,
+												Version: 1,
+												Payload: []byte{1, 1, 2},
+											},
+											{
+												ID:      1,
+												Version: 1,
+												Payload: []byte{
+													0x00, 0x00,
+													0x00, 0x00,
+													'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', '!',
+												},
+											},
+										}),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "successfully auto-reply send instant message",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_send_im chattingChuck "hello world!" auto`),
+			mockParams: mockParams{
+				icbmParams: icbmParams{
+					channelMsgToHostParamsICBM: channelMsgToHostParamsICBM{
+						{
+							sender: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+								ChannelID:  wire.ICBMChannelIM,
+								ScreenName: "chattingChuck",
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ICBMTLVAOLIMData, []wire.ICBMCh1Fragment{
+											{
+												ID:      5,
+												Version: 1,
+												Payload: []byte{1, 1, 2},
+											},
+											{
+												ID:      1,
+												Version: 1,
+												Payload: []byte{
+													0x00, 0x00,
+													0x00, 0x00,
+													'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', '!',
+												},
+											},
+										}),
+										wire.NewTLVBE(wire.ICBMTLVAutoResponse, []byte{}),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "send instant message, receive error from ICBM service",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_send_im chattingChuck "hello world!"`),
+			mockParams: mockParams{
+				icbmParams: icbmParams{
+					channelMsgToHostParamsICBM: channelMsgToHostParamsICBM{
+						{
+							sender: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+								ChannelID:  wire.ICBMChannelIM,
+								ScreenName: "chattingChuck",
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ICBMTLVAOLIMData, []wire.ICBMCh1Fragment{
+											{
+												ID:      5,
+												Version: 1,
+												Payload: []byte{1, 1, 2},
+											},
+											{
+												ID:      1,
+												Version: 1,
+												Payload: []byte{
+													0x00, 0x00,
+													0x00, 0x00,
+													'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', '!',
+												},
+											},
+										}),
+									},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_send_im`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			icbmSvc := newMockICBMService(t)
+			for _, params := range tc.mockParams.channelMsgToHostParamsICBM {
+				icbmSvc.EXPECT().
+					ChannelMsgToHost(ctx, matchSession(params.sender), params.inFrame, params.inBody).
+					Return(params.result, params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:      slog.Default(),
+				ICBMService: icbmSvc,
+			}
+			msg := svc.SendIM(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_SetAway(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully set away with message",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_set_away "I'm away from my computer right now."`),
+			mockParams: mockParams{
+				locateParams: locateParams{
+					setInfoParams: setInfoParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x02_0x04_LocateSetInfo{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, "I'm away from my computer right now."),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "successfully set away without message",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_set_away`),
+			mockParams: mockParams{
+				locateParams: locateParams{
+					setInfoParams: setInfoParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x02_0x04_LocateSetInfo{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, ""),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "set away message, receive error from locate service",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_set_away "I'm away from my computer right now."`),
+			mockParams: mockParams{
+				locateParams: locateParams{
+					setInfoParams: setInfoParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x02_0x04_LocateSetInfo{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, "I'm away from my computer right now."),
+									},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_set_away_bad`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			locateSvc := newMockLocateService(t)
+			for _, params := range tc.mockParams.setInfoParams {
+				locateSvc.EXPECT().
+					SetInfo(ctx, matchSession(params.me), params.inBody).
+					Return(params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:        slog.Default(),
+				LocateService: locateSvc,
+			}
+			msg := svc.SetAway(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_SetCaps(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully set capabilities",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_set_caps 09460000-4C7F-11D1-8222-444553540000 09460001-4C7F-11D1-8222-444553540000`),
+			mockParams: mockParams{
+				locateParams: locateParams{
+					setInfoParams: setInfoParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x02_0x04_LocateSetInfo{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.LocateTLVTagsInfoCapabilities, []uuid.UUID{
+											uuid.MustParse("09460000-4C7F-11D1-8222-444553540000"),
+											uuid.MustParse("09460001-4C7F-11D1-8222-444553540000"),
+											capChat,
+										}),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "set capability, receive error from locate service",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_set_caps 09460000-4C7F-11D1-8222-444553540000`),
+			mockParams: mockParams{
+				locateParams: locateParams{
+					setInfoParams: setInfoParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x02_0x04_LocateSetInfo{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.LocateTLVTagsInfoCapabilities, []uuid.UUID{
+											uuid.MustParse("09460000-4C7F-11D1-8222-444553540000"),
+											capChat,
+										}),
+									},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "set malformed capability UUID",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_set_caps 09460000-`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_set_caps_bad`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			locateSvc := newMockLocateService(t)
+			for _, params := range tc.mockParams.setInfoParams {
+				locateSvc.EXPECT().
+					SetInfo(ctx, matchSession(params.me), params.inBody).
+					Return(params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:        slog.Default(),
+				LocateService: locateSvc,
+			}
+			msg := svc.SetCaps(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_SetConfig(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully set permit all config",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_set_config {m 1\ng Buddies\nb friend1\nb friend2\n}\n"),
+			mockParams: mockParams{
+				permitDenyParams: permitDenyParams{
+					// confusingly, setting "permit all" mode requires adding a
+					// deny list entry
+					addDenyListEntriesParams: addDenyListEntriesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							body: wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries{
+								Users: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "me"},
+								},
+							},
+						},
+					},
+				},
+				buddyParams: buddyParams{
+					addBuddiesParams: addBuddiesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+								Buddies: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend1"},
+									{ScreenName: "friend2"},
+								},
+							},
+						},
+					},
+				},
+				tocConfigParams: tocConfigParams{
+					setTOCConfigParams: setTOCConfigParams{
+						{
+							user:   state.NewIdentScreenName("me"),
+							config: "m 1\ng Buddies\nb friend1\nb friend2",
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "set permit all config, receive err from config store svc",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_set_config {m 1\ng Buddies\nb friend1\n}\n"),
+			mockParams: mockParams{
+				permitDenyParams: permitDenyParams{
+					// confusingly, setting "permit all" mode requires adding a
+					// deny list entry
+					addDenyListEntriesParams: addDenyListEntriesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							body: wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries{
+								Users: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "me"},
+								},
+							},
+						},
+					},
+				},
+				buddyParams: buddyParams{
+					addBuddiesParams: addBuddiesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+								Buddies: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend1"},
+								},
+							},
+						},
+					},
+				},
+				tocConfigParams: tocConfigParams{
+					setTOCConfigParams: setTOCConfigParams{
+						{
+							user:   state.NewIdentScreenName("me"),
+							config: "m 1\ng Buddies\nb friend1",
+							err:    io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "set permit all config, receive err from buddy svc",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_set_config {m 1\ng Buddies\nb friend1\n}\n"),
+			mockParams: mockParams{
+				permitDenyParams: permitDenyParams{
+					// confusingly, setting "permit all" mode requires adding a
+					// deny list entry
+					addDenyListEntriesParams: addDenyListEntriesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							body: wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries{
+								Users: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "me"},
+								},
+							},
+						},
+					},
+				},
+				buddyParams: buddyParams{
+					addBuddiesParams: addBuddiesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+								Buddies: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend1"},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "set permit all config, receive err from permit-deny svc",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_set_config {m 1\ng Buddies\nb friend1\nb friend2\n}\n"),
+			mockParams: mockParams{
+				permitDenyParams: permitDenyParams{
+					// confusingly, setting "permit all" mode requires adding a
+					// deny list entry
+					addDenyListEntriesParams: addDenyListEntriesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							body: wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries{
+								Users: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "me"},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "successfully set deny all config",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_set_config {m 2\ng Buddies\nb friend1\nb friend2\n}\n"),
+			mockParams: mockParams{
+				permitDenyParams: permitDenyParams{
+					// confusingly, setting "deny all" mode requires adding a
+					// permit list entry
+					addPermListEntriesParams: addPermListEntriesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							body: wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries{
+								Users: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "me"},
+								},
+							},
+						},
+					},
+				},
+				buddyParams: buddyParams{
+					addBuddiesParams: addBuddiesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+								Buddies: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend1"},
+									{ScreenName: "friend2"},
+								},
+							},
+						},
+					},
+				},
+				tocConfigParams: tocConfigParams{
+					setTOCConfigParams: setTOCConfigParams{
+						{
+							user:   state.NewIdentScreenName("me"),
+							config: "m 2\ng Buddies\nb friend1\nb friend2",
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "set deny all config, receive err from permit-deny svc",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_set_config {m 2\ng Buddies\nb friend1\nb friend2\n}\n"),
+			mockParams: mockParams{
+				permitDenyParams: permitDenyParams{
+					// confusingly, setting "deny all" mode requires adding a
+					// permit list entry
+					addPermListEntriesParams: addPermListEntriesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							body: wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries{
+								Users: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "me"},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "successfully set permit some config",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_set_config {m 3\np friend3\np friend4\n\ng Buddies\nb friend1\nb friend2\n}\n"),
+			mockParams: mockParams{
+				permitDenyParams: permitDenyParams{
+					addPermListEntriesParams: addPermListEntriesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							body: wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries{
+								Users: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend3"},
+									{ScreenName: "friend4"},
+								},
+							},
+						},
+					},
+				},
+				buddyParams: buddyParams{
+					addBuddiesParams: addBuddiesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+								Buddies: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend1"},
+									{ScreenName: "friend2"},
+								},
+							},
+						},
+					},
+				},
+				tocConfigParams: tocConfigParams{
+					setTOCConfigParams: setTOCConfigParams{
+						{
+							user:   state.NewIdentScreenName("me"),
+							config: "m 3\np friend3\np friend4\n\ng Buddies\nb friend1\nb friend2",
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "set permit some config, receive err from permit-deny svc",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_set_config {m 3\np friend3\np friend4\n\ng Buddies\nb friend1\nb friend2\n}\n"),
+			mockParams: mockParams{
+				permitDenyParams: permitDenyParams{
+					addPermListEntriesParams: addPermListEntriesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							body: wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries{
+								Users: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend3"},
+									{ScreenName: "friend4"},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "successfully set deny some config",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_set_config {m 4\nd friend3\nd friend4\n\ng Buddies\nb friend1\nb friend2\n}\n"),
+			mockParams: mockParams{
+				permitDenyParams: permitDenyParams{
+					addDenyListEntriesParams: addDenyListEntriesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							body: wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries{
+								Users: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend3"},
+									{ScreenName: "friend4"},
+								},
+							},
+						},
+					},
+				},
+				buddyParams: buddyParams{
+					addBuddiesParams: addBuddiesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+								Buddies: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend1"},
+									{ScreenName: "friend2"},
+								},
+							},
+						},
+					},
+				},
+				tocConfigParams: tocConfigParams{
+					setTOCConfigParams: setTOCConfigParams{
+						{
+							user:   state.NewIdentScreenName("me"),
+							config: "m 4\nd friend3\nd friend4\n\ng Buddies\nb friend1\nb friend2",
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "set deny some config, receive err from permit-deny svc",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_set_config {m 4\nd friend3\nd friend4\n\ng Buddies\nb friend1\nb friend2\n}\n"),
+			mockParams: mockParams{
+				permitDenyParams: permitDenyParams{
+					addDenyListEntriesParams: addDenyListEntriesParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							body: wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries{
+								Users: []struct {
+									ScreenName string `oscar:"len_prefix=uint8"`
+								}{
+									{ScreenName: "friend3"},
+									{ScreenName: "friend4"},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "set unknown PD mode",
+			me:       newTestSession("me"),
+			givenCmd: []byte("toc_set_config {m 5\nd friend3\nd friend4\n\ng Buddies\nb friend1\nb friend2\n}\n"),
+			wantMsg:  cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_chat_leave`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			pdSvc := newMockPermitDenyService(t)
+			for _, params := range tc.mockParams.addDenyListEntriesParams {
+				pdSvc.EXPECT().
+					AddDenyListEntries(ctx, matchSession(params.me), params.body).
+					Return(params.err)
+			}
+			for _, params := range tc.mockParams.addPermListEntriesParams {
+				pdSvc.EXPECT().
+					AddPermListEntries(ctx, matchSession(params.me), params.body).
+					Return(params.err)
+			}
+			buddySvc := newMockBuddyService(t)
+			for _, params := range tc.mockParams.addBuddiesParams {
+				buddySvc.EXPECT().
+					AddBuddies(ctx, matchSession(params.me), params.inBody).
+					Return(params.err)
+			}
+			tocConfigSvc := newMockTOCConfigStore(t)
+			for _, params := range tc.mockParams.setTOCConfigParams {
+				tocConfigSvc.EXPECT().
+					SetTOCConfig(params.user, params.config).
+					Return(params.err)
+			}
+
+			svc := OSCARProxy{
+				BuddyService:      buddySvc,
+				Logger:            slog.Default(),
+				PermitDenyService: pdSvc,
+				TOCConfigStore:    tocConfigSvc,
+			}
+			msg := svc.SetConfig(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_SetDir(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully set directory info with quoted fields",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_set_dir "first name":"middle name":"last name":"maiden name":"city":"state":"country":"email":"allow web searches"`),
+			mockParams: mockParams{
+				locateParams: locateParams{
+					setDirInfoParams: setDirInfoParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x02_0x09_LocateSetDirInfo{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ODirTLVFirstName, "first name"),
+										wire.NewTLVBE(wire.ODirTLVMiddleName, "middle name"),
+										wire.NewTLVBE(wire.ODirTLVLastName, "last name"),
+										wire.NewTLVBE(wire.ODirTLVMaidenName, "maiden name"),
+										wire.NewTLVBE(wire.ODirTLVCountry, "country"),
+										wire.NewTLVBE(wire.ODirTLVState, "state"),
+										wire.NewTLVBE(wire.ODirTLVCity, "city"),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "successfully set directory info with some blank fields",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_set_dir "first name"::"last name"::"city":"state":"country":"email":"allow web searches"`),
+			mockParams: mockParams{
+				locateParams: locateParams{
+					setDirInfoParams: setDirInfoParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x02_0x09_LocateSetDirInfo{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ODirTLVFirstName, "first name"),
+										wire.NewTLVBE(wire.ODirTLVMiddleName, ""),
+										wire.NewTLVBE(wire.ODirTLVLastName, "last name"),
+										wire.NewTLVBE(wire.ODirTLVMaidenName, ""),
+										wire.NewTLVBE(wire.ODirTLVCountry, "country"),
+										wire.NewTLVBE(wire.ODirTLVState, "state"),
+										wire.NewTLVBE(wire.ODirTLVCity, "city"),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "successfully set directory info with last two fields absent",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_set_dir "first name"::"last name"::"city":"state":"country"`),
+			mockParams: mockParams{
+				locateParams: locateParams{
+					setDirInfoParams: setDirInfoParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x02_0x09_LocateSetDirInfo{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ODirTLVFirstName, "first name"),
+										wire.NewTLVBE(wire.ODirTLVMiddleName, ""),
+										wire.NewTLVBE(wire.ODirTLVLastName, "last name"),
+										wire.NewTLVBE(wire.ODirTLVMaidenName, ""),
+										wire.NewTLVBE(wire.ODirTLVCountry, "country"),
+										wire.NewTLVBE(wire.ODirTLVState, "state"),
+										wire.NewTLVBE(wire.ODirTLVCity, "city"),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "set directory info, receive error from locate svc",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_set_dir "first name":"middle name":"last name":"maiden name":"city":"state":"country":"email":"allow web searches"`),
+			mockParams: mockParams{
+				locateParams: locateParams{
+					setDirInfoParams: setDirInfoParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x02_0x09_LocateSetDirInfo{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ODirTLVFirstName, "first name"),
+										wire.NewTLVBE(wire.ODirTLVMiddleName, "middle name"),
+										wire.NewTLVBE(wire.ODirTLVLastName, "last name"),
+										wire.NewTLVBE(wire.ODirTLVMaidenName, "maiden name"),
+										wire.NewTLVBE(wire.ODirTLVCountry, "country"),
+										wire.NewTLVBE(wire.ODirTLVState, "state"),
+										wire.NewTLVBE(wire.ODirTLVCity, "city"),
+									},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "set directory with too many fields present",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_set_dir "first name"::"last name"::"city":"state":"country":"email":"allow web searches":"extra":"extra"`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_set_dir`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			locateSvc := newMockLocateService(t)
+			for _, params := range tc.mockParams.setDirInfoParams {
+				locateSvc.EXPECT().
+					SetDirInfo(ctx, matchSession(params.me), wire.SNACFrame{}, params.inBody).
+					Return(params.msg, params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:        slog.Default(),
+				LocateService: locateSvc,
+			}
+			msg := svc.SetDir(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_SetIdle(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully set idle status",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_set_idle 10`),
+			mockParams: mockParams{
+				oServiceBOSParams: oServiceParams{
+					idleNotificationParams: idleNotificationParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							bodyIn: wire.SNAC_0x01_0x11_OServiceIdleNotification{
+								IdleTime: uint32(10),
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "set idle status, receive err from BOS oservice svc",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_set_idle 10`),
+			mockParams: mockParams{
+				oServiceBOSParams: oServiceParams{
+					idleNotificationParams: idleNotificationParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							bodyIn: wire.SNAC_0x01_0x11_OServiceIdleNotification{
+								IdleTime: uint32(10),
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "bad secs param",
+			givenCmd: []byte(`toc_set_idle zero`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_set_idle`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			oServiceSvc := newMockOServiceService(t)
+			for _, params := range tc.mockParams.oServiceBOSParams.idleNotificationParams {
+				oServiceSvc.EXPECT().
+					IdleNotification(ctx, matchSession(params.me), params.bodyIn).
+					Return(params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:             slog.Default(),
+				OServiceServiceBOS: oServiceSvc,
+			}
+			msg := svc.SetIdle(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_SetInfo(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:     "successfully set profile",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_set_info "my profile!"`),
+			mockParams: mockParams{
+				locateParams: locateParams{
+					setInfoParams: setInfoParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x02_0x04_LocateSetInfo{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, "my profile!"),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:     "set profile, receive error from locate svc",
+			me:       newTestSession("me"),
+			givenCmd: []byte(`toc_set_info "my profile!"`),
+			mockParams: mockParams{
+				locateParams: locateParams{
+					setInfoParams: setInfoParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x02_0x04_LocateSetInfo{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, "my profile!"),
+									},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: cmdInternalSvcErr,
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_set_info`),
+			wantMsg:  cmdInternalSvcErr,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			locateSvc := newMockLocateService(t)
+			for _, params := range tc.mockParams.setInfoParams {
+				locateSvc.EXPECT().
+					SetInfo(ctx, matchSession(params.me), params.inBody).
+					Return(params.err)
+			}
+
+			svc := OSCARProxy{
+				Logger:        slog.Default(),
+				LocateService: locateSvc,
+			}
+			msg := svc.SetInfo(ctx, tc.me, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+		})
+	}
+}
+
+func TestOSCARProxy_Signon(t *testing.T) {
+	roastedPass := wire.RoastTOCPassword([]byte("thepass"))
+
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenCmd is the TOC command
+		givenCmd []byte
+		// wantMsg is the expected TOC response
+		wantMsg []string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name: "successfully login",
+			me: newTestSession("me", func(session *state.Session) {
+				session.SetCaps([][16]byte{capChat})
+			}),
+			givenCmd: []byte(`toc_signon "" "" me "xx` + hex.EncodeToString(roastedPass) + `"`),
+			mockParams: mockParams{
+				authParams: authParams{
+					flapLoginParams: flapLoginParams{
+						{
+							frame: wire.FLAPSignonFrame{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.LoginTLVTagsScreenName, "me"),
+										wire.NewTLVBE(wire.LoginTLVTagsRoastedTOCPassword, roastedPass),
+									},
+								},
+							},
+							newUserFn: state.NewStubUser,
+							tlv: wire.TLVRestBlock{
+								TLVList: wire.TLVList{
+									wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("thecookie")),
+								},
+							},
+						},
+					},
+					registerBOSSessionParams: registerBOSSessionParams{
+						{
+							authCookie: []byte("thecookie"),
+							sess:       newTestSession("me"),
+						},
+					},
+				},
+				buddyListRegistryParams: buddyListRegistryParams{
+					registerBuddyListParams: registerBuddyListParams{
+						{
+							user: state.NewIdentScreenName("me"),
+						},
+					},
+				},
+				tocConfigParams: tocConfigParams{
+					userParams: userParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							returnedUser: &state.User{
+								TOCConfig: "my-toc-config",
+							},
+						},
+					},
+				},
+			},
+			wantMsg: []string{"SIGN_ON:TOC1.0", "CONFIG:my-toc-config"},
+		},
+		{
+			name:     "login, receive error from auth svc FLAP login",
+			givenCmd: []byte(`toc_signon "" "" me "xx` + hex.EncodeToString(roastedPass) + `"`),
+			mockParams: mockParams{
+				authParams: authParams{
+					flapLoginParams: flapLoginParams{
+						{
+							frame: wire.FLAPSignonFrame{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.LoginTLVTagsScreenName, "me"),
+										wire.NewTLVBE(wire.LoginTLVTagsRoastedTOCPassword, roastedPass),
+									},
+								},
+							},
+							newUserFn: state.NewStubUser,
+							err:       io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: []string{string(cmdInternalSvcErr)},
+		},
+		{
+			name:     "login, receive error from auth svc registration",
+			givenCmd: []byte(`toc_signon "" "" me "xx` + hex.EncodeToString(roastedPass) + `"`),
+			mockParams: mockParams{
+				authParams: authParams{
+					flapLoginParams: flapLoginParams{
+						{
+							frame: wire.FLAPSignonFrame{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.LoginTLVTagsScreenName, "me"),
+										wire.NewTLVBE(wire.LoginTLVTagsRoastedTOCPassword, roastedPass),
+									},
+								},
+							},
+							newUserFn: state.NewStubUser,
+							tlv: wire.TLVRestBlock{
+								TLVList: wire.TLVList{
+									wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("thecookie")),
+								},
+							},
+						},
+					},
+					registerBOSSessionParams: registerBOSSessionParams{
+						{
+							authCookie: []byte("thecookie"),
+							err:        io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: []string{string(cmdInternalSvcErr)},
+		},
+		{
+			name:     "login, receive error from buddy list registry",
+			givenCmd: []byte(`toc_signon "" "" me "xx` + hex.EncodeToString(roastedPass) + `"`),
+			mockParams: mockParams{
+				authParams: authParams{
+					flapLoginParams: flapLoginParams{
+						{
+							frame: wire.FLAPSignonFrame{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.LoginTLVTagsScreenName, "me"),
+										wire.NewTLVBE(wire.LoginTLVTagsRoastedTOCPassword, roastedPass),
+									},
+								},
+							},
+							newUserFn: state.NewStubUser,
+							tlv: wire.TLVRestBlock{
+								TLVList: wire.TLVList{
+									wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("thecookie")),
+								},
+							},
+						},
+					},
+					registerBOSSessionParams: registerBOSSessionParams{
+						{
+							authCookie: []byte("thecookie"),
+							sess:       newTestSession("me"),
+						},
+					},
+				},
+				buddyListRegistryParams: buddyListRegistryParams{
+					registerBuddyListParams: registerBuddyListParams{
+						{
+							user: state.NewIdentScreenName("me"),
+							err:  io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: []string{string(cmdInternalSvcErr)},
+		},
+		{
+			name:     "login, receive error from TOC config store",
+			givenCmd: []byte(`toc_signon "" "" me "xx` + hex.EncodeToString(roastedPass) + `"`),
+			mockParams: mockParams{
+				authParams: authParams{
+					flapLoginParams: flapLoginParams{
+						{
+							frame: wire.FLAPSignonFrame{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.LoginTLVTagsScreenName, "me"),
+										wire.NewTLVBE(wire.LoginTLVTagsRoastedTOCPassword, roastedPass),
+									},
+								},
+							},
+							newUserFn: state.NewStubUser,
+							tlv: wire.TLVRestBlock{
+								TLVList: wire.TLVList{
+									wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("thecookie")),
+								},
+							},
+						},
+					},
+					registerBOSSessionParams: registerBOSSessionParams{
+						{
+							authCookie: []byte("thecookie"),
+							sess:       newTestSession("me"),
+						},
+					},
+				},
+				buddyListRegistryParams: buddyListRegistryParams{
+					registerBuddyListParams: registerBuddyListParams{
+						{
+							user: state.NewIdentScreenName("me"),
+						},
+					},
+				},
+				tocConfigParams: tocConfigParams{
+					userParams: userParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							err:        io.EOF,
+						},
+					},
+				},
+			},
+			wantMsg: []string{string(cmdInternalSvcErr)},
+		},
+		{
+			name:     "login, user not found after login",
+			givenCmd: []byte(`toc_signon "" "" me "xx` + hex.EncodeToString(roastedPass) + `"`),
+			mockParams: mockParams{
+				authParams: authParams{
+					flapLoginParams: flapLoginParams{
+						{
+							frame: wire.FLAPSignonFrame{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.LoginTLVTagsScreenName, "me"),
+										wire.NewTLVBE(wire.LoginTLVTagsRoastedTOCPassword, roastedPass),
+									},
+								},
+							},
+							newUserFn: state.NewStubUser,
+							tlv: wire.TLVRestBlock{
+								TLVList: wire.TLVList{
+									wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("thecookie")),
+								},
+							},
+						},
+					},
+					registerBOSSessionParams: registerBOSSessionParams{
+						{
+							authCookie: []byte("thecookie"),
+							sess:       newTestSession("me"),
+						},
+					},
+				},
+				buddyListRegistryParams: buddyListRegistryParams{
+					registerBuddyListParams: registerBuddyListParams{
+						{
+							user: state.NewIdentScreenName("me"),
+						},
+					},
+				},
+				tocConfigParams: tocConfigParams{
+					userParams: userParams{
+						{
+							screenName:   state.NewIdentScreenName("me"),
+							returnedUser: nil,
+						},
+					},
+				},
+			},
+			wantMsg: []string{string(cmdInternalSvcErr)},
+		},
+		{
+			name:     "login with bad credentials",
+			givenCmd: []byte(`toc_signon "" "" me "xx` + hex.EncodeToString(roastedPass) + `"`),
+			mockParams: mockParams{
+				authParams: authParams{
+					flapLoginParams: flapLoginParams{
+						{
+							frame: wire.FLAPSignonFrame{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.LoginTLVTagsScreenName, "me"),
+										wire.NewTLVBE(wire.LoginTLVTagsRoastedTOCPassword, roastedPass),
+									},
+								},
+							},
+							newUserFn: state.NewStubUser,
+							tlv: wire.TLVRestBlock{
+								TLVList: wire.TLVList{
+									wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, wire.LoginErrInvalidUsernameOrPassword),
+								},
+							},
+						},
+					},
+				},
+			},
+			wantMsg: []string{"ERROR:980"},
+		},
+		{
+			name:     "bad command",
+			givenCmd: []byte(`toc_init_done_diff`),
+			wantMsg:  []string{string(cmdInternalSvcErr)},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			authSvc := newMockAuthService(t)
+			for _, params := range tc.mockParams.flapLoginParams {
+				authSvc.EXPECT().
+					FLAPLogin(params.frame, mock.Anything).
+					Return(params.tlv, params.err)
+			}
+			for _, params := range tc.mockParams.registerBOSSessionParams {
+				authSvc.EXPECT().
+					RegisterBOSSession(ctx, params.authCookie).
+					Return(params.sess, params.err)
+			}
+			buddyRegistry := newMockBuddyListRegistry(t)
+			for _, params := range tc.mockParams.registerBuddyListParams {
+				buddyRegistry.EXPECT().
+					RegisterBuddyList(params.user).
+					Return(params.err)
+			}
+			tocCfg := newMockTOCConfigStore(t)
+			for _, params := range tc.mockParams.userParams {
+				tocCfg.EXPECT().
+					User(params.screenName).
+					Return(params.returnedUser, params.err)
+			}
+
+			svc := OSCARProxy{
+				AuthService:       authSvc,
+				BuddyListRegistry: buddyRegistry,
+				Logger:            slog.Default(),
+				TOCConfigStore:    tocCfg,
+			}
+			sess, msg := svc.Signon(ctx, tc.givenCmd)
+
+			assert.Equal(t, tc.wantMsg, msg)
+			if tc.me == nil {
+				assert.Nil(t, sess)
+			} else if assert.NotNil(t, sess) {
+				assert.Equal(t, tc.me.IdentScreenName(), sess.IdentScreenName())
+				assert.Equal(t, tc.me.Caps(), sess.Caps())
+			}
+		})
+	}
+}
+
+func TestOSCARProxy_Signout(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name: "successfully sign out",
+			me:   newTestSession("me"),
+			mockParams: mockParams{
+				buddyParams: buddyParams{
+					broadcastBuddyDepartedParams: broadcastBuddyDepartedParams{
+						{
+							me: state.NewIdentScreenName("me"),
+						},
+					},
+				},
+				buddyListRegistryParams: buddyListRegistryParams{
+					unregisterBuddyListParams: unregisterBuddyListParams{
+						{
+							user: state.NewIdentScreenName("me"),
+						},
+					},
+				},
+				authParams: authParams{
+					signoutParams: signoutParams{
+						{
+							me: state.NewIdentScreenName("me"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "sign out, receive error from buddy service",
+			me:   newTestSession("me"),
+			mockParams: mockParams{
+				buddyParams: buddyParams{
+					broadcastBuddyDepartedParams: broadcastBuddyDepartedParams{
+						{
+							me:  state.NewIdentScreenName("me"),
+							err: io.EOF,
+						},
+					},
+				},
+				buddyListRegistryParams: buddyListRegistryParams{
+					unregisterBuddyListParams: unregisterBuddyListParams{
+						{
+							user: state.NewIdentScreenName("me"),
+						},
+					},
+				},
+				authParams: authParams{
+					signoutParams: signoutParams{
+						{
+							me: state.NewIdentScreenName("me"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "sign out, receive error from buddy list registry",
+			me:   newTestSession("me"),
+			mockParams: mockParams{
+				buddyParams: buddyParams{
+					broadcastBuddyDepartedParams: broadcastBuddyDepartedParams{
+						{
+							me: state.NewIdentScreenName("me"),
+						},
+					},
+				},
+				buddyListRegistryParams: buddyListRegistryParams{
+					unregisterBuddyListParams: unregisterBuddyListParams{
+						{
+							user: state.NewIdentScreenName("me"),
+							err:  io.EOF,
+						},
+					},
+				},
+				authParams: authParams{
+					signoutParams: signoutParams{
+						{
+							me: state.NewIdentScreenName("me"),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			buddySvc := newMockBuddyService(t)
+			for _, params := range tc.mockParams.broadcastBuddyDepartedParams {
+				buddySvc.EXPECT().
+					BroadcastBuddyDeparted(ctx, matchSession(params.me)).
+					Return(params.err)
+			}
+
+			buddyListSvc := newMockBuddyListRegistry(t)
+			for _, params := range tc.mockParams.unregisterBuddyListParams {
+				buddyListSvc.EXPECT().
+					UnregisterBuddyList(params.user).
+					Return(params.err)
+			}
+
+			authSvc := newMockAuthService(t)
+			for _, params := range tc.mockParams.signoutParams {
+				authSvc.EXPECT().Signout(ctx, matchSession(params.me))
+			}
+
+			svc := OSCARProxy{
+				AuthService:       authSvc,
+				BuddyListRegistry: buddyListSvc,
+				BuddyService:      buddySvc,
+				Logger:            slog.Default(),
+			}
+			svc.Signout(ctx, tc.me)
+		})
+	}
+}
+
+func Test_parseArgs(t *testing.T) {
+	type testCase struct {
+		name         string
+		givenPayload string
+		givenCmd     string
+		givenArgs    []*string
+		wantVarArgs  []string
+		wantArgs     []string
+		wantErrMsg   string
+	}
+
+	tests := []testCase{
+		{
+			name:         "no positional args or varargs",
+			givenPayload: `toc_chat_invite`,
+			givenCmd:     "toc_chat_invite",
+			givenArgs:    nil,
+			wantVarArgs:  nil,
+		},
+		{
+			name:         "positional args with varargs",
+			givenPayload: `toc_chat_invite 1234 "Join me!" user1 user2 user3`,
+			givenCmd:     "toc_chat_invite",
+			givenArgs:    []*string{new(string), new(string)},
+			wantVarArgs:  []string{"user1", "user2", "user3"},
+			wantArgs:     []string{"1234", "Join me!"},
+		},
+		{
+			name:         "nil positional argument placeholders should get skipped",
+			givenPayload: `toc_chat_invite 1234 "Join me!" user1 user2 user3`,
+			givenCmd:     "toc_chat_invite",
+			givenArgs:    []*string{nil, nil}, // still 2 placeholders, both nil
+			wantVarArgs:  []string{"user1", "user2", "user3"},
+			wantArgs:     []string{"", ""},
+		},
+		{
+			name:         "positional args with no varargs",
+			givenPayload: `toc_chat_invite 1234 "Join me!"`,
+			givenCmd:     "toc_chat_invite",
+			givenArgs:    []*string{new(string), new(string)}, // roomID + msg
+			wantVarArgs:  nil,
+			wantArgs:     []string{"1234", "Join me!"},
+		},
+		{
+			name:         "varargs only",
+			givenPayload: `toc_chat_invite user1 user2 user3`,
+			givenCmd:     "toc_chat_invite",
+			givenArgs:    nil,
+			wantVarArgs:  []string{"user1", "user2", "user3"},
+		},
+		{
+			name:         "command mismatch",
+			givenPayload: `toc_chat_invite user1 user2 user3`,
+			givenCmd:     "toc_chat_accept",
+			givenArgs:    nil,
+			wantVarArgs:  nil,
+			wantErrMsg:   "mismatch",
+		},
+		{
+			name:         "too many positional arg placeholders",
+			givenPayload: `toc_chat_invite`,
+			givenCmd:     "toc_chat_invite",
+			givenArgs:    []*string{new(string), new(string)},
+			wantVarArgs:  nil,
+			wantErrMsg:   "command contains fewer arguments than expected",
+		},
+		{
+			name:         "CSV parser error",
+			givenPayload: ``,
+			givenCmd:     "toc_chat_invite",
+			givenArgs:    []*string{nil},
+			wantVarArgs:  nil,
+			wantErrMsg:   "CSV reader error",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			varArgs, err := parseArgs([]byte(tt.givenPayload), tt.givenCmd, tt.givenArgs...)
+
+			if tt.wantErrMsg != "" {
+				assert.ErrorContains(t, err, tt.wantErrMsg)
+				return
+			}
+
+			assert.NoError(t, err)
+
+			// verify the placeholder pointers got populated
+			for i, want := range tt.wantArgs {
+				if want == "" {
+					assert.Nil(t, tt.givenArgs[i])
+				} else {
+					got := *tt.givenArgs[i]
+					assert.Equal(t, want, got)
+				}
+			}
+
+			// verify we have the same varargs
+			assert.Equal(t, tt.wantVarArgs, varArgs)
+			assert.Equal(t, len(tt.wantArgs), len(tt.givenArgs))
+
+		})
+	}
+}

+ 292 - 0
server/toc/cmd_server.go

@@ -0,0 +1,292 @@
+package toc
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+var (
+	cmdInternalSvcErr = "ERROR:989:internal server error"
+	errDisconnect     = errors.New("got booted by another session")
+)
+
+// RecvBOS routes incoming SNAC messages from the BOS server to their
+// corresponding TOC handlers. It ignores any SNAC messages for which there is
+// no TOC response.
+func (s OSCARProxy) RecvBOS(ctx context.Context, me *state.Session, chatRegistry *ChatRegistry, ch chan<- []byte) error {
+	defer func() {
+		fmt.Println("closing RecvBOS")
+	}()
+	for {
+		select {
+		case <-ctx.Done():
+			return nil
+		case <-me.Closed():
+			fmt.Println("I got signed off")
+			return errDisconnect
+		case snac := <-me.ReceiveMessage():
+			switch v := snac.Body.(type) {
+			case wire.SNAC_0x03_0x0B_BuddyArrived:
+				sendOrCancel(ctx, ch, s.UpdateBuddyArrival(v))
+			case wire.SNAC_0x03_0x0C_BuddyDeparted:
+				sendOrCancel(ctx, ch, s.UpdateBuddyDeparted(v))
+			case wire.SNAC_0x04_0x07_ICBMChannelMsgToClient:
+				sendOrCancel(ctx, ch, s.IMIn(ctx, chatRegistry, v))
+			case wire.SNAC_0x01_0x10_OServiceEvilNotification:
+				sendOrCancel(ctx, ch, s.Eviled(v))
+			default:
+				s.Logger.DebugContext(ctx, fmt.Sprintf("unsupported snac. foodgroup: %s subgroup: %s",
+					wire.FoodGroupName(snac.Frame.FoodGroup),
+					wire.SubGroupName(snac.Frame.FoodGroup, snac.Frame.SubGroup)))
+			}
+		}
+	}
+
+	return nil
+}
+
+// RecvChat routes incoming SNAC messages from the chat server to their
+// corresponding TOC handlers. It ignores any SNAC messages for which there is
+// no TOC response.
+func (s OSCARProxy) RecvChat(ctx context.Context, me *state.Session, chatID int, ch chan<- []byte) {
+	defer func() {
+		fmt.Println("closing chat RecvChat")
+	}()
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-me.Closed():
+			return
+		case snac := <-me.ReceiveMessage():
+			switch v := snac.Body.(type) {
+			case wire.SNAC_0x0E_0x04_ChatUsersLeft:
+				sendOrCancel(ctx, ch, s.ChatUpdateBuddyLeft(v, chatID))
+			case wire.SNAC_0x0E_0x03_ChatUsersJoined:
+				sendOrCancel(ctx, ch, s.ChatUpdateBuddyArrived(v, chatID))
+			case wire.SNAC_0x0E_0x06_ChatChannelMsgToClient:
+				sendOrCancel(ctx, ch, s.ChatIn(ctx, v, chatID))
+			default:
+				s.Logger.DebugContext(ctx, fmt.Sprintf("unsupported snac. foodgroup: %s subgroup: %s",
+					wire.FoodGroupName(snac.Frame.FoodGroup),
+					wire.SubGroupName(snac.Frame.FoodGroup, snac.Frame.SubGroup)))
+			}
+		}
+	}
+}
+
+// ChatIn handles the CHAT_IN TOC command.
+//
+// From the TiK documentation:
+//
+//	A chat message was sent in a chat room.
+//
+// Command syntax: CHAT_IN:<Chat Room Id>:<Source User>:<Whisper? T/F>:<Message>
+func (s OSCARProxy) ChatIn(ctx context.Context, snac wire.SNAC_0x0E_0x06_ChatChannelMsgToClient, chatID int) string {
+	b, ok := snac.Bytes(wire.ChatTLVSenderInformation)
+	if !ok {
+		return s.runtimeErr(ctx, errors.New("snac.Bytes: missing wire.ChatTLVSenderInformation"))
+	}
+
+	u := wire.TLVUserInfo{}
+	err := wire.UnmarshalBE(&u, bytes.NewReader(b))
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))
+	}
+
+	b, ok = snac.Bytes(wire.ChatTLVMessageInfo)
+	if !ok {
+		return s.runtimeErr(ctx, errors.New("snac.Bytes: missing wire.ChatTLVMessageInfo"))
+	}
+
+	text, err := wire.UnmarshalChatMessageText(b)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalChatMessageText: %w", err))
+	}
+
+	return fmt.Sprintf("CHAT_IN:%d:%s:F:%s", chatID, u.ScreenName, text)
+}
+
+// ChatUpdateBuddyArrived handles the CHAT_UPDATE_BUDDY TOC command for chat
+// room arrival events.
+//
+// From the TiK documentation:
+//
+//	This one command handles arrival/departs from a chat room. The very first
+//	message of this type for each chat room contains the users already in the
+//	room.
+//
+// Command syntax: CHAT_UPDATE_BUDDY:<Chat Room Id>:<Inside? T/F>:<User 1>:<User 2>...
+func (s OSCARProxy) ChatUpdateBuddyArrived(snac wire.SNAC_0x0E_0x03_ChatUsersJoined, chatID int) string {
+	users := make([]string, 0, len(snac.Users))
+	for _, u := range snac.Users {
+		users = append(users, u.ScreenName)
+	}
+	return fmt.Sprintf("CHAT_UPDATE_BUDDY:%d:T:%s", chatID, strings.Join(users, ":"))
+}
+
+// ChatUpdateBuddyLeft handles the CHAT_UPDATE_BUDDY TOC command for chat
+// room departure events.
+//
+// From the TiK documentation:
+//
+//	This one command handles arrival/departs from a chat room. The very first
+//	message of this type for each chat room contains the users already in the
+//	room.
+//
+// Command syntax: CHAT_UPDATE_BUDDY:<Chat Room Id>:<Inside? T/F>:<User 1>:<User 2>...
+func (s OSCARProxy) ChatUpdateBuddyLeft(snac wire.SNAC_0x0E_0x04_ChatUsersLeft, chatID int) string {
+	users := make([]string, 0, len(snac.Users))
+	for _, u := range snac.Users {
+		users = append(users, u.ScreenName)
+	}
+	return fmt.Sprintf("CHAT_UPDATE_BUDDY:%d:F:%s", chatID, strings.Join(users, ":"))
+}
+
+// Eviled handles the EVILED TOC command.
+//
+// From the TiK documentation:
+//
+//	The user was just eviled.
+//
+// Command syntax: EVILED:<new evil>:<name of eviler, blank if anonymous>
+func (s OSCARProxy) Eviled(snac wire.SNAC_0x01_0x10_OServiceEvilNotification) string {
+	warning := fmt.Sprintf("%d", snac.NewEvil/10)
+	who := ""
+	if snac.Snitcher != nil {
+		who = snac.Snitcher.ScreenName
+	}
+	return fmt.Sprintf("EVILED:%s:%s", warning, who)
+}
+
+// IMIn handles the IM_IN TOC command.
+//
+// From the TiK documentation:
+//
+//	Receive an IM from someone. Everything after the third colon is the
+//	incoming message, including other colons.
+//
+// Command syntax: IM_IN:<Source User>:<Auto Response T/F?>:<Message>
+func (s OSCARProxy) IMIn(ctx context.Context, chatRegistry *ChatRegistry, snac wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) string {
+	if snac.ChannelID == wire.ICBMChannelRendezvous {
+		rdinfo, has := snac.TLVRestBlock.Bytes(wire.ICBMTLVData)
+		if !has {
+			return s.runtimeErr(ctx, errors.New("TLVRestBlock.Bytes: missing rendezvous block"))
+		}
+		frag := wire.ICBMCh2Fragment{}
+		if err := wire.UnmarshalBE(&frag, bytes.NewReader(rdinfo)); err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))
+		}
+		prompt, ok := frag.Bytes(wire.ICBMRdvTLVTagsInvitation)
+		if !ok {
+			return s.runtimeErr(ctx, errors.New("frag.Bytes: missing chat invite prompt"))
+		}
+
+		svcData, ok := frag.Bytes(wire.ICBMRdvTLVTagsSvcData)
+		if !ok {
+			return s.runtimeErr(ctx, errors.New("frag.Bytes: missing room info"))
+		}
+
+		roomInfo := wire.ICBMRoomInfo{}
+		if err := wire.UnmarshalBE(&roomInfo, bytes.NewReader(svcData)); err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))
+		}
+
+		cookie := strings.Split(roomInfo.Cookie, "-") // make this safe
+		if len(cookie) < 3 {
+			return s.runtimeErr(ctx, errors.New("roomInfo.Cookie: malformed cookie, could not get room name"))
+		}
+
+		roomName := cookie[2]
+		chatID := chatRegistry.Add(roomInfo)
+
+		return fmt.Sprintf("CHAT_INVITE:%s:%d:%s:%s", roomName, chatID, snac.ScreenName, prompt)
+	}
+
+	buf, ok := snac.TLVRestBlock.Bytes(wire.ICBMTLVAOLIMData)
+	if !ok {
+		return s.runtimeErr(ctx, errors.New("TLVRestBlock.Bytes: missing wire.ICBMTLVAOLIMData"))
+	}
+	txt, err := wire.UnmarshalICBMMessageText(buf)
+	if err != nil {
+		return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalICBMMessageText: %w", err))
+	}
+
+	autoResp := "F"
+	if _, isAutoReply := snac.TLVRestBlock.Bytes(wire.ICBMTLVAutoResponse); isAutoReply {
+		autoResp = "T"
+	}
+
+	return fmt.Sprintf("IM_IN:%s:%s:%s", snac.ScreenName, autoResp, txt)
+}
+
+// UpdateBuddyArrival handles the UPDATE_BUDDY TOC command for buddy arrival events.
+//
+// From the TiK documentation:
+//
+//	This one command handles arrival/depart/updates. Evil Amount is a percentage, Signon Time is UNIX epoc, idle time is in minutes, UC (User Class) is a two/three character string.
+//		- uc[0]
+//			- ' ' - Ignore
+//			- 'A' - On AOL
+//		- uc[1]
+//			- ' ' - Ignore
+//			- 'A' - Oscar Admin
+//			- 'U' - Oscar Unconfirmed
+//			- 'O' - Oscar Normal
+//		- uc[2]
+//			- '\0' - Ignore
+//			- ' ' - Ignore
+//			- 'U' - The user has set their unavailable flag.
+//
+// Command syntax: UPDATE_BUDDY:<Buddy User>:<Online? T/F>:<Evil Amount>:<Signon Time>:<IdleTime>:<UC>
+func (s OSCARProxy) UpdateBuddyArrival(snac wire.SNAC_0x03_0x0B_BuddyArrived) string {
+	online, _ := snac.Uint32BE(wire.OServiceUserInfoSignonTOD)
+	idle, _ := snac.Uint16BE(wire.OServiceUserInfoIdleTime)
+	uc := [3]string{" ", "O", " "}
+	if snac.IsAway() {
+		uc[2] = "U"
+	}
+	warning := fmt.Sprintf("%d", snac.WarningLevel/10)
+	class := strings.Join(uc[:], "")
+	return fmt.Sprintf("UPDATE_BUDDY:%s:%s:%s:%d:%d:%s", snac.ScreenName, "T", warning, online, idle, class)
+}
+
+// UpdateBuddyDeparted handles the UPDATE_BUDDY TOC command for buddy departure events.
+//
+// From the TiK documentation:
+//
+//	This one command handles arrival/depart/updates. Evil Amount is a
+//	percentage, Signon Time is UNIX epoc, idle time is in minutes, UC (User
+//	Class) is a two/three character string.
+//		- uc[0]
+//			- ' ' - Ignore
+//			- 'A' - On AOL
+//		- uc[1]
+//			- ' ' - Ignore
+//			- 'A' - Oscar Admin
+//			- 'U' - Oscar Unconfirmed
+//			- 'O' - Oscar Normal
+//		- uc[2]
+//			- '\0' - Ignore
+//			- ' ' - Ignore
+//			- 'U' - The user has set their unavailable flag.
+//
+// Command syntax: UPDATE_BUDDY:<Buddy User>:<Online? T/F>:<Evil Amount>:<Signon Time>:<IdleTime>:<UC>
+func (s OSCARProxy) UpdateBuddyDeparted(snac wire.SNAC_0x03_0x0C_BuddyDeparted) string {
+	return fmt.Sprintf("UPDATE_BUDDY:%s:F:0:0:0:   ", snac.ScreenName)
+}
+
+func sendOrCancel(ctx context.Context, ch chan<- []byte, msg string) {
+	select {
+	case <-ctx.Done():
+		return
+	case ch <- []byte(msg):
+		return
+	}
+}

+ 566 - 0
server/toc/cmd_server_test.go

@@ -0,0 +1,566 @@
+package toc
+
+import (
+	"context"
+	"log/slog"
+	"sync"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func TestOSCARProxy_RecvBOS_ChatIn(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// chatID is the chat ID
+		chatID int
+		// givenMsg is the incoming SNAC
+		givenMsg wire.SNACMessage
+		// wantCmd is the expected TOC response
+		wantCmd []byte
+	}{
+		{
+			name:   "send chat message",
+			me:     newTestSession("me"),
+			chatID: 0,
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ChatTLVSenderInformation, wire.TLVUserInfo{
+								ScreenName: "them",
+							}),
+							wire.NewTLVBE(wire.ChatTLVMessageInfo, wire.TLVRestBlock{
+								TLVList: wire.TLVList{
+									wire.NewTLVBE(wire.ChatTLVMessageInfoText, "<p>hello world!</p>"),
+								},
+							}),
+						},
+					},
+				},
+			},
+			wantCmd: []byte("CHAT_IN:0:them:F:<p>hello world!</p>"),
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(context.Background())
+
+			svc := OSCARProxy{
+				Logger: slog.Default(),
+			}
+
+			ch := make(chan []byte)
+			wg := &sync.WaitGroup{}
+			wg.Add(1)
+
+			go func() {
+				defer wg.Done()
+				svc.RecvChat(ctx, tc.me, tc.chatID, ch)
+			}()
+
+			status := tc.me.RelayMessage(tc.givenMsg)
+			assert.Equal(t, state.SessSendOK, status)
+
+			gotCmd := <-ch
+			assert.Equal(t, string(tc.wantCmd), string(gotCmd))
+
+			cancel()
+			wg.Wait()
+		})
+	}
+}
+
+func TestOSCARProxy_RecvBOS_ChatUpdateBuddyArrived(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// chatID is the chat ID
+		chatID int
+		// givenMsg is the incoming SNAC
+		givenMsg wire.SNACMessage
+		// wantCmd is the expected TOC response
+		wantCmd []byte
+	}{
+		{
+			name: "send chat participant arrival",
+			me:   newTestSession("me"),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{
+					Users: []wire.TLVUserInfo{
+						{ScreenName: "user1"},
+						{ScreenName: "user2"},
+					},
+				},
+			},
+			wantCmd: []byte("CHAT_UPDATE_BUDDY:0:T:user1:user2"),
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(context.Background())
+
+			svc := OSCARProxy{
+				Logger: slog.Default(),
+			}
+
+			ch := make(chan []byte)
+			wg := &sync.WaitGroup{}
+			wg.Add(1)
+
+			go func() {
+				defer wg.Done()
+				svc.RecvChat(ctx, tc.me, tc.chatID, ch)
+			}()
+
+			status := tc.me.RelayMessage(tc.givenMsg)
+			assert.Equal(t, state.SessSendOK, status)
+
+			gotCmd := <-ch
+			assert.Equal(t, string(tc.wantCmd), string(gotCmd))
+
+			cancel()
+			wg.Wait()
+		})
+	}
+}
+
+func TestOSCARProxy_RecvBOS_ChatUpdateBuddyLeft(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// chatID is the chat ID
+		chatID int
+		// givenMsg is the incoming SNAC
+		givenMsg wire.SNACMessage
+		// wantCmd is the expected TOC response
+		wantCmd []byte
+	}{
+		{
+			name: "send chat participant departure",
+			me:   newTestSession("me"),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x0E_0x04_ChatUsersLeft{
+					Users: []wire.TLVUserInfo{
+						{ScreenName: "user1"},
+						{ScreenName: "user2"},
+					},
+				},
+			},
+			wantCmd: []byte("CHAT_UPDATE_BUDDY:0:F:user1:user2"),
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(context.Background())
+
+			svc := OSCARProxy{
+				Logger: slog.Default(),
+			}
+
+			ch := make(chan []byte)
+			wg := &sync.WaitGroup{}
+			wg.Add(1)
+
+			go func() {
+				defer wg.Done()
+				svc.RecvChat(ctx, tc.me, tc.chatID, ch)
+			}()
+
+			status := tc.me.RelayMessage(tc.givenMsg)
+			assert.Equal(t, state.SessSendOK, status)
+
+			gotCmd := <-ch
+			assert.Equal(t, string(tc.wantCmd), string(gotCmd))
+
+			cancel()
+			wg.Wait()
+		})
+	}
+}
+
+func TestOSCARProxy_RecvBOS_Eviled(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenMsg is the incoming SNAC
+		givenMsg wire.SNACMessage
+		// chatRegistry is the chat registry for the current session
+		chatRegistry *ChatRegistry
+		// wantCmd is the expected TOC response
+		wantCmd []byte
+	}{
+		{
+			name: "anonymous warning - 10%",
+			me:   newTestSession("me"),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x01_0x10_OServiceEvilNotification{
+					NewEvil: 100,
+				},
+			},
+			wantCmd: []byte("EVILED:10:"),
+		},
+		{
+			name: "normal warning - 10%",
+			me:   newTestSession("me"),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x01_0x10_OServiceEvilNotification{
+					NewEvil: 100,
+					Snitcher: &struct {
+						wire.TLVUserInfo
+					}{
+						TLVUserInfo: wire.TLVUserInfo{
+							ScreenName: "them",
+						},
+					},
+				},
+			},
+			wantCmd: []byte("EVILED:10:them"),
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(context.Background())
+
+			svc := OSCARProxy{
+				Logger: slog.Default(),
+			}
+
+			ch := make(chan []byte)
+			wg := &sync.WaitGroup{}
+			wg.Add(1)
+
+			go func() {
+				defer wg.Done()
+				err := svc.RecvBOS(ctx, tc.me, tc.chatRegistry, ch)
+				assert.NoError(t, err)
+			}()
+
+			status := tc.me.RelayMessage(tc.givenMsg)
+			assert.Equal(t, state.SessSendOK, status)
+
+			gotCmd := <-ch
+			assert.Equal(t, string(tc.wantCmd), string(gotCmd))
+
+			cancel()
+			wg.Wait()
+		})
+	}
+}
+
+func TestOSCARProxy_RecvBOS_IMIn(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenMsg is the incoming SNAC
+		givenMsg wire.SNACMessage
+		// chatRegistry is the chat registry for the current session
+		chatRegistry *ChatRegistry
+		// wantCmd is the expected TOC response
+		wantCmd []byte
+	}{
+		{
+			name: "send IM",
+			me:   newTestSession("me"),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+					ChannelID: wire.ICBMChannelIM,
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName: "them",
+					},
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVAOLIMData, []wire.ICBMCh1Fragment{
+								{
+									ID:      0x5,
+									Version: 0x1,
+									Payload: []uint8{0x1, 0x1, 0x2},
+								},
+								{
+									ID:      0x1,
+									Version: 0x1,
+									Payload: []uint8{
+										0x0, 0x0, // charset
+										0x0, 0x0, // lang
+										'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', '!',
+									},
+								},
+							}),
+						},
+					},
+				},
+			},
+			wantCmd: []byte("IM_IN:them:F:hello world!"),
+		},
+		{
+			name: "send IM - auto-response",
+			me:   newTestSession("me"),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+					ChannelID: wire.ICBMChannelIM,
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName: "them",
+					},
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVAutoResponse, []byte{}),
+							wire.NewTLVBE(wire.ICBMTLVAOLIMData, []wire.ICBMCh1Fragment{
+								{
+									ID:      0x5,
+									Version: 0x1,
+									Payload: []uint8{0x1, 0x1, 0x2},
+								},
+								{
+									ID:      0x1,
+									Version: 0x1,
+									Payload: []uint8{
+										0x0, 0x0, // charset
+										0x0, 0x0, // lang
+										'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', '!',
+									},
+								},
+							}),
+						},
+					},
+				},
+			},
+			wantCmd: []byte("IM_IN:them:T:hello world!"),
+		},
+		{
+			name: "send chat invitation",
+			me:   newTestSession("me"),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+					ChannelID: wire.ICBMChannelRendezvous,
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName: "them",
+					},
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVData, []wire.ICBMCh2Fragment{
+								{
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ICBMRdvTLVTagsInvitation, "join my chat!"),
+											wire.NewTLVBE(wire.ICBMRdvTLVTagsSvcData, wire.ICBMRoomInfo{
+												Cookie: "a-b-the room",
+											}),
+										},
+									},
+								},
+							}),
+						},
+					},
+				},
+			},
+			chatRegistry: NewChatRegistry(),
+			wantCmd:      []byte("CHAT_INVITE:the room:0:them:join my chat!"),
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(context.Background())
+
+			svc := OSCARProxy{
+				Logger: slog.Default(),
+			}
+
+			ch := make(chan []byte)
+			wg := &sync.WaitGroup{}
+			wg.Add(1)
+
+			go func() {
+				defer wg.Done()
+				err := svc.RecvBOS(ctx, tc.me, tc.chatRegistry, ch)
+				assert.NoError(t, err)
+			}()
+
+			status := tc.me.RelayMessage(tc.givenMsg)
+			assert.Equal(t, state.SessSendOK, status)
+
+			gotCmd := <-ch
+			assert.Equal(t, string(tc.wantCmd), string(gotCmd))
+
+			cancel()
+			wg.Wait()
+		})
+	}
+}
+
+func TestOSCARProxy_RecvBOS_UpdateBuddyArrival(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenMsg is the incoming SNAC
+		givenMsg wire.SNACMessage
+		// wantCmd is the expected TOC response
+		wantCmd []byte
+	}{
+		{
+			name: "send buddy arrival - buddy online",
+			me:   newTestSession("me"),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName:   "me",
+						WarningLevel: 0,
+						TLVBlock: wire.TLVBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1234)),
+								wire.NewTLVBE(wire.OServiceUserInfoIdleTime, uint16(5678)),
+							},
+						},
+					},
+				},
+			},
+			wantCmd: []byte("UPDATE_BUDDY:me:T:0:1234:5678: O "),
+		},
+		{
+			name: "send buddy arrival - buddy warned 10%",
+			me:   newTestSession("me"),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName:   "me",
+						WarningLevel: 100,
+						TLVBlock: wire.TLVBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1234)),
+								wire.NewTLVBE(wire.OServiceUserInfoIdleTime, uint16(5678)),
+							},
+						},
+					},
+				},
+			},
+			wantCmd: []byte("UPDATE_BUDDY:me:T:10:1234:5678: O "),
+		},
+		{
+			name: "send buddy arrival - buddy away",
+			me:   newTestSession("me"),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName:   "me",
+						WarningLevel: 0,
+						TLVBlock: wire.TLVBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(1234)),
+								wire.NewTLVBE(wire.OServiceUserInfoIdleTime, uint16(5678)),
+								wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable),
+							},
+						},
+					},
+				},
+			},
+			wantCmd: []byte("UPDATE_BUDDY:me:T:0:1234:5678: OU"),
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(context.Background())
+
+			svc := OSCARProxy{
+				Logger: slog.Default(),
+			}
+
+			ch := make(chan []byte)
+			wg := &sync.WaitGroup{}
+			wg.Add(1)
+
+			go func() {
+				defer wg.Done()
+				err := svc.RecvBOS(ctx, tc.me, nil, ch)
+				assert.NoError(t, err)
+			}()
+
+			status := tc.me.RelayMessage(tc.givenMsg)
+			assert.Equal(t, state.SessSendOK, status)
+
+			gotCmd := <-ch
+			assert.Equal(t, string(tc.wantCmd), string(gotCmd))
+
+			cancel()
+			wg.Wait()
+		})
+	}
+}
+
+func TestOSCARProxy_RecvBOS_UpdateBuddyDeparted(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// me is the TOC user session
+		me *state.Session
+		// givenMsg is the incoming SNAC
+		givenMsg wire.SNACMessage
+		// wantCmd is the expected TOC response
+		wantCmd []byte
+	}{
+		{
+			name: "send buddy departure",
+			me:   newTestSession("me"),
+			givenMsg: wire.SNACMessage{
+				Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
+					TLVUserInfo: wire.TLVUserInfo{
+						ScreenName: "me",
+					},
+				},
+			},
+			wantCmd: []byte("UPDATE_BUDDY:me:F:0:0:0:   "),
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(context.Background())
+
+			svc := OSCARProxy{
+				Logger: slog.Default(),
+			}
+
+			ch := make(chan []byte)
+			wg := &sync.WaitGroup{}
+			wg.Add(1)
+
+			go func() {
+				defer wg.Done()
+				err := svc.RecvBOS(ctx, tc.me, nil, ch)
+				assert.NoError(t, err)
+			}()
+
+			status := tc.me.RelayMessage(tc.givenMsg)
+			assert.Equal(t, state.SessSendOK, status)
+
+			gotCmd := <-ch
+			assert.Equal(t, string(tc.wantCmd), string(gotCmd))
+
+			cancel()
+			wg.Wait()
+		})
+	}
+}
+
+func TestOSCARProxy_RecvBOS_Signout(t *testing.T) {
+}

+ 373 - 0
server/toc/helpers_test.go

@@ -0,0 +1,373 @@
+package toc
+
+import (
+	"github.com/google/uuid"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+type addBuddiesParams []struct {
+	me     state.IdentScreenName
+	inBody wire.SNAC_0x03_0x04_BuddyAddBuddies
+	err    error
+}
+
+type broadcastBuddyDepartedParams []struct {
+	me  state.IdentScreenName
+	err error
+}
+
+type delBuddiesParams []struct {
+	me     state.IdentScreenName
+	inBody wire.SNAC_0x03_0x05_BuddyDelBuddies
+	err    error
+}
+
+type rightsQueryParams []struct {
+	inFrame wire.SNACFrame
+	msg     wire.SNACMessage
+}
+
+type buddyParams struct {
+	addBuddiesParams
+	broadcastBuddyDepartedParams
+	delBuddiesParams
+	rightsQueryParams
+}
+
+type chatParams struct {
+	channelMsgToHostParamsChat
+}
+
+type createRoomParams []struct {
+	me     state.IdentScreenName
+	inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate
+	msg    wire.SNACMessage
+	err    error
+}
+
+type exchangeInfoParams []struct {
+	inFrame wire.SNACFrame
+	inBody  wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo
+	msg     wire.SNACMessage
+	err     error
+}
+
+type requestChatRightsParams []struct {
+	inFrame wire.SNACFrame
+	msg     wire.SNACMessage
+}
+
+type requestRoomInfoParams []struct {
+	inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo
+	msg    wire.SNACMessage
+	err    error
+}
+
+type chatNavParams struct {
+	createRoomParams
+	exchangeInfoParams
+	requestChatRightsParams
+	requestRoomInfoParams
+}
+
+type channelMsgToHostParamsChat []struct {
+	sender state.IdentScreenName
+	inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost
+	result *wire.SNACMessage
+	err    error
+}
+
+type channelMsgToHostParamsICBM []struct {
+	sender  state.IdentScreenName
+	inFrame wire.SNACFrame
+	inBody  wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
+	result  *wire.SNACMessage
+	err     error
+}
+
+type clientEventParams []struct {
+	sess    *state.Session
+	inFrame wire.SNACFrame
+	inBody  wire.SNAC_0x04_0x14_ICBMClientEvent
+	err     error
+}
+
+type evilRequestParams []struct {
+	me     state.IdentScreenName
+	inBody wire.SNAC_0x04_0x08_ICBMEvilRequest
+	msg    wire.SNACMessage
+	err    error
+}
+
+type parameterQueryParams []struct {
+	inFrame wire.SNACFrame
+	msg     wire.SNACMessage
+}
+
+type clientErrParams []struct {
+	sess  *state.Session
+	frame wire.SNACFrame
+	body  wire.SNAC_0x04_0x0B_ICBMClientErr
+	err   error
+}
+
+type icbmParams struct {
+	channelMsgToHostParamsICBM
+	clientEventParams
+	evilRequestParams
+	parameterQueryParams
+	clientErrParams
+}
+
+type clientOnlineParams []struct {
+	body wire.SNAC_0x01_0x02_OServiceClientOnline
+	me   state.IdentScreenName
+	err  error
+}
+
+type idleNotificationParams []struct {
+	me     state.IdentScreenName
+	bodyIn wire.SNAC_0x01_0x11_OServiceIdleNotification
+	err    error
+}
+
+type serviceRequestParams []struct {
+	me     state.IdentScreenName
+	bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest
+	msg    wire.SNACMessage
+	err    error
+}
+
+type oServiceParams struct {
+	clientOnlineParams
+	idleNotificationParams
+	serviceRequestParams
+}
+
+type bucpChallengeParams []struct {
+	bodyIn  wire.SNAC_0x17_0x06_BUCPChallengeRequest
+	newUUID func() uuid.UUID
+	msg     wire.SNACMessage
+	err     error
+}
+
+type bucpLoginParams []struct {
+	bodyIn    wire.SNAC_0x17_0x02_BUCPLoginRequest
+	newUserFn func(screenName state.DisplayScreenName) (state.User, error)
+	msg       wire.SNACMessage
+	err       error
+}
+
+type flapLoginParams []struct {
+	frame     wire.FLAPSignonFrame
+	newUserFn func(screenName state.DisplayScreenName) (state.User, error)
+	tlv       wire.TLVRestBlock
+	err       error
+}
+
+type registerBOSSessionParams []struct {
+	authCookie []byte
+	sess       *state.Session
+	err        error
+}
+
+type retrieveBOSSessionParams []struct {
+	authCookie []byte
+	sess       *state.Session
+	err        error
+}
+
+type registerChatSessionParams []struct {
+	authCookie []byte
+	sess       *state.Session
+	err        error
+}
+
+type signoutParams []struct {
+	me state.IdentScreenName
+}
+
+type signoutChatParams []struct {
+	me state.IdentScreenName
+}
+
+type authParams struct {
+	bucpChallengeParams
+	bucpLoginParams
+	flapLoginParams
+	registerBOSSessionParams
+	retrieveBOSSessionParams
+	registerChatSessionParams
+	signoutParams
+	signoutChatParams
+}
+
+type setDirInfoParams []struct {
+	me     state.IdentScreenName
+	inBody wire.SNAC_0x02_0x09_LocateSetDirInfo
+	msg    wire.SNACMessage
+	err    error
+}
+
+type setInfoParams []struct {
+	me     state.IdentScreenName
+	inBody wire.SNAC_0x02_0x04_LocateSetInfo
+	err    error
+}
+
+type userInfoQueryParams []struct {
+	me     state.IdentScreenName
+	inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery
+	msg    wire.SNACMessage
+	err    error
+}
+
+type dirInfoParams []struct {
+	body wire.SNAC_0x02_0x0B_LocateGetDirInfo
+	msg  wire.SNACMessage
+	err  error
+}
+
+type locateParams struct {
+	setDirInfoParams
+	setInfoParams
+	userInfoQueryParams
+	dirInfoParams
+}
+
+type infoQueryParams []struct {
+	inBody wire.SNAC_0x0F_0x02_InfoQuery
+	msg    wire.SNACMessage
+	err    error
+}
+
+type dirSearchParams struct {
+	infoQueryParams
+}
+
+type addDenyListEntriesParams []struct {
+	me   state.IdentScreenName
+	body wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries
+	err  error
+}
+
+type addPermListEntriesParams []struct {
+	me   state.IdentScreenName
+	body wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries
+	err  error
+}
+
+type delDenyListEntriesParams []struct {
+	me   state.IdentScreenName
+	body wire.SNAC_0x09_0x08_PermitDenyDelDenyListEntries
+	err  error
+}
+
+type delPermListEntriesParams []struct {
+	me   state.IdentScreenName
+	body wire.SNAC_0x09_0x06_PermitDenyDelPermListEntries
+	err  error
+}
+
+type rightsQueryParamsPermitDeny []struct {
+	frame wire.SNACFrame
+	msg   wire.SNACMessage
+}
+
+type permitDenyParams struct {
+	addDenyListEntriesParams
+	addPermListEntriesParams
+	delDenyListEntriesParams
+	delPermListEntriesParams
+	rightsQueryParamsPermitDeny
+}
+
+type registerBuddyListParams []struct {
+	user state.IdentScreenName
+	err  error
+}
+
+type unregisterBuddyListParams []struct {
+	user state.IdentScreenName
+	err  error
+}
+
+type buddyListRegistryParams struct {
+	registerBuddyListParams
+	unregisterBuddyListParams
+}
+
+type setTOCConfigParams []struct {
+	user   state.IdentScreenName
+	config string
+	err    error
+}
+
+type userParams []struct {
+	screenName   state.IdentScreenName
+	returnedUser *state.User
+	err          error
+}
+
+type tocConfigParams struct {
+	setTOCConfigParams
+	userParams
+}
+
+type mockParams struct {
+	authParams
+	buddyListRegistryParams
+	buddyParams
+	chatNavParams
+	chatParams
+	cookieBakerParams
+	dirSearchParams
+	icbmParams
+	locateParams
+	oServiceBOSParams  oServiceParams
+	oServiceChatParams oServiceParams
+	permitDenyParams
+	tocConfigParams
+}
+
+// crackParams holds multiple scenarios for the Crack method.
+type crackParams []struct {
+	data       []byte
+	returnData []byte
+	returnErr  error
+}
+
+// issueParams holds multiple scenarios for the Issue method.
+type issueParams []struct {
+	data       []byte
+	returnData []byte
+	returnErr  error
+}
+
+// cookieBakerParams groups the method scenarios for a CookieBaker.
+type cookieBakerParams struct {
+	crackParams crackParams
+	issueParams issueParams
+}
+
+// matchSession matches a mock call based session ident screen name.
+func matchSession(mustMatch state.IdentScreenName) interface{} {
+	return mock.MatchedBy(func(s *state.Session) bool {
+		return mustMatch == s.IdentScreenName()
+	})
+}
+
+// newTestSession creates a session object with 0 or more functional options
+// applied
+func newTestSession(screenName state.DisplayScreenName, options ...func(session *state.Session)) *state.Session {
+	s := state.NewSession()
+	s.SetIdentScreenName(screenName.IdentScreenName())
+	s.SetDisplayScreenName(screenName)
+	for _, op := range options {
+		op(s)
+	}
+	return s
+}

+ 360 - 0
server/toc/http.go

@@ -0,0 +1,360 @@
+package toc
+
+import (
+	"bytes"
+	"context"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"html/template"
+	"io"
+	"net/http"
+
+	"golang.org/x/net/html"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// profileTpl is the profile lookup response go template.
+const profileTpl = `
+<HTML><HEAD><TITLE>Profile Lookup</TITLE></HEAD><BODY>
+Username : <B>{{- .ScreenName -}}</B><BR><BR>
+{{ .Profile }}
+</BODY></HTML>`
+
+// directoryTpl is the directory search response go template.
+const directoryTpl = `
+<HTML><HEAD><TITLE>Retro AIM Server</TITLE></HEAD><BODY><H3>Dir Results</H3>
+{{- if .Results -}}
+<TABLE>
+{{- range .Results -}}
+<TR><TD>
+{{- if .FirstName}}<B>First Name:</B> {{.FirstName}}<BR>{{- end -}}
+{{- if .MiddleName}}<B>Middle Name:</B> {{.MiddleName}}<BR>{{- end -}}
+{{- if .LastName}}<B>Last Name:</B> {{.LastName}}<BR>{{- end -}}
+{{- if .MaidenName}}<B>Maiden Name:</B> {{.MaidenName}}<BR>{{- end -}}
+{{- if .Country}}<B>Country:</B> {{.Country}}<BR>{{- end -}}
+{{- if .State}}<B>State:</B> {{.State}}<BR>{{- end -}}
+{{- if .City}}<B>City:</B> {{.City}}<BR>{{- end -}}
+{{- if .NickName}}<B>Nick Name:</B> {{.NickName}}<BR>{{- end -}}
+{{- if .ZIP}}<B>ZIP Code:</B> {{.ZIP}}<BR>{{- end -}}
+{{- if .Address}}<B>Address :</B> {{.Address}}<BR>{{- end -}}
+</TD></TR>
+{{- end -}}
+</TABLE>
+{{- else -}}
+<BR>No results found.
+{{- end -}}
+</BODY></HTML>`
+
+// NewServeMux creates and returns an HTTP mux that serves all TOC routes.
+func (s OSCARProxy) NewServeMux() http.Handler {
+	mux := http.NewServeMux()
+	mux.Handle("GET /info", s.AuthMiddleware(http.HandlerFunc(s.ProfileHandler)))
+	mux.Handle("GET /dir_info", s.AuthMiddleware(http.HandlerFunc(s.DirInfoHandler)))
+	mux.Handle("GET /dir_search", s.AuthMiddleware(http.HandlerFunc(s.DirSearchHandler)))
+	return mux
+}
+
+// AuthMiddleware is an HTTP middleware that enforces authentication using an
+// authorization cookie provided as a query parameter. It validates and decrypts
+// the cookie before allowing the request to proceed.
+//
+// If the `cookie` query parameter is missing or invalid, the middleware
+// responds with an appropriate HTTP error:
+//   - 400 Bad Request if the `cookie` parameter is missing.
+//   - 403 Forbidden if the cookie is invalid or cannot be decrypted.
+//
+// Requests with a valid cookie are passed to the next handler.
+func (s OSCARProxy) AuthMiddleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		ctx := r.Context()
+
+		cookie := r.URL.Query().Get("cookie")
+		if cookie == "" {
+			http.Error(w, "required `cookie` param is missing", http.StatusBadRequest)
+			return
+		}
+
+		data, err := hex.DecodeString(cookie)
+		if err != nil {
+			s.Logger.DebugContext(ctx, "error decoding string", "err", err.Error())
+			http.Error(w, "invalid auth cookie", http.StatusForbidden)
+			return
+		}
+
+		if _, err = s.CookieBaker.Crack(data); err != nil {
+			s.Logger.DebugContext(ctx, "error cracking auth cookie", "err", err.Error())
+			http.Error(w, "invalid auth cookie", http.StatusForbidden)
+			return
+		}
+
+		next.ServeHTTP(w, r)
+	})
+}
+
+// ProfileHandler handles requests to retrieve a user's profile information.
+// It queries the LocateService to fetch profile data for the specified user.
+//
+// The request must include the following query parameters:
+//   - `from`: The screen name of the user making the request.
+//   - `user`: The screen name of the user whose profile is being requested.
+//
+// If any required parameter is missing, it responds with a 400 Bad Request.
+// If the requested user is unavailable, it responds with a 404 Not Found.
+func (s OSCARProxy) ProfileHandler(w http.ResponseWriter, r *http.Request) {
+	from := r.URL.Query().Get("from")
+	if from == "" {
+		http.Error(w, "required `from` param is missing", http.StatusBadRequest)
+		return
+	}
+	user := r.URL.Query().Get("user")
+	if user == "" {
+		http.Error(w, "required `user` param is missing", http.StatusBadRequest)
+		return
+	}
+
+	sess := state.NewSession()
+	sess.SetIdentScreenName(state.NewIdentScreenName(from))
+	inBody := wire.SNAC_0x02_0x05_LocateUserInfoQuery{
+		Type:       uint16(wire.LocateTypeSig),
+		ScreenName: user,
+	}
+
+	ctx := r.Context()
+	info, err := s.LocateService.UserInfoQuery(ctx, sess, wire.SNACFrame{}, inBody)
+	if err != nil {
+		s.logAndReturn500(ctx, w, fmt.Errorf("LocateService.UserInfoQuery: %w", err))
+		return
+	}
+
+	switch v := info.Body.(type) {
+	case wire.SNACError:
+		if v.Code == wire.ErrorCodeNotLoggedOn {
+			http.Error(w, "user is unavailable", http.StatusNotFound)
+		} else {
+			s.logAndReturn500(ctx, w, fmt.Errorf("LocateService.UserInfoQuery error code: %d", v.Code))
+		}
+	case wire.SNAC_0x02_0x06_LocateUserInfoReply:
+		profile, hasProf := v.LocateInfo.Bytes(wire.LocateTLVTagsInfoSigData)
+		if !hasProf {
+			s.logAndReturn500(ctx, w, errors.New("LocateInfo.Bytes: missing wire.LocateTLVTagsInfoSigData"))
+			return
+		}
+
+		t, err := template.New("results").Parse(profileTpl)
+		if err != nil {
+			s.logAndReturn500(ctx, w, fmt.Errorf("template.New: %w", err))
+			return
+		}
+
+		pd := struct {
+			ScreenName string
+			Profile    string
+		}{
+			ScreenName: user,
+			Profile:    extractBodyContent(profile),
+		}
+
+		if err := t.Execute(w, pd); err != nil {
+			s.logAndReturn500(ctx, w, fmt.Errorf("t.Execute: %w", err))
+		}
+	default:
+		s.logAndReturn500(ctx, w, fmt.Errorf("unknown response type: %T", v))
+	}
+}
+
+// DirInfoHandler handles requests to retrieve directory information for a user.
+// It queries the LocateService to fetch directory details associated with the
+// given screen name.
+//
+// The request must include the following query parameter:
+//   - `user`: The screen name of the user whose directory info is being requested.
+//
+// If the `user` parameter is missing, it responds with a 400 Bad Request.
+// If no directory information is found, it responds with a 404 Not Found.
+func (s OSCARProxy) DirInfoHandler(w http.ResponseWriter, request *http.Request) {
+	user := request.URL.Query().Get("user")
+	if user == "" {
+		http.Error(w, "required `user` param is missing", http.StatusBadRequest)
+		return
+	}
+
+	inBody := wire.SNAC_0x02_0x0B_LocateGetDirInfo{
+		ScreenName: user,
+	}
+
+	ctx := request.Context()
+	info, err := s.LocateService.DirInfo(ctx, wire.SNACFrame{}, inBody)
+	if err != nil {
+		s.logAndReturn500(ctx, w, fmt.Errorf("LocateService.DirInfo: %w", err))
+		return
+	}
+
+	switch v := info.Body.(type) {
+	case wire.SNAC_0x02_0x0C_LocateGetDirReply:
+		if len(v.TLVList) > 0 {
+			s.outputSearchResults(ctx, w, v.TLVBlock)
+		} else {
+			http.Error(w, "no user directory info found", http.StatusNotFound)
+		}
+	default:
+		s.logAndReturn500(ctx, w, fmt.Errorf("LocateService.DirInfo: unknown response type: %T", v))
+	}
+}
+
+// DirSearchHandler handles requests to perform a directory search based on
+// various criteria. It queries the DirSearchService to find users matching the
+// specified parameters. There are 3 search modes: name, email, keyword.
+//
+//	-Named-based search is toggled by the presence of either `first_name`
+//	and/or `last_name` params. The following search params can be passed:
+//		-`first_name`
+//	  	-`middle_name`
+//		-`last_name`
+//		-`maiden_name`
+//		-`city`
+//		-`state`
+//		-`country`
+//	-Email-based search is triggered by the`email` param.
+//	-Keyword-based search is triggered by the `keyword` param.
+//
+// If the search is missing required name parameters, it responds with a 400
+// Bad Request.
+func (s OSCARProxy) DirSearchHandler(w http.ResponseWriter, r *http.Request) {
+	inBody := wire.SNAC_0x0F_0x02_InfoQuery{}
+
+	q := r.URL.Query()
+	switch {
+	case q.Has("first_name") || q.Has("last_name"):
+		if val := q.Get("first_name"); val != "" {
+			inBody.Append(wire.NewTLVBE(wire.ODirTLVFirstName, val))
+		}
+		if val := q.Get("middle_name"); val != "" {
+			inBody.Append(wire.NewTLVBE(wire.ODirTLVMiddleName, val))
+		}
+		if val := q.Get("last_name"); val != "" {
+			inBody.Append(wire.NewTLVBE(wire.ODirTLVLastName, val))
+		}
+		if val := q.Get("maiden_name"); val != "" {
+			inBody.Append(wire.NewTLVBE(wire.ODirTLVMaidenName, val))
+		}
+		if val := q.Get("city"); val != "" {
+			inBody.Append(wire.NewTLVBE(wire.ODirTLVCity, val))
+		}
+		if val := q.Get("state"); val != "" {
+			inBody.Append(wire.NewTLVBE(wire.ODirTLVState, val))
+		}
+		if val := q.Get("country"); val != "" {
+			inBody.Append(wire.NewTLVBE(wire.ODirTLVCountry, val))
+		}
+	case q.Has("email"):
+		inBody.Append(wire.NewTLVBE(wire.ODirTLVEmailAddress, q.Get("email")))
+	case q.Has("keyword"):
+		inBody.Append(wire.NewTLVBE(wire.ODirTLVInterest, q.Get("keyword")))
+	}
+
+	ctx := r.Context()
+	info, err := s.DirSearchService.InfoQuery(ctx, wire.SNACFrame{}, inBody)
+	if err != nil {
+		s.logAndReturn500(ctx, w, fmt.Errorf("DirSearchService.InfoQuery: %w", err))
+		return
+	}
+
+	switch v := info.Body.(type) {
+	case wire.SNAC_0x0F_0x03_InfoReply:
+		switch v.Status {
+		case wire.ODirSearchResponseNameMissing:
+			http.Error(w, "missing search parameters", http.StatusBadRequest)
+		case wire.ODirSearchResponseOK:
+			s.outputSearchResults(nil, w, v.Results.List...)
+		default:
+			s.logAndReturn500(ctx, w, fmt.Errorf("DirSearchService.InfoQuery unknown status: %d", v.Status))
+		}
+	default:
+		s.logAndReturn500(ctx, w, fmt.Errorf("DirSearchService.InfoQuery: unknown response type: %T", v))
+	}
+}
+
+func (s OSCARProxy) outputSearchResults(ctx context.Context, w http.ResponseWriter, users ...wire.TLVBlock) {
+	type DirSearchResult struct {
+		FirstName  string
+		MiddleName string
+		LastName   string
+		MaidenName string
+		Country    string
+		State      string
+		City       string
+		NickName   string
+		ZIP        string
+		Address    string
+	}
+	type PageData struct {
+		Results []DirSearchResult
+	}
+
+	results := make([]DirSearchResult, 0, len(users))
+	for _, result := range users {
+		rec := DirSearchResult{}
+		rec.FirstName, _ = result.String(wire.ODirTLVFirstName)
+		rec.MiddleName, _ = result.String(wire.ODirTLVMiddleName)
+		rec.LastName, _ = result.String(wire.ODirTLVLastName)
+		rec.MaidenName, _ = result.String(wire.ODirTLVMaidenName)
+		rec.Country, _ = result.String(wire.ODirTLVCountry)
+		rec.State, _ = result.String(wire.ODirTLVState)
+		rec.City, _ = result.String(wire.ODirTLVCity)
+		rec.NickName, _ = result.String(wire.ODirTLVNickName)
+		rec.ZIP, _ = result.String(wire.ODirTLVZIP)
+		rec.Address, _ = result.String(wire.ODirTLVAddress)
+		results = append(results, rec)
+	}
+
+	t, err := template.New("results").Parse(directoryTpl)
+	if err != nil {
+		s.logAndReturn500(ctx, w, fmt.Errorf("template.New: %w", err))
+		return
+	}
+
+	if err := t.Execute(w, PageData{Results: results}); err != nil {
+		s.logAndReturn500(ctx, w, fmt.Errorf("t.Execute: %w", err))
+	}
+}
+
+func (s OSCARProxy) logAndReturn500(ctx context.Context, w http.ResponseWriter, err error) {
+	s.Logger.ErrorContext(ctx, "internal service error", "err", err.Error())
+	http.Error(w, "internal server error", http.StatusInternalServerError)
+}
+
+// extractBodyContent parses an HTML string and extracts the content within <BODY>...</BODY> tags.
+func extractBodyContent(htmlContent []byte) string {
+	tokenizer := html.NewTokenizer(bytes.NewReader(htmlContent))
+	var bodyContent bytes.Buffer
+	inBody := false
+
+	for {
+		switch tokenizer.Next() {
+		case html.ErrorToken:
+			if err := tokenizer.Err(); err != nil && err != io.EOF {
+				return "unable to read profile"
+			}
+			return bodyContent.String()
+		case html.StartTagToken:
+			token := tokenizer.Token()
+			if token.Data == "body" {
+				inBody = true
+			}
+		case html.EndTagToken:
+			token := tokenizer.Token()
+			if token.Data == "body" {
+				inBody = false
+			}
+		case html.TextToken:
+			if inBody {
+				bodyContent.Write(tokenizer.Text())
+			}
+		}
+	}
+
+	return ""
+}

+ 611 - 0
server/toc/http_test.go

@@ -0,0 +1,611 @@
+package toc
+
+import (
+	"encoding/base64"
+	"io"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func TestOSCARProxy_NewServeMux(t *testing.T) {
+	cookieBaker, err := state.NewHMACCookieBaker()
+	assert.NoError(t, err)
+
+	p := OSCARProxy{
+		CookieBaker: cookieBaker,
+		Logger:      slog.Default(),
+	}
+	cookie, err := p.newHTTPAuthToken(state.NewIdentScreenName("me"))
+	assert.NoError(t, err)
+
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// path is the HTTP request path
+		path string
+		// expectedStatus is the expected HTTP response code
+		expectedStatus int
+		// expectedBody is the expected body payload
+		expectedBody string
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:           "Successfully retrieve profile",
+			path:           "/info?from=me&user=them&cookie=" + cookie,
+			expectedStatus: http.StatusOK,
+			expectedBody:   "My profile!",
+			mockParams: mockParams{
+				locateParams: locateParams{
+					userInfoQueryParams: userInfoQueryParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x02_0x05_LocateUserInfoQuery{
+								Type:       uint16(wire.LocateTypeSig),
+								ScreenName: "them",
+							},
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Locate,
+									SubGroup:  wire.LocateUserInfoReply,
+								},
+								Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
+									TLVUserInfo: newTestSession("them").TLVUserInfo(),
+									LocateInfo: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, "<HTML><BODY>My profile!</BODY></HTML>"),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "Retrieve profile with missing `from` query param",
+			path:           "/info?user=them&cookie=" + cookie,
+			expectedStatus: http.StatusBadRequest,
+			expectedBody:   "required `from` param is missing",
+		},
+		{
+			name:           "Retrieve profile with missing `from` query param",
+			path:           "/info?from=me&cookie=" + cookie,
+			expectedStatus: http.StatusBadRequest,
+			expectedBody:   "required `user` param is missing",
+		},
+		{
+			name:           "Retrieve profile with missing `cookie` query param",
+			path:           "/info?from=me&user=them",
+			expectedStatus: http.StatusBadRequest,
+			expectedBody:   "required `cookie` param is missing",
+		},
+		{
+			name: "Retrieve profile with invalid auth cookie",
+			path: func() string {
+				return "/info?from=me&user=them&cookie=" + base64.URLEncoding.EncodeToString([]byte("blahblah"))
+			}(),
+			expectedStatus: http.StatusForbidden,
+			expectedBody:   "invalid auth cookie",
+		},
+		{
+			name:           "Retrieve profile, receive error from locate svc",
+			path:           "/info?from=me&user=them&cookie=" + cookie,
+			expectedStatus: http.StatusInternalServerError,
+			expectedBody:   "internal server error",
+			mockParams: mockParams{
+				locateParams: locateParams{
+					userInfoQueryParams: userInfoQueryParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x02_0x05_LocateUserInfoQuery{
+								Type:       uint16(wire.LocateTypeSig),
+								ScreenName: "them",
+							},
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Locate,
+									SubGroup:  wire.LocateUserInfoReply,
+								},
+								Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
+									TLVUserInfo: newTestSession("them").TLVUserInfo(),
+									LocateInfo: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, "<HTML><BODY>My profile!</BODY></HTML>"),
+										},
+									},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "Retrieve profile, receive unknown response from locate svc",
+			path:           "/info?from=me&user=them&cookie=" + cookie,
+			expectedStatus: http.StatusInternalServerError,
+			expectedBody:   "internal server error",
+			mockParams: mockParams{
+				locateParams: locateParams{
+					userInfoQueryParams: userInfoQueryParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x02_0x05_LocateUserInfoQuery{
+								Type:       uint16(wire.LocateTypeSig),
+								ScreenName: "them",
+							},
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Locate,
+									SubGroup:  wire.LocateUserInfoReply,
+								},
+								Body: wire.SNAC_0x04_0x09_ICBMEvilReply{},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "Retrieve profile, user offline",
+			path:           "/info?from=me&user=them&cookie=" + cookie,
+			expectedStatus: http.StatusNotFound,
+			expectedBody:   "user is unavailable",
+			mockParams: mockParams{
+				locateParams: locateParams{
+					userInfoQueryParams: userInfoQueryParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x02_0x05_LocateUserInfoQuery{
+								Type:       uint16(wire.LocateTypeSig),
+								ScreenName: "them",
+							},
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Locate,
+									SubGroup:  wire.LocateUserInfoReply,
+								},
+								Body: wire.SNACError{
+									Code: wire.ErrorCodeNotLoggedOn,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "Retrieve profile, receive error code from locate svc",
+			path:           "/info?from=me&user=them&cookie=" + cookie,
+			expectedStatus: http.StatusInternalServerError,
+			expectedBody:   "internal server error",
+			mockParams: mockParams{
+				locateParams: locateParams{
+					userInfoQueryParams: userInfoQueryParams{
+						{
+							me: state.NewIdentScreenName("me"),
+							inBody: wire.SNAC_0x02_0x05_LocateUserInfoQuery{
+								Type:       uint16(wire.LocateTypeSig),
+								ScreenName: "them",
+							},
+							msg: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Locate,
+									SubGroup:  wire.LocateUserInfoReply,
+								},
+								Body: wire.SNACError{
+									Code: wire.ErrorCodeInvalidSnac,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "Successfully retrieve directory info",
+			path:           "/dir_info?user=them&cookie=" + cookie,
+			expectedStatus: http.StatusOK,
+			expectedBody:   "their_first_name",
+			mockParams: mockParams{
+				locateParams: locateParams{
+					dirInfoParams: dirInfoParams{
+						{
+							body: wire.SNAC_0x02_0x0B_LocateGetDirInfo{
+								ScreenName: "them",
+							},
+							msg: wire.SNACMessage{
+								Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{
+									Status: wire.LocateGetDirReplyOK,
+									TLVBlock: wire.TLVBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ODirTLVFirstName, "their_first_name"),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "Retrieve directory info, no results found",
+			path:           "/dir_info?user=them&cookie=" + cookie,
+			expectedStatus: http.StatusNotFound,
+			expectedBody:   "no user directory info found",
+			mockParams: mockParams{
+				locateParams: locateParams{
+					dirInfoParams: dirInfoParams{
+						{
+							body: wire.SNAC_0x02_0x0B_LocateGetDirInfo{
+								ScreenName: "them",
+							},
+							msg: wire.SNACMessage{
+								Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{
+									Status: wire.LocateGetDirReplyOK,
+									TLVBlock: wire.TLVBlock{
+										TLVList: wire.TLVList{},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "Retrieve directory info, unexpected response from locate svc",
+			path:           "/dir_info?user=them&cookie=" + cookie,
+			expectedStatus: http.StatusInternalServerError,
+			expectedBody:   "internal server error",
+			mockParams: mockParams{
+				locateParams: locateParams{
+					dirInfoParams: dirInfoParams{
+						{
+							body: wire.SNAC_0x02_0x0B_LocateGetDirInfo{
+								ScreenName: "them",
+							},
+							msg: wire.SNACMessage{
+								Body: wire.SNACError{},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "Search directory with missing `user` query param",
+			path:           "/dir_info?cookie=" + cookie,
+			expectedStatus: http.StatusBadRequest,
+			expectedBody:   "required `user` param is missing",
+		},
+		{
+			name:           "Retrieve directory info, receive err from locate svc",
+			path:           "/dir_info?user=them&cookie=" + cookie,
+			expectedStatus: http.StatusInternalServerError,
+			expectedBody:   "internal server error",
+			mockParams: mockParams{
+				locateParams: locateParams{
+					dirInfoParams: dirInfoParams{
+						{
+							body: wire.SNAC_0x02_0x0B_LocateGetDirInfo{
+								ScreenName: "them",
+							},
+							msg: wire.SNACMessage{
+								Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{
+									Status: wire.LocateGetDirReplyOK,
+									TLVBlock: wire.TLVBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ODirTLVFirstName, "their_first_name"),
+										},
+									},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "Successfully search directory by name and address",
+			path: "/dir_search?first_name=their_first_name" +
+				"&middle_name=their_middle_name" +
+				"&last_name=their_last_name" +
+				"&maiden_name=their_maiden_name" +
+				"&city=their_city" +
+				"&state=their_state" +
+				"&country=their_country" +
+				"&cookie=" + cookie,
+			expectedStatus: http.StatusOK,
+			expectedBody:   "their_first_name",
+			mockParams: mockParams{
+				dirSearchParams: dirSearchParams{
+					infoQueryParams: infoQueryParams{
+						{
+							inBody: wire.SNAC_0x0F_0x02_InfoQuery{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ODirTLVFirstName, "their_first_name"),
+										wire.NewTLVBE(wire.ODirTLVMiddleName, "their_middle_name"),
+										wire.NewTLVBE(wire.ODirTLVLastName, "their_last_name"),
+										wire.NewTLVBE(wire.ODirTLVMaidenName, "their_maiden_name"),
+										wire.NewTLVBE(wire.ODirTLVCity, "their_city"),
+										wire.NewTLVBE(wire.ODirTLVState, "their_state"),
+										wire.NewTLVBE(wire.ODirTLVCountry, "their_country"),
+									},
+								},
+							},
+							msg: wire.SNACMessage{
+								Body: wire.SNAC_0x0F_0x03_InfoReply{
+									Status: wire.ODirSearchResponseOK,
+									Results: struct {
+										List []wire.TLVBlock `oscar:"count_prefix=uint16"`
+									}{
+										List: []wire.TLVBlock{
+											{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(wire.ODirTLVFirstName, "their_first_name"),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "Successfully search directory by email",
+			path:           "/dir_search?email=their_email@aol.com&cookie=" + cookie,
+			expectedStatus: http.StatusOK,
+			expectedBody:   "their_first_name",
+			mockParams: mockParams{
+				dirSearchParams: dirSearchParams{
+					infoQueryParams: infoQueryParams{
+						{
+							inBody: wire.SNAC_0x0F_0x02_InfoQuery{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ODirTLVEmailAddress, "their_email@aol.com"),
+									},
+								},
+							},
+							msg: wire.SNACMessage{
+								Body: wire.SNAC_0x0F_0x03_InfoReply{
+									Status: wire.ODirSearchResponseOK,
+									Results: struct {
+										List []wire.TLVBlock `oscar:"count_prefix=uint16"`
+									}{
+										List: []wire.TLVBlock{
+											{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(wire.ODirTLVFirstName, "their_first_name"),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "Successfully search directory by keyword",
+			path:           "/dir_search?keyword=their_keyword&cookie=" + cookie,
+			expectedStatus: http.StatusOK,
+			expectedBody:   "their_first_name",
+			mockParams: mockParams{
+				dirSearchParams: dirSearchParams{
+					infoQueryParams: infoQueryParams{
+						{
+							inBody: wire.SNAC_0x0F_0x02_InfoQuery{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ODirTLVInterest, "their_keyword"),
+									},
+								},
+							},
+							msg: wire.SNACMessage{
+								Body: wire.SNAC_0x0F_0x03_InfoReply{
+									Status: wire.ODirSearchResponseOK,
+									Results: struct {
+										List []wire.TLVBlock `oscar:"count_prefix=uint16"`
+									}{
+										List: []wire.TLVBlock{
+											{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(wire.ODirTLVFirstName, "their_first_name"),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "Search directory by email, receive err from dir search svc",
+			path:           "/dir_search?email=their_email@aol.com&cookie=" + cookie,
+			expectedStatus: http.StatusInternalServerError,
+			expectedBody:   "internal server error",
+			mockParams: mockParams{
+				dirSearchParams: dirSearchParams{
+					infoQueryParams: infoQueryParams{
+						{
+							inBody: wire.SNAC_0x0F_0x02_InfoQuery{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ODirTLVEmailAddress, "their_email@aol.com"),
+									},
+								},
+							},
+							msg: wire.SNACMessage{
+								Body: wire.SNAC_0x0F_0x03_InfoReply{
+									Status: wire.ODirSearchResponseOK,
+									Results: struct {
+										List []wire.TLVBlock `oscar:"count_prefix=uint16"`
+									}{
+										List: []wire.TLVBlock{
+											{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(wire.ODirTLVFirstName, "their_first_name"),
+												},
+											},
+										},
+									},
+								},
+							},
+							err: io.EOF,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "Search directory by email, receive unknown response from dir search svc",
+			path:           "/dir_search?email=their_email@aol.com&cookie=" + cookie,
+			expectedStatus: http.StatusInternalServerError,
+			expectedBody:   "internal server error",
+			mockParams: mockParams{
+				dirSearchParams: dirSearchParams{
+					infoQueryParams: infoQueryParams{
+						{
+							inBody: wire.SNAC_0x0F_0x02_InfoQuery{
+								TLVRestBlock: wire.TLVRestBlock{
+									TLVList: wire.TLVList{
+										wire.NewTLVBE(wire.ODirTLVEmailAddress, "their_email@aol.com"),
+									},
+								},
+							},
+							msg: wire.SNACMessage{
+								Body: wire.SNACError{},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "Search directory without any correct search parameters",
+			path:           "/dir_search?cookie=" + cookie,
+			expectedStatus: http.StatusBadRequest,
+			expectedBody:   "missing search parameters",
+			mockParams: mockParams{
+				dirSearchParams: dirSearchParams{
+					infoQueryParams: infoQueryParams{
+						{
+							inBody: wire.SNAC_0x0F_0x02_InfoQuery{},
+							msg: wire.SNACMessage{
+								Body: wire.SNAC_0x0F_0x03_InfoReply{
+									Status: wire.ODirSearchResponseNameMissing,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "Search directory, receive error code from search dir svc",
+			path:           "/dir_search?cookie=" + cookie,
+			expectedStatus: http.StatusInternalServerError,
+			expectedBody:   "internal server error",
+			mockParams: mockParams{
+				dirSearchParams: dirSearchParams{
+					infoQueryParams: infoQueryParams{
+						{
+							inBody: wire.SNAC_0x0F_0x02_InfoQuery{},
+							msg: wire.SNACMessage{
+								Body: wire.SNAC_0x0F_0x03_InfoReply{
+									Status: wire.ODirSearchResponseUnavailable1,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "Search directory, receive unknown response from search dir svc",
+			path:           "/dir_search?cookie=" + cookie,
+			expectedStatus: http.StatusInternalServerError,
+			expectedBody:   "internal server error",
+			mockParams: mockParams{
+				dirSearchParams: dirSearchParams{
+					infoQueryParams: infoQueryParams{
+						{
+							inBody: wire.SNAC_0x0F_0x02_InfoQuery{},
+							msg: wire.SNACMessage{
+								Body: wire.SNAC_0x0F_0x03_InfoReply{
+									Status: wire.ODirSearchResponseUnavailable1,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			locateSvc := newMockLocateService(t)
+			for _, params := range tc.mockParams.userInfoQueryParams {
+				locateSvc.EXPECT().
+					UserInfoQuery(mock.Anything, matchSession(params.me), wire.SNACFrame{}, params.inBody).
+					Return(params.msg, params.err)
+			}
+			for _, params := range tc.mockParams.dirInfoParams {
+				locateSvc.EXPECT().
+					DirInfo(mock.Anything, wire.SNACFrame{}, params.body).
+					Return(params.msg, params.err)
+			}
+			dirSearchSvc := newMockDirSearchService(t)
+			for _, params := range tc.mockParams.infoQueryParams {
+				dirSearchSvc.EXPECT().
+					InfoQuery(mock.Anything, wire.SNACFrame{}, params.inBody).
+					Return(params.msg, params.err)
+			}
+
+			svc := OSCARProxy{
+				CookieBaker:      cookieBaker,
+				DirSearchService: dirSearchSvc,
+				LocateService:    locateSvc,
+				Logger:           slog.Default(),
+			}
+
+			req, err := http.NewRequest(http.MethodGet, tc.path, nil)
+			if err != nil {
+				t.Fatalf("Failed to create request: %v", err)
+			}
+
+			rr := httptest.NewRecorder()
+
+			svc.NewServeMux().ServeHTTP(rr, req)
+
+			assert.Equal(t, tc.expectedStatus, rr.Code)
+			assert.Contains(t, rr.Body.String(), tc.expectedBody)
+		})
+	}
+}

+ 456 - 0
server/toc/mock_auth_service_test.go

@@ -0,0 +1,456 @@
+// Code generated by mockery v2.51.1. DO NOT EDIT.
+
+package toc
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	uuid "github.com/google/uuid"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockAuthService is an autogenerated mock type for the AuthService type
+type mockAuthService struct {
+	mock.Mock
+}
+
+type mockAuthService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockAuthService) EXPECT() *mockAuthService_Expecter {
+	return &mockAuthService_Expecter{mock: &_m.Mock}
+}
+
+// BUCPChallenge provides a mock function with given fields: bodyIn, newUUID
+func (_m *mockAuthService) BUCPChallenge(bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error) {
+	ret := _m.Called(bodyIn, newUUID)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BUCPChallenge")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(wire.SNAC_0x17_0x06_BUCPChallengeRequest, func() uuid.UUID) (wire.SNACMessage, error)); ok {
+		return rf(bodyIn, newUUID)
+	}
+	if rf, ok := ret.Get(0).(func(wire.SNAC_0x17_0x06_BUCPChallengeRequest, func() uuid.UUID) wire.SNACMessage); ok {
+		r0 = rf(bodyIn, newUUID)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(wire.SNAC_0x17_0x06_BUCPChallengeRequest, func() uuid.UUID) error); ok {
+		r1 = rf(bodyIn, newUUID)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAuthService_BUCPChallenge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BUCPChallenge'
+type mockAuthService_BUCPChallenge_Call struct {
+	*mock.Call
+}
+
+// BUCPChallenge is a helper method to define mock.On call
+//   - bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest
+//   - newUUID func() uuid.UUID
+func (_e *mockAuthService_Expecter) BUCPChallenge(bodyIn interface{}, newUUID interface{}) *mockAuthService_BUCPChallenge_Call {
+	return &mockAuthService_BUCPChallenge_Call{Call: _e.mock.On("BUCPChallenge", bodyIn, newUUID)}
+}
+
+func (_c *mockAuthService_BUCPChallenge_Call) Run(run func(bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID)) *mockAuthService_BUCPChallenge_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(wire.SNAC_0x17_0x06_BUCPChallengeRequest), args[1].(func() uuid.UUID))
+	})
+	return _c
+}
+
+func (_c *mockAuthService_BUCPChallenge_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockAuthService_BUCPChallenge_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAuthService_BUCPChallenge_Call) RunAndReturn(run func(wire.SNAC_0x17_0x06_BUCPChallengeRequest, func() uuid.UUID) (wire.SNACMessage, error)) *mockAuthService_BUCPChallenge_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// BUCPLogin provides a mock function with given fields: bodyIn, newUserFn
+func (_m *mockAuthService) BUCPLogin(bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error) {
+	ret := _m.Called(bodyIn, newUserFn)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BUCPLogin")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)); ok {
+		return rf(bodyIn, newUserFn)
+	}
+	if rf, ok := ret.Get(0).(func(wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error)) wire.SNACMessage); ok {
+		r0 = rf(bodyIn, newUserFn)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error)) error); ok {
+		r1 = rf(bodyIn, newUserFn)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAuthService_BUCPLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BUCPLogin'
+type mockAuthService_BUCPLogin_Call struct {
+	*mock.Call
+}
+
+// BUCPLogin is a helper method to define mock.On call
+//   - bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest
+//   - newUserFn func(state.DisplayScreenName)(state.User , error)
+func (_e *mockAuthService_Expecter) BUCPLogin(bodyIn interface{}, newUserFn interface{}) *mockAuthService_BUCPLogin_Call {
+	return &mockAuthService_BUCPLogin_Call{Call: _e.mock.On("BUCPLogin", bodyIn, newUserFn)}
+}
+
+func (_c *mockAuthService_BUCPLogin_Call) Run(run func(bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error))) *mockAuthService_BUCPLogin_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(wire.SNAC_0x17_0x02_BUCPLoginRequest), args[1].(func(state.DisplayScreenName) (state.User, error)))
+	})
+	return _c
+}
+
+func (_c *mockAuthService_BUCPLogin_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockAuthService_BUCPLogin_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAuthService_BUCPLogin_Call) RunAndReturn(run func(wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)) *mockAuthService_BUCPLogin_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// FLAPLogin provides a mock function with given fields: frame, newUserFn
+func (_m *mockAuthService) FLAPLogin(frame wire.FLAPSignonFrame, newUserFn func(state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error) {
+	ret := _m.Called(frame, newUserFn)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FLAPLogin")
+	}
+
+	var r0 wire.TLVRestBlock
+	var r1 error
+	if rf, ok := ret.Get(0).(func(wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error)); ok {
+		return rf(frame, newUserFn)
+	}
+	if rf, ok := ret.Get(0).(func(wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error)) wire.TLVRestBlock); ok {
+		r0 = rf(frame, newUserFn)
+	} else {
+		r0 = ret.Get(0).(wire.TLVRestBlock)
+	}
+
+	if rf, ok := ret.Get(1).(func(wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error)) error); ok {
+		r1 = rf(frame, newUserFn)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAuthService_FLAPLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FLAPLogin'
+type mockAuthService_FLAPLogin_Call struct {
+	*mock.Call
+}
+
+// FLAPLogin is a helper method to define mock.On call
+//   - frame wire.FLAPSignonFrame
+//   - newUserFn func(state.DisplayScreenName)(state.User , error)
+func (_e *mockAuthService_Expecter) FLAPLogin(frame interface{}, newUserFn interface{}) *mockAuthService_FLAPLogin_Call {
+	return &mockAuthService_FLAPLogin_Call{Call: _e.mock.On("FLAPLogin", frame, newUserFn)}
+}
+
+func (_c *mockAuthService_FLAPLogin_Call) Run(run func(frame wire.FLAPSignonFrame, newUserFn func(state.DisplayScreenName) (state.User, error))) *mockAuthService_FLAPLogin_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(wire.FLAPSignonFrame), args[1].(func(state.DisplayScreenName) (state.User, error)))
+	})
+	return _c
+}
+
+func (_c *mockAuthService_FLAPLogin_Call) Return(_a0 wire.TLVRestBlock, _a1 error) *mockAuthService_FLAPLogin_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAuthService_FLAPLogin_Call) RunAndReturn(run func(wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error)) *mockAuthService_FLAPLogin_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RegisterBOSSession provides a mock function with given fields: ctx, authCookie
+func (_m *mockAuthService) RegisterBOSSession(ctx context.Context, authCookie []byte) (*state.Session, error) {
+	ret := _m.Called(ctx, authCookie)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RegisterBOSSession")
+	}
+
+	var r0 *state.Session
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, []byte) (*state.Session, error)); ok {
+		return rf(ctx, authCookie)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, []byte) *state.Session); ok {
+		r0 = rf(ctx, authCookie)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*state.Session)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
+		r1 = rf(ctx, authCookie)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAuthService_RegisterBOSSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterBOSSession'
+type mockAuthService_RegisterBOSSession_Call struct {
+	*mock.Call
+}
+
+// RegisterBOSSession is a helper method to define mock.On call
+//   - ctx context.Context
+//   - authCookie []byte
+func (_e *mockAuthService_Expecter) RegisterBOSSession(ctx interface{}, authCookie interface{}) *mockAuthService_RegisterBOSSession_Call {
+	return &mockAuthService_RegisterBOSSession_Call{Call: _e.mock.On("RegisterBOSSession", ctx, authCookie)}
+}
+
+func (_c *mockAuthService_RegisterBOSSession_Call) Run(run func(ctx context.Context, authCookie []byte)) *mockAuthService_RegisterBOSSession_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].([]byte))
+	})
+	return _c
+}
+
+func (_c *mockAuthService_RegisterBOSSession_Call) Return(_a0 *state.Session, _a1 error) *mockAuthService_RegisterBOSSession_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAuthService_RegisterBOSSession_Call) RunAndReturn(run func(context.Context, []byte) (*state.Session, error)) *mockAuthService_RegisterBOSSession_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RegisterChatSession provides a mock function with given fields: ctx, authCookie
+func (_m *mockAuthService) RegisterChatSession(ctx context.Context, authCookie []byte) (*state.Session, error) {
+	ret := _m.Called(ctx, authCookie)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RegisterChatSession")
+	}
+
+	var r0 *state.Session
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, []byte) (*state.Session, error)); ok {
+		return rf(ctx, authCookie)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, []byte) *state.Session); ok {
+		r0 = rf(ctx, authCookie)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*state.Session)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
+		r1 = rf(ctx, authCookie)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAuthService_RegisterChatSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterChatSession'
+type mockAuthService_RegisterChatSession_Call struct {
+	*mock.Call
+}
+
+// RegisterChatSession is a helper method to define mock.On call
+//   - ctx context.Context
+//   - authCookie []byte
+func (_e *mockAuthService_Expecter) RegisterChatSession(ctx interface{}, authCookie interface{}) *mockAuthService_RegisterChatSession_Call {
+	return &mockAuthService_RegisterChatSession_Call{Call: _e.mock.On("RegisterChatSession", ctx, authCookie)}
+}
+
+func (_c *mockAuthService_RegisterChatSession_Call) Run(run func(ctx context.Context, authCookie []byte)) *mockAuthService_RegisterChatSession_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].([]byte))
+	})
+	return _c
+}
+
+func (_c *mockAuthService_RegisterChatSession_Call) Return(_a0 *state.Session, _a1 error) *mockAuthService_RegisterChatSession_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAuthService_RegisterChatSession_Call) RunAndReturn(run func(context.Context, []byte) (*state.Session, error)) *mockAuthService_RegisterChatSession_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RetrieveBOSSession provides a mock function with given fields: authCookie
+func (_m *mockAuthService) RetrieveBOSSession(authCookie []byte) (*state.Session, error) {
+	ret := _m.Called(authCookie)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RetrieveBOSSession")
+	}
+
+	var r0 *state.Session
+	var r1 error
+	if rf, ok := ret.Get(0).(func([]byte) (*state.Session, error)); ok {
+		return rf(authCookie)
+	}
+	if rf, ok := ret.Get(0).(func([]byte) *state.Session); ok {
+		r0 = rf(authCookie)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*state.Session)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func([]byte) error); ok {
+		r1 = rf(authCookie)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAuthService_RetrieveBOSSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveBOSSession'
+type mockAuthService_RetrieveBOSSession_Call struct {
+	*mock.Call
+}
+
+// RetrieveBOSSession is a helper method to define mock.On call
+//   - authCookie []byte
+func (_e *mockAuthService_Expecter) RetrieveBOSSession(authCookie interface{}) *mockAuthService_RetrieveBOSSession_Call {
+	return &mockAuthService_RetrieveBOSSession_Call{Call: _e.mock.On("RetrieveBOSSession", authCookie)}
+}
+
+func (_c *mockAuthService_RetrieveBOSSession_Call) Run(run func(authCookie []byte)) *mockAuthService_RetrieveBOSSession_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].([]byte))
+	})
+	return _c
+}
+
+func (_c *mockAuthService_RetrieveBOSSession_Call) Return(_a0 *state.Session, _a1 error) *mockAuthService_RetrieveBOSSession_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAuthService_RetrieveBOSSession_Call) RunAndReturn(run func([]byte) (*state.Session, error)) *mockAuthService_RetrieveBOSSession_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// Signout provides a mock function with given fields: ctx, sess
+func (_m *mockAuthService) Signout(ctx context.Context, sess *state.Session) {
+	_m.Called(ctx, sess)
+}
+
+// mockAuthService_Signout_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Signout'
+type mockAuthService_Signout_Call struct {
+	*mock.Call
+}
+
+// Signout is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+func (_e *mockAuthService_Expecter) Signout(ctx interface{}, sess interface{}) *mockAuthService_Signout_Call {
+	return &mockAuthService_Signout_Call{Call: _e.mock.On("Signout", ctx, sess)}
+}
+
+func (_c *mockAuthService_Signout_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockAuthService_Signout_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session))
+	})
+	return _c
+}
+
+func (_c *mockAuthService_Signout_Call) Return() *mockAuthService_Signout_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockAuthService_Signout_Call) RunAndReturn(run func(context.Context, *state.Session)) *mockAuthService_Signout_Call {
+	_c.Run(run)
+	return _c
+}
+
+// SignoutChat provides a mock function with given fields: ctx, sess
+func (_m *mockAuthService) SignoutChat(ctx context.Context, sess *state.Session) {
+	_m.Called(ctx, sess)
+}
+
+// mockAuthService_SignoutChat_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SignoutChat'
+type mockAuthService_SignoutChat_Call struct {
+	*mock.Call
+}
+
+// SignoutChat is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+func (_e *mockAuthService_Expecter) SignoutChat(ctx interface{}, sess interface{}) *mockAuthService_SignoutChat_Call {
+	return &mockAuthService_SignoutChat_Call{Call: _e.mock.On("SignoutChat", ctx, sess)}
+}
+
+func (_c *mockAuthService_SignoutChat_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockAuthService_SignoutChat_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session))
+	})
+	return _c
+}
+
+func (_c *mockAuthService_SignoutChat_Call) Return() *mockAuthService_SignoutChat_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockAuthService_SignoutChat_Call) RunAndReturn(run func(context.Context, *state.Session)) *mockAuthService_SignoutChat_Call {
+	_c.Run(run)
+	return _c
+}
+
+// newMockAuthService creates a new instance of mockAuthService. 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 newMockAuthService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockAuthService {
+	mock := &mockAuthService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 127 - 0
server/toc/mock_buddy_list_registry_test.go

@@ -0,0 +1,127 @@
+// Code generated by mockery v2.51.1. DO NOT EDIT.
+
+package toc
+
+import (
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockBuddyListRegistry is an autogenerated mock type for the BuddyListRegistry type
+type mockBuddyListRegistry struct {
+	mock.Mock
+}
+
+type mockBuddyListRegistry_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockBuddyListRegistry) EXPECT() *mockBuddyListRegistry_Expecter {
+	return &mockBuddyListRegistry_Expecter{mock: &_m.Mock}
+}
+
+// RegisterBuddyList provides a mock function with given fields: user
+func (_m *mockBuddyListRegistry) RegisterBuddyList(user state.IdentScreenName) error {
+	ret := _m.Called(user)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RegisterBuddyList")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) error); ok {
+		r0 = rf(user)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBuddyListRegistry_RegisterBuddyList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterBuddyList'
+type mockBuddyListRegistry_RegisterBuddyList_Call struct {
+	*mock.Call
+}
+
+// RegisterBuddyList is a helper method to define mock.On call
+//   - user state.IdentScreenName
+func (_e *mockBuddyListRegistry_Expecter) RegisterBuddyList(user interface{}) *mockBuddyListRegistry_RegisterBuddyList_Call {
+	return &mockBuddyListRegistry_RegisterBuddyList_Call{Call: _e.mock.On("RegisterBuddyList", user)}
+}
+
+func (_c *mockBuddyListRegistry_RegisterBuddyList_Call) Run(run func(user state.IdentScreenName)) *mockBuddyListRegistry_RegisterBuddyList_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockBuddyListRegistry_RegisterBuddyList_Call) Return(_a0 error) *mockBuddyListRegistry_RegisterBuddyList_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBuddyListRegistry_RegisterBuddyList_Call) RunAndReturn(run func(state.IdentScreenName) error) *mockBuddyListRegistry_RegisterBuddyList_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UnregisterBuddyList provides a mock function with given fields: user
+func (_m *mockBuddyListRegistry) UnregisterBuddyList(user state.IdentScreenName) error {
+	ret := _m.Called(user)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UnregisterBuddyList")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) error); ok {
+		r0 = rf(user)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBuddyListRegistry_UnregisterBuddyList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UnregisterBuddyList'
+type mockBuddyListRegistry_UnregisterBuddyList_Call struct {
+	*mock.Call
+}
+
+// UnregisterBuddyList is a helper method to define mock.On call
+//   - user state.IdentScreenName
+func (_e *mockBuddyListRegistry_Expecter) UnregisterBuddyList(user interface{}) *mockBuddyListRegistry_UnregisterBuddyList_Call {
+	return &mockBuddyListRegistry_UnregisterBuddyList_Call{Call: _e.mock.On("UnregisterBuddyList", user)}
+}
+
+func (_c *mockBuddyListRegistry_UnregisterBuddyList_Call) Run(run func(user state.IdentScreenName)) *mockBuddyListRegistry_UnregisterBuddyList_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockBuddyListRegistry_UnregisterBuddyList_Call) Return(_a0 error) *mockBuddyListRegistry_UnregisterBuddyList_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBuddyListRegistry_UnregisterBuddyList_Call) RunAndReturn(run func(state.IdentScreenName) error) *mockBuddyListRegistry_UnregisterBuddyList_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockBuddyListRegistry creates a new instance of mockBuddyListRegistry. 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 newMockBuddyListRegistry(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockBuddyListRegistry {
+	mock := &mockBuddyListRegistry{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 229 - 0
server/toc/mock_buddy_service_test.go

@@ -0,0 +1,229 @@
+// Code generated by mockery v2.51.1. DO NOT EDIT.
+
+package toc
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockBuddyService is an autogenerated mock type for the BuddyService type
+type mockBuddyService struct {
+	mock.Mock
+}
+
+type mockBuddyService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockBuddyService) EXPECT() *mockBuddyService_Expecter {
+	return &mockBuddyService_Expecter{mock: &_m.Mock}
+}
+
+// AddBuddies provides a mock function with given fields: ctx, sess, inBody
+func (_m *mockBuddyService) AddBuddies(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) error {
+	ret := _m.Called(ctx, sess, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for AddBuddies")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNAC_0x03_0x04_BuddyAddBuddies) error); ok {
+		r0 = rf(ctx, sess, inBody)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBuddyService_AddBuddies_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddBuddies'
+type mockBuddyService_AddBuddies_Call struct {
+	*mock.Call
+}
+
+// AddBuddies is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inBody wire.SNAC_0x03_0x04_BuddyAddBuddies
+func (_e *mockBuddyService_Expecter) AddBuddies(ctx interface{}, sess interface{}, inBody interface{}) *mockBuddyService_AddBuddies_Call {
+	return &mockBuddyService_AddBuddies_Call{Call: _e.mock.On("AddBuddies", ctx, sess, inBody)}
+}
+
+func (_c *mockBuddyService_AddBuddies_Call) Run(run func(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies)) *mockBuddyService_AddBuddies_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNAC_0x03_0x04_BuddyAddBuddies))
+	})
+	return _c
+}
+
+func (_c *mockBuddyService_AddBuddies_Call) Return(_a0 error) *mockBuddyService_AddBuddies_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBuddyService_AddBuddies_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNAC_0x03_0x04_BuddyAddBuddies) error) *mockBuddyService_AddBuddies_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// BroadcastBuddyDeparted provides a mock function with given fields: ctx, sess
+func (_m *mockBuddyService) BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error {
+	ret := _m.Called(ctx, sess)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BroadcastBuddyDeparted")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session) error); ok {
+		r0 = rf(ctx, sess)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBuddyService_BroadcastBuddyDeparted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BroadcastBuddyDeparted'
+type mockBuddyService_BroadcastBuddyDeparted_Call struct {
+	*mock.Call
+}
+
+// BroadcastBuddyDeparted is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+func (_e *mockBuddyService_Expecter) BroadcastBuddyDeparted(ctx interface{}, sess interface{}) *mockBuddyService_BroadcastBuddyDeparted_Call {
+	return &mockBuddyService_BroadcastBuddyDeparted_Call{Call: _e.mock.On("BroadcastBuddyDeparted", ctx, sess)}
+}
+
+func (_c *mockBuddyService_BroadcastBuddyDeparted_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockBuddyService_BroadcastBuddyDeparted_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session))
+	})
+	return _c
+}
+
+func (_c *mockBuddyService_BroadcastBuddyDeparted_Call) Return(_a0 error) *mockBuddyService_BroadcastBuddyDeparted_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBuddyService_BroadcastBuddyDeparted_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockBuddyService_BroadcastBuddyDeparted_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// DelBuddies provides a mock function with given fields: _a0, sess, inBody
+func (_m *mockBuddyService) DelBuddies(_a0 context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x05_BuddyDelBuddies) error {
+	ret := _m.Called(_a0, sess, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for DelBuddies")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNAC_0x03_0x05_BuddyDelBuddies) error); ok {
+		r0 = rf(_a0, sess, inBody)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBuddyService_DelBuddies_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DelBuddies'
+type mockBuddyService_DelBuddies_Call struct {
+	*mock.Call
+}
+
+// DelBuddies is a helper method to define mock.On call
+//   - _a0 context.Context
+//   - sess *state.Session
+//   - inBody wire.SNAC_0x03_0x05_BuddyDelBuddies
+func (_e *mockBuddyService_Expecter) DelBuddies(_a0 interface{}, sess interface{}, inBody interface{}) *mockBuddyService_DelBuddies_Call {
+	return &mockBuddyService_DelBuddies_Call{Call: _e.mock.On("DelBuddies", _a0, sess, inBody)}
+}
+
+func (_c *mockBuddyService_DelBuddies_Call) Run(run func(_a0 context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x05_BuddyDelBuddies)) *mockBuddyService_DelBuddies_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNAC_0x03_0x05_BuddyDelBuddies))
+	})
+	return _c
+}
+
+func (_c *mockBuddyService_DelBuddies_Call) Return(_a0 error) *mockBuddyService_DelBuddies_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBuddyService_DelBuddies_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNAC_0x03_0x05_BuddyDelBuddies) error) *mockBuddyService_DelBuddies_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RightsQuery provides a mock function with given fields: ctx, inFrame
+func (_m *mockBuddyService) RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	ret := _m.Called(ctx, inFrame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RightsQuery")
+	}
+
+	var r0 wire.SNACMessage
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(ctx, inFrame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	return r0
+}
+
+// mockBuddyService_RightsQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RightsQuery'
+type mockBuddyService_RightsQuery_Call struct {
+	*mock.Call
+}
+
+// RightsQuery is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+func (_e *mockBuddyService_Expecter) RightsQuery(ctx interface{}, inFrame interface{}) *mockBuddyService_RightsQuery_Call {
+	return &mockBuddyService_RightsQuery_Call{Call: _e.mock.On("RightsQuery", ctx, inFrame)}
+}
+
+func (_c *mockBuddyService_RightsQuery_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame)) *mockBuddyService_RightsQuery_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame))
+	})
+	return _c
+}
+
+func (_c *mockBuddyService_RightsQuery_Call) Return(_a0 wire.SNACMessage) *mockBuddyService_RightsQuery_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBuddyService_RightsQuery_Call) RunAndReturn(run func(context.Context, wire.SNACFrame) wire.SNACMessage) *mockBuddyService_RightsQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockBuddyService creates a new instance of mockBuddyService. 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 newMockBuddyService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockBuddyService {
+	mock := &mockBuddyService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 261 - 0
server/toc/mock_chat_nav_service_test.go

@@ -0,0 +1,261 @@
+// Code generated by mockery v2.51.1. DO NOT EDIT.
+
+package toc
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockChatNavService is an autogenerated mock type for the ChatNavService type
+type mockChatNavService struct {
+	mock.Mock
+}
+
+type mockChatNavService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockChatNavService) EXPECT() *mockChatNavService_Expecter {
+	return &mockChatNavService_Expecter{mock: &_m.Mock}
+}
+
+// CreateRoom provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockChatNavService) CreateRoom(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for CreateRoom")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) error); ok {
+		r1 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockChatNavService_CreateRoom_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRoom'
+type mockChatNavService_CreateRoom_Call struct {
+	*mock.Call
+}
+
+// CreateRoom is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate
+func (_e *mockChatNavService_Expecter) CreateRoom(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockChatNavService_CreateRoom_Call {
+	return &mockChatNavService_CreateRoom_Call{Call: _e.mock.On("CreateRoom", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockChatNavService_CreateRoom_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate)) *mockChatNavService_CreateRoom_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate))
+	})
+	return _c
+}
+
+func (_c *mockChatNavService_CreateRoom_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockChatNavService_CreateRoom_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockChatNavService_CreateRoom_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error)) *mockChatNavService_CreateRoom_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// ExchangeInfo provides a mock function with given fields: ctx, inFrame, inBody
+func (_m *mockChatNavService) ExchangeInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ExchangeInfo")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) (wire.SNACMessage, error)); ok {
+		return rf(ctx, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) wire.SNACMessage); ok {
+		r0 = rf(ctx, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) error); ok {
+		r1 = rf(ctx, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockChatNavService_ExchangeInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExchangeInfo'
+type mockChatNavService_ExchangeInfo_Call struct {
+	*mock.Call
+}
+
+// ExchangeInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo
+func (_e *mockChatNavService_Expecter) ExchangeInfo(ctx interface{}, inFrame interface{}, inBody interface{}) *mockChatNavService_ExchangeInfo_Call {
+	return &mockChatNavService_ExchangeInfo_Call{Call: _e.mock.On("ExchangeInfo", ctx, inFrame, inBody)}
+}
+
+func (_c *mockChatNavService_ExchangeInfo_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo)) *mockChatNavService_ExchangeInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame), args[2].(wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo))
+	})
+	return _c
+}
+
+func (_c *mockChatNavService_ExchangeInfo_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockChatNavService_ExchangeInfo_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockChatNavService_ExchangeInfo_Call) RunAndReturn(run func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) (wire.SNACMessage, error)) *mockChatNavService_ExchangeInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RequestChatRights provides a mock function with given fields: ctx, inFrame
+func (_m *mockChatNavService) RequestChatRights(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	ret := _m.Called(ctx, inFrame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RequestChatRights")
+	}
+
+	var r0 wire.SNACMessage
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(ctx, inFrame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	return r0
+}
+
+// mockChatNavService_RequestChatRights_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RequestChatRights'
+type mockChatNavService_RequestChatRights_Call struct {
+	*mock.Call
+}
+
+// RequestChatRights is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+func (_e *mockChatNavService_Expecter) RequestChatRights(ctx interface{}, inFrame interface{}) *mockChatNavService_RequestChatRights_Call {
+	return &mockChatNavService_RequestChatRights_Call{Call: _e.mock.On("RequestChatRights", ctx, inFrame)}
+}
+
+func (_c *mockChatNavService_RequestChatRights_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame)) *mockChatNavService_RequestChatRights_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame))
+	})
+	return _c
+}
+
+func (_c *mockChatNavService_RequestChatRights_Call) Return(_a0 wire.SNACMessage) *mockChatNavService_RequestChatRights_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockChatNavService_RequestChatRights_Call) RunAndReturn(run func(context.Context, wire.SNACFrame) wire.SNACMessage) *mockChatNavService_RequestChatRights_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RequestRoomInfo provides a mock function with given fields: ctx, inFrame, inBody
+func (_m *mockChatNavService) RequestRoomInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RequestRoomInfo")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (wire.SNACMessage, error)); ok {
+		return rf(ctx, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) wire.SNACMessage); ok {
+		r0 = rf(ctx, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) error); ok {
+		r1 = rf(ctx, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockChatNavService_RequestRoomInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RequestRoomInfo'
+type mockChatNavService_RequestRoomInfo_Call struct {
+	*mock.Call
+}
+
+// RequestRoomInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo
+func (_e *mockChatNavService_Expecter) RequestRoomInfo(ctx interface{}, inFrame interface{}, inBody interface{}) *mockChatNavService_RequestRoomInfo_Call {
+	return &mockChatNavService_RequestRoomInfo_Call{Call: _e.mock.On("RequestRoomInfo", ctx, inFrame, inBody)}
+}
+
+func (_c *mockChatNavService_RequestRoomInfo_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo)) *mockChatNavService_RequestRoomInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame), args[2].(wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo))
+	})
+	return _c
+}
+
+func (_c *mockChatNavService_RequestRoomInfo_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockChatNavService_RequestRoomInfo_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockChatNavService_RequestRoomInfo_Call) RunAndReturn(run func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (wire.SNACMessage, error)) *mockChatNavService_RequestRoomInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockChatNavService creates a new instance of mockChatNavService. 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 newMockChatNavService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockChatNavService {
+	mock := &mockChatNavService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 100 - 0
server/toc/mock_chat_service_test.go

@@ -0,0 +1,100 @@
+// Code generated by mockery v2.51.1. DO NOT EDIT.
+
+package toc
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockChatService is an autogenerated mock type for the ChatService type
+type mockChatService struct {
+	mock.Mock
+}
+
+type mockChatService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockChatService) EXPECT() *mockChatService_Expecter {
+	return &mockChatService_Expecter{mock: &_m.Mock}
+}
+
+// ChannelMsgToHost provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockChatService) ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ChannelMsgToHost")
+	}
+
+	var r0 *wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) *wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.SNACMessage)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) error); ok {
+		r1 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockChatService_ChannelMsgToHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChannelMsgToHost'
+type mockChatService_ChannelMsgToHost_Call struct {
+	*mock.Call
+}
+
+// ChannelMsgToHost is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost
+func (_e *mockChatService_Expecter) ChannelMsgToHost(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockChatService_ChannelMsgToHost_Call {
+	return &mockChatService_ChannelMsgToHost_Call{Call: _e.mock.On("ChannelMsgToHost", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockChatService_ChannelMsgToHost_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost)) *mockChatService_ChannelMsgToHost_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x0E_0x05_ChatChannelMsgToHost))
+	})
+	return _c
+}
+
+func (_c *mockChatService_ChannelMsgToHost_Call) Return(_a0 *wire.SNACMessage, _a1 error) *mockChatService_ChannelMsgToHost_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockChatService_ChannelMsgToHost_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error)) *mockChatService_ChannelMsgToHost_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockChatService creates a new instance of mockChatService. 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 newMockChatService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockChatService {
+	mock := &mockChatService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 148 - 0
server/toc/mock_cookie_baker_test.go

@@ -0,0 +1,148 @@
+// Code generated by mockery v2.51.1. DO NOT EDIT.
+
+package toc
+
+import mock "github.com/stretchr/testify/mock"
+
+// mockCookieBaker is an autogenerated mock type for the CookieBaker type
+type mockCookieBaker struct {
+	mock.Mock
+}
+
+type mockCookieBaker_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockCookieBaker) EXPECT() *mockCookieBaker_Expecter {
+	return &mockCookieBaker_Expecter{mock: &_m.Mock}
+}
+
+// Crack provides a mock function with given fields: data
+func (_m *mockCookieBaker) Crack(data []byte) ([]byte, error) {
+	ret := _m.Called(data)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Crack")
+	}
+
+	var r0 []byte
+	var r1 error
+	if rf, ok := ret.Get(0).(func([]byte) ([]byte, error)); ok {
+		return rf(data)
+	}
+	if rf, ok := ret.Get(0).(func([]byte) []byte); ok {
+		r0 = rf(data)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]byte)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func([]byte) error); ok {
+		r1 = rf(data)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockCookieBaker_Crack_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Crack'
+type mockCookieBaker_Crack_Call struct {
+	*mock.Call
+}
+
+// Crack is a helper method to define mock.On call
+//   - data []byte
+func (_e *mockCookieBaker_Expecter) Crack(data interface{}) *mockCookieBaker_Crack_Call {
+	return &mockCookieBaker_Crack_Call{Call: _e.mock.On("Crack", data)}
+}
+
+func (_c *mockCookieBaker_Crack_Call) Run(run func(data []byte)) *mockCookieBaker_Crack_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].([]byte))
+	})
+	return _c
+}
+
+func (_c *mockCookieBaker_Crack_Call) Return(_a0 []byte, _a1 error) *mockCookieBaker_Crack_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockCookieBaker_Crack_Call) RunAndReturn(run func([]byte) ([]byte, error)) *mockCookieBaker_Crack_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// Issue provides a mock function with given fields: data
+func (_m *mockCookieBaker) Issue(data []byte) ([]byte, error) {
+	ret := _m.Called(data)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Issue")
+	}
+
+	var r0 []byte
+	var r1 error
+	if rf, ok := ret.Get(0).(func([]byte) ([]byte, error)); ok {
+		return rf(data)
+	}
+	if rf, ok := ret.Get(0).(func([]byte) []byte); ok {
+		r0 = rf(data)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]byte)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func([]byte) error); ok {
+		r1 = rf(data)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockCookieBaker_Issue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Issue'
+type mockCookieBaker_Issue_Call struct {
+	*mock.Call
+}
+
+// Issue is a helper method to define mock.On call
+//   - data []byte
+func (_e *mockCookieBaker_Expecter) Issue(data interface{}) *mockCookieBaker_Issue_Call {
+	return &mockCookieBaker_Issue_Call{Call: _e.mock.On("Issue", data)}
+}
+
+func (_c *mockCookieBaker_Issue_Call) Run(run func(data []byte)) *mockCookieBaker_Issue_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].([]byte))
+	})
+	return _c
+}
+
+func (_c *mockCookieBaker_Issue_Call) Return(_a0 []byte, _a1 error) *mockCookieBaker_Issue_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockCookieBaker_Issue_Call) RunAndReturn(run func([]byte) ([]byte, error)) *mockCookieBaker_Issue_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockCookieBaker creates a new instance of mockCookieBaker. 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 newMockCookieBaker(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockCookieBaker {
+	mock := &mockCookieBaker{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 95 - 0
server/toc/mock_dir_search_service_test.go

@@ -0,0 +1,95 @@
+// Code generated by mockery v2.51.1. DO NOT EDIT.
+
+package toc
+
+import (
+	context "context"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockDirSearchService is an autogenerated mock type for the DirSearchService type
+type mockDirSearchService struct {
+	mock.Mock
+}
+
+type mockDirSearchService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockDirSearchService) EXPECT() *mockDirSearchService_Expecter {
+	return &mockDirSearchService_Expecter{mock: &_m.Mock}
+}
+
+// InfoQuery provides a mock function with given fields: _a0, inFrame, inBody
+func (_m *mockDirSearchService) InfoQuery(_a0 context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error) {
+	ret := _m.Called(_a0, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for InfoQuery")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)); ok {
+		return rf(_a0, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) wire.SNACMessage); ok {
+		r0 = rf(_a0, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) error); ok {
+		r1 = rf(_a0, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockDirSearchService_InfoQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InfoQuery'
+type mockDirSearchService_InfoQuery_Call struct {
+	*mock.Call
+}
+
+// InfoQuery is a helper method to define mock.On call
+//   - _a0 context.Context
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x0F_0x02_InfoQuery
+func (_e *mockDirSearchService_Expecter) InfoQuery(_a0 interface{}, inFrame interface{}, inBody interface{}) *mockDirSearchService_InfoQuery_Call {
+	return &mockDirSearchService_InfoQuery_Call{Call: _e.mock.On("InfoQuery", _a0, inFrame, inBody)}
+}
+
+func (_c *mockDirSearchService_InfoQuery_Call) Run(run func(_a0 context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery)) *mockDirSearchService_InfoQuery_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame), args[2].(wire.SNAC_0x0F_0x02_InfoQuery))
+	})
+	return _c
+}
+
+func (_c *mockDirSearchService_InfoQuery_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockDirSearchService_InfoQuery_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockDirSearchService_InfoQuery_Call) RunAndReturn(run func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)) *mockDirSearchService_InfoQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockDirSearchService creates a new instance of mockDirSearchService. 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 newMockDirSearchService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockDirSearchService {
+	mock := &mockDirSearchService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 304 - 0
server/toc/mock_icbm_service_test.go

@@ -0,0 +1,304 @@
+// Code generated by mockery v2.51.1. DO NOT EDIT.
+
+package toc
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockICBMService is an autogenerated mock type for the ICBMService type
+type mockICBMService struct {
+	mock.Mock
+}
+
+type mockICBMService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockICBMService) EXPECT() *mockICBMService_Expecter {
+	return &mockICBMService_Expecter{mock: &_m.Mock}
+}
+
+// ChannelMsgToHost provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockICBMService) ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ChannelMsgToHost")
+	}
+
+	var r0 *wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) *wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.SNACMessage)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) error); ok {
+		r1 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockICBMService_ChannelMsgToHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChannelMsgToHost'
+type mockICBMService_ChannelMsgToHost_Call struct {
+	*mock.Call
+}
+
+// ChannelMsgToHost is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
+func (_e *mockICBMService_Expecter) ChannelMsgToHost(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockICBMService_ChannelMsgToHost_Call {
+	return &mockICBMService_ChannelMsgToHost_Call{Call: _e.mock.On("ChannelMsgToHost", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockICBMService_ChannelMsgToHost_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost)) *mockICBMService_ChannelMsgToHost_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x04_0x06_ICBMChannelMsgToHost))
+	})
+	return _c
+}
+
+func (_c *mockICBMService_ChannelMsgToHost_Call) Return(_a0 *wire.SNACMessage, _a1 error) *mockICBMService_ChannelMsgToHost_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockICBMService_ChannelMsgToHost_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)) *mockICBMService_ChannelMsgToHost_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// ClientErr provides a mock function with given fields: ctx, sess, frame, body
+func (_m *mockICBMService) ClientErr(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x04_0x0B_ICBMClientErr) error {
+	ret := _m.Called(ctx, sess, frame, body)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ClientErr")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x0B_ICBMClientErr) error); ok {
+		r0 = rf(ctx, sess, frame, body)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICBMService_ClientErr_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClientErr'
+type mockICBMService_ClientErr_Call struct {
+	*mock.Call
+}
+
+// ClientErr is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - frame wire.SNACFrame
+//   - body wire.SNAC_0x04_0x0B_ICBMClientErr
+func (_e *mockICBMService_Expecter) ClientErr(ctx interface{}, sess interface{}, frame interface{}, body interface{}) *mockICBMService_ClientErr_Call {
+	return &mockICBMService_ClientErr_Call{Call: _e.mock.On("ClientErr", ctx, sess, frame, body)}
+}
+
+func (_c *mockICBMService_ClientErr_Call) Run(run func(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x04_0x0B_ICBMClientErr)) *mockICBMService_ClientErr_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x04_0x0B_ICBMClientErr))
+	})
+	return _c
+}
+
+func (_c *mockICBMService_ClientErr_Call) Return(_a0 error) *mockICBMService_ClientErr_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICBMService_ClientErr_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x0B_ICBMClientErr) error) *mockICBMService_ClientErr_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// ClientEvent provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockICBMService) ClientEvent(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ClientEvent")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x14_ICBMClientEvent) error); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICBMService_ClientEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClientEvent'
+type mockICBMService_ClientEvent_Call struct {
+	*mock.Call
+}
+
+// ClientEvent is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x04_0x14_ICBMClientEvent
+func (_e *mockICBMService_Expecter) ClientEvent(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockICBMService_ClientEvent_Call {
+	return &mockICBMService_ClientEvent_Call{Call: _e.mock.On("ClientEvent", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockICBMService_ClientEvent_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent)) *mockICBMService_ClientEvent_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x04_0x14_ICBMClientEvent))
+	})
+	return _c
+}
+
+func (_c *mockICBMService_ClientEvent_Call) Return(_a0 error) *mockICBMService_ClientEvent_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICBMService_ClientEvent_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x14_ICBMClientEvent) error) *mockICBMService_ClientEvent_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// EvilRequest provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for EvilRequest")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x08_ICBMEvilRequest) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x08_ICBMEvilRequest) error); ok {
+		r1 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockICBMService_EvilRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EvilRequest'
+type mockICBMService_EvilRequest_Call struct {
+	*mock.Call
+}
+
+// EvilRequest is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x04_0x08_ICBMEvilRequest
+func (_e *mockICBMService_Expecter) EvilRequest(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockICBMService_EvilRequest_Call {
+	return &mockICBMService_EvilRequest_Call{Call: _e.mock.On("EvilRequest", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockICBMService_EvilRequest_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x08_ICBMEvilRequest)) *mockICBMService_EvilRequest_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x04_0x08_ICBMEvilRequest))
+	})
+	return _c
+}
+
+func (_c *mockICBMService_EvilRequest_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockICBMService_EvilRequest_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockICBMService_EvilRequest_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error)) *mockICBMService_EvilRequest_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// ParameterQuery provides a mock function with given fields: ctx, inFrame
+func (_m *mockICBMService) ParameterQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage {
+	ret := _m.Called(ctx, inFrame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ParameterQuery")
+	}
+
+	var r0 wire.SNACMessage
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(ctx, inFrame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	return r0
+}
+
+// mockICBMService_ParameterQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ParameterQuery'
+type mockICBMService_ParameterQuery_Call struct {
+	*mock.Call
+}
+
+// ParameterQuery is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+func (_e *mockICBMService_Expecter) ParameterQuery(ctx interface{}, inFrame interface{}) *mockICBMService_ParameterQuery_Call {
+	return &mockICBMService_ParameterQuery_Call{Call: _e.mock.On("ParameterQuery", ctx, inFrame)}
+}
+
+func (_c *mockICBMService_ParameterQuery_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame)) *mockICBMService_ParameterQuery_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame))
+	})
+	return _c
+}
+
+func (_c *mockICBMService_ParameterQuery_Call) Return(_a0 wire.SNACMessage) *mockICBMService_ParameterQuery_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICBMService_ParameterQuery_Call) RunAndReturn(run func(context.Context, wire.SNACFrame) wire.SNACMessage) *mockICBMService_ParameterQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockICBMService creates a new instance of mockICBMService. 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 newMockICBMService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockICBMService {
+	mock := &mockICBMService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 263 - 0
server/toc/mock_locate_service_test.go

@@ -0,0 +1,263 @@
+// Code generated by mockery v2.51.1. DO NOT EDIT.
+
+package toc
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockLocateService is an autogenerated mock type for the LocateService type
+type mockLocateService struct {
+	mock.Mock
+}
+
+type mockLocateService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockLocateService) EXPECT() *mockLocateService_Expecter {
+	return &mockLocateService_Expecter{mock: &_m.Mock}
+}
+
+// DirInfo provides a mock function with given fields: ctx, inFrame, body
+func (_m *mockLocateService) DirInfo(ctx context.Context, inFrame wire.SNACFrame, body wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, inFrame, body)
+
+	if len(ret) == 0 {
+		panic("no return value specified for DirInfo")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error)); ok {
+		return rf(ctx, inFrame, body)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x02_0x0B_LocateGetDirInfo) wire.SNACMessage); ok {
+		r0 = rf(ctx, inFrame, body)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, wire.SNACFrame, wire.SNAC_0x02_0x0B_LocateGetDirInfo) error); ok {
+		r1 = rf(ctx, inFrame, body)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockLocateService_DirInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DirInfo'
+type mockLocateService_DirInfo_Call struct {
+	*mock.Call
+}
+
+// DirInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inFrame wire.SNACFrame
+//   - body wire.SNAC_0x02_0x0B_LocateGetDirInfo
+func (_e *mockLocateService_Expecter) DirInfo(ctx interface{}, inFrame interface{}, body interface{}) *mockLocateService_DirInfo_Call {
+	return &mockLocateService_DirInfo_Call{Call: _e.mock.On("DirInfo", ctx, inFrame, body)}
+}
+
+func (_c *mockLocateService_DirInfo_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame, body wire.SNAC_0x02_0x0B_LocateGetDirInfo)) *mockLocateService_DirInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame), args[2].(wire.SNAC_0x02_0x0B_LocateGetDirInfo))
+	})
+	return _c
+}
+
+func (_c *mockLocateService_DirInfo_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockLocateService_DirInfo_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockLocateService_DirInfo_Call) RunAndReturn(run func(context.Context, wire.SNACFrame, wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error)) *mockLocateService_DirInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetDirInfo provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockLocateService) SetDirInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetDirInfo")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x09_LocateSetDirInfo) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x09_LocateSetDirInfo) error); ok {
+		r1 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockLocateService_SetDirInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDirInfo'
+type mockLocateService_SetDirInfo_Call struct {
+	*mock.Call
+}
+
+// SetDirInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x02_0x09_LocateSetDirInfo
+func (_e *mockLocateService_Expecter) SetDirInfo(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockLocateService_SetDirInfo_Call {
+	return &mockLocateService_SetDirInfo_Call{Call: _e.mock.On("SetDirInfo", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockLocateService_SetDirInfo_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x09_LocateSetDirInfo)) *mockLocateService_SetDirInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x02_0x09_LocateSetDirInfo))
+	})
+	return _c
+}
+
+func (_c *mockLocateService_SetDirInfo_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockLocateService_SetDirInfo_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockLocateService_SetDirInfo_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error)) *mockLocateService_SetDirInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// SetInfo provides a mock function with given fields: ctx, sess, inBody
+func (_m *mockLocateService) SetInfo(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x02_0x04_LocateSetInfo) error {
+	ret := _m.Called(ctx, sess, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetInfo")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNAC_0x02_0x04_LocateSetInfo) error); ok {
+		r0 = rf(ctx, sess, inBody)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockLocateService_SetInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetInfo'
+type mockLocateService_SetInfo_Call struct {
+	*mock.Call
+}
+
+// SetInfo is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inBody wire.SNAC_0x02_0x04_LocateSetInfo
+func (_e *mockLocateService_Expecter) SetInfo(ctx interface{}, sess interface{}, inBody interface{}) *mockLocateService_SetInfo_Call {
+	return &mockLocateService_SetInfo_Call{Call: _e.mock.On("SetInfo", ctx, sess, inBody)}
+}
+
+func (_c *mockLocateService_SetInfo_Call) Run(run func(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x02_0x04_LocateSetInfo)) *mockLocateService_SetInfo_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNAC_0x02_0x04_LocateSetInfo))
+	})
+	return _c
+}
+
+func (_c *mockLocateService_SetInfo_Call) Return(_a0 error) *mockLocateService_SetInfo_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockLocateService_SetInfo_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNAC_0x02_0x04_LocateSetInfo) error) *mockLocateService_SetInfo_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UserInfoQuery provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockLocateService) UserInfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UserInfoQuery")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x05_LocateUserInfoQuery) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x05_LocateUserInfoQuery) error); ok {
+		r1 = rf(ctx, sess, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockLocateService_UserInfoQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UserInfoQuery'
+type mockLocateService_UserInfoQuery_Call struct {
+	*mock.Call
+}
+
+// UserInfoQuery is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery
+func (_e *mockLocateService_Expecter) UserInfoQuery(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockLocateService_UserInfoQuery_Call {
+	return &mockLocateService_UserInfoQuery_Call{Call: _e.mock.On("UserInfoQuery", ctx, sess, inFrame, inBody)}
+}
+
+func (_c *mockLocateService_UserInfoQuery_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery)) *mockLocateService_UserInfoQuery_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x02_0x05_LocateUserInfoQuery))
+	})
+	return _c
+}
+
+func (_c *mockLocateService_UserInfoQuery_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockLocateService_UserInfoQuery_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockLocateService_UserInfoQuery_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error)) *mockLocateService_UserInfoQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockLocateService creates a new instance of mockLocateService. 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 newMockLocateService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockLocateService {
+	mock := &mockLocateService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 194 - 0
server/toc/mock_oservice_service_test.go

@@ -0,0 +1,194 @@
+// Code generated by mockery v2.51.1. DO NOT EDIT.
+
+package toc
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockOServiceService is an autogenerated mock type for the OServiceService type
+type mockOServiceService struct {
+	mock.Mock
+}
+
+type mockOServiceService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockOServiceService) EXPECT() *mockOServiceService_Expecter {
+	return &mockOServiceService_Expecter{mock: &_m.Mock}
+}
+
+// ClientOnline provides a mock function with given fields: ctx, _a1, sess
+func (_m *mockOServiceService) ClientOnline(ctx context.Context, _a1 wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error {
+	ret := _m.Called(ctx, _a1, sess)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ClientOnline")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x01_0x02_OServiceClientOnline, *state.Session) error); ok {
+		r0 = rf(ctx, _a1, sess)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockOServiceService_ClientOnline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClientOnline'
+type mockOServiceService_ClientOnline_Call struct {
+	*mock.Call
+}
+
+// ClientOnline is a helper method to define mock.On call
+//   - ctx context.Context
+//   - _a1 wire.SNAC_0x01_0x02_OServiceClientOnline
+//   - sess *state.Session
+func (_e *mockOServiceService_Expecter) ClientOnline(ctx interface{}, _a1 interface{}, sess interface{}) *mockOServiceService_ClientOnline_Call {
+	return &mockOServiceService_ClientOnline_Call{Call: _e.mock.On("ClientOnline", ctx, _a1, sess)}
+}
+
+func (_c *mockOServiceService_ClientOnline_Call) Run(run func(ctx context.Context, _a1 wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session)) *mockOServiceService_ClientOnline_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNAC_0x01_0x02_OServiceClientOnline), args[2].(*state.Session))
+	})
+	return _c
+}
+
+func (_c *mockOServiceService_ClientOnline_Call) Return(_a0 error) *mockOServiceService_ClientOnline_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockOServiceService_ClientOnline_Call) RunAndReturn(run func(context.Context, wire.SNAC_0x01_0x02_OServiceClientOnline, *state.Session) error) *mockOServiceService_ClientOnline_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// IdleNotification provides a mock function with given fields: ctx, sess, bodyIn
+func (_m *mockOServiceService) IdleNotification(ctx context.Context, sess *state.Session, bodyIn wire.SNAC_0x01_0x11_OServiceIdleNotification) error {
+	ret := _m.Called(ctx, sess, bodyIn)
+
+	if len(ret) == 0 {
+		panic("no return value specified for IdleNotification")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNAC_0x01_0x11_OServiceIdleNotification) error); ok {
+		r0 = rf(ctx, sess, bodyIn)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockOServiceService_IdleNotification_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IdleNotification'
+type mockOServiceService_IdleNotification_Call struct {
+	*mock.Call
+}
+
+// IdleNotification is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - bodyIn wire.SNAC_0x01_0x11_OServiceIdleNotification
+func (_e *mockOServiceService_Expecter) IdleNotification(ctx interface{}, sess interface{}, bodyIn interface{}) *mockOServiceService_IdleNotification_Call {
+	return &mockOServiceService_IdleNotification_Call{Call: _e.mock.On("IdleNotification", ctx, sess, bodyIn)}
+}
+
+func (_c *mockOServiceService_IdleNotification_Call) Run(run func(ctx context.Context, sess *state.Session, bodyIn wire.SNAC_0x01_0x11_OServiceIdleNotification)) *mockOServiceService_IdleNotification_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNAC_0x01_0x11_OServiceIdleNotification))
+	})
+	return _c
+}
+
+func (_c *mockOServiceService_IdleNotification_Call) Return(_a0 error) *mockOServiceService_IdleNotification_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockOServiceService_IdleNotification_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNAC_0x01_0x11_OServiceIdleNotification) error) *mockOServiceService_IdleNotification_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// ServiceRequest provides a mock function with given fields: ctx, sess, frame, bodyIn
+func (_m *mockOServiceService) ServiceRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, frame, bodyIn)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ServiceRequest")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest) (wire.SNACMessage, error)); ok {
+		return rf(ctx, sess, frame, bodyIn)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest) wire.SNACMessage); ok {
+		r0 = rf(ctx, sess, frame, bodyIn)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest) error); ok {
+		r1 = rf(ctx, sess, frame, bodyIn)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockOServiceService_ServiceRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServiceRequest'
+type mockOServiceService_ServiceRequest_Call struct {
+	*mock.Call
+}
+
+// ServiceRequest is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - frame wire.SNACFrame
+//   - bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest
+func (_e *mockOServiceService_Expecter) ServiceRequest(ctx interface{}, sess interface{}, frame interface{}, bodyIn interface{}) *mockOServiceService_ServiceRequest_Call {
+	return &mockOServiceService_ServiceRequest_Call{Call: _e.mock.On("ServiceRequest", ctx, sess, frame, bodyIn)}
+}
+
+func (_c *mockOServiceService_ServiceRequest_Call) Run(run func(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest)) *mockOServiceService_ServiceRequest_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x01_0x04_OServiceServiceRequest))
+	})
+	return _c
+}
+
+func (_c *mockOServiceService_ServiceRequest_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockOServiceService_ServiceRequest_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockOServiceService_ServiceRequest_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest) (wire.SNACMessage, error)) *mockOServiceService_ServiceRequest_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockOServiceService creates a new instance of mockOServiceService. 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 newMockOServiceService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockOServiceService {
+	mock := &mockOServiceService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 278 - 0
server/toc/mock_permit_deny_service_test.go

@@ -0,0 +1,278 @@
+// Code generated by mockery v2.51.1. DO NOT EDIT.
+
+package toc
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockPermitDenyService is an autogenerated mock type for the PermitDenyService type
+type mockPermitDenyService struct {
+	mock.Mock
+}
+
+type mockPermitDenyService_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockPermitDenyService) EXPECT() *mockPermitDenyService_Expecter {
+	return &mockPermitDenyService_Expecter{mock: &_m.Mock}
+}
+
+// AddDenyListEntries provides a mock function with given fields: ctx, sess, body
+func (_m *mockPermitDenyService) AddDenyListEntries(ctx context.Context, sess *state.Session, body wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries) error {
+	ret := _m.Called(ctx, sess, body)
+
+	if len(ret) == 0 {
+		panic("no return value specified for AddDenyListEntries")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries) error); ok {
+		r0 = rf(ctx, sess, body)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockPermitDenyService_AddDenyListEntries_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddDenyListEntries'
+type mockPermitDenyService_AddDenyListEntries_Call struct {
+	*mock.Call
+}
+
+// AddDenyListEntries is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - body wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries
+func (_e *mockPermitDenyService_Expecter) AddDenyListEntries(ctx interface{}, sess interface{}, body interface{}) *mockPermitDenyService_AddDenyListEntries_Call {
+	return &mockPermitDenyService_AddDenyListEntries_Call{Call: _e.mock.On("AddDenyListEntries", ctx, sess, body)}
+}
+
+func (_c *mockPermitDenyService_AddDenyListEntries_Call) Run(run func(ctx context.Context, sess *state.Session, body wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries)) *mockPermitDenyService_AddDenyListEntries_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries))
+	})
+	return _c
+}
+
+func (_c *mockPermitDenyService_AddDenyListEntries_Call) Return(_a0 error) *mockPermitDenyService_AddDenyListEntries_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockPermitDenyService_AddDenyListEntries_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries) error) *mockPermitDenyService_AddDenyListEntries_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// AddPermListEntries provides a mock function with given fields: ctx, sess, body
+func (_m *mockPermitDenyService) AddPermListEntries(ctx context.Context, sess *state.Session, body wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries) error {
+	ret := _m.Called(ctx, sess, body)
+
+	if len(ret) == 0 {
+		panic("no return value specified for AddPermListEntries")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries) error); ok {
+		r0 = rf(ctx, sess, body)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockPermitDenyService_AddPermListEntries_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddPermListEntries'
+type mockPermitDenyService_AddPermListEntries_Call struct {
+	*mock.Call
+}
+
+// AddPermListEntries is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - body wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries
+func (_e *mockPermitDenyService_Expecter) AddPermListEntries(ctx interface{}, sess interface{}, body interface{}) *mockPermitDenyService_AddPermListEntries_Call {
+	return &mockPermitDenyService_AddPermListEntries_Call{Call: _e.mock.On("AddPermListEntries", ctx, sess, body)}
+}
+
+func (_c *mockPermitDenyService_AddPermListEntries_Call) Run(run func(ctx context.Context, sess *state.Session, body wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries)) *mockPermitDenyService_AddPermListEntries_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries))
+	})
+	return _c
+}
+
+func (_c *mockPermitDenyService_AddPermListEntries_Call) Return(_a0 error) *mockPermitDenyService_AddPermListEntries_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockPermitDenyService_AddPermListEntries_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries) error) *mockPermitDenyService_AddPermListEntries_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// DelDenyListEntries provides a mock function with given fields: ctx, sess, body
+func (_m *mockPermitDenyService) DelDenyListEntries(ctx context.Context, sess *state.Session, body wire.SNAC_0x09_0x08_PermitDenyDelDenyListEntries) error {
+	ret := _m.Called(ctx, sess, body)
+
+	if len(ret) == 0 {
+		panic("no return value specified for DelDenyListEntries")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNAC_0x09_0x08_PermitDenyDelDenyListEntries) error); ok {
+		r0 = rf(ctx, sess, body)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockPermitDenyService_DelDenyListEntries_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DelDenyListEntries'
+type mockPermitDenyService_DelDenyListEntries_Call struct {
+	*mock.Call
+}
+
+// DelDenyListEntries is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - body wire.SNAC_0x09_0x08_PermitDenyDelDenyListEntries
+func (_e *mockPermitDenyService_Expecter) DelDenyListEntries(ctx interface{}, sess interface{}, body interface{}) *mockPermitDenyService_DelDenyListEntries_Call {
+	return &mockPermitDenyService_DelDenyListEntries_Call{Call: _e.mock.On("DelDenyListEntries", ctx, sess, body)}
+}
+
+func (_c *mockPermitDenyService_DelDenyListEntries_Call) Run(run func(ctx context.Context, sess *state.Session, body wire.SNAC_0x09_0x08_PermitDenyDelDenyListEntries)) *mockPermitDenyService_DelDenyListEntries_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNAC_0x09_0x08_PermitDenyDelDenyListEntries))
+	})
+	return _c
+}
+
+func (_c *mockPermitDenyService_DelDenyListEntries_Call) Return(_a0 error) *mockPermitDenyService_DelDenyListEntries_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockPermitDenyService_DelDenyListEntries_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNAC_0x09_0x08_PermitDenyDelDenyListEntries) error) *mockPermitDenyService_DelDenyListEntries_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// DelPermListEntries provides a mock function with given fields: ctx, sess, body
+func (_m *mockPermitDenyService) DelPermListEntries(ctx context.Context, sess *state.Session, body wire.SNAC_0x09_0x06_PermitDenyDelPermListEntries) error {
+	ret := _m.Called(ctx, sess, body)
+
+	if len(ret) == 0 {
+		panic("no return value specified for DelPermListEntries")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNAC_0x09_0x06_PermitDenyDelPermListEntries) error); ok {
+		r0 = rf(ctx, sess, body)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockPermitDenyService_DelPermListEntries_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DelPermListEntries'
+type mockPermitDenyService_DelPermListEntries_Call struct {
+	*mock.Call
+}
+
+// DelPermListEntries is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - body wire.SNAC_0x09_0x06_PermitDenyDelPermListEntries
+func (_e *mockPermitDenyService_Expecter) DelPermListEntries(ctx interface{}, sess interface{}, body interface{}) *mockPermitDenyService_DelPermListEntries_Call {
+	return &mockPermitDenyService_DelPermListEntries_Call{Call: _e.mock.On("DelPermListEntries", ctx, sess, body)}
+}
+
+func (_c *mockPermitDenyService_DelPermListEntries_Call) Run(run func(ctx context.Context, sess *state.Session, body wire.SNAC_0x09_0x06_PermitDenyDelPermListEntries)) *mockPermitDenyService_DelPermListEntries_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNAC_0x09_0x06_PermitDenyDelPermListEntries))
+	})
+	return _c
+}
+
+func (_c *mockPermitDenyService_DelPermListEntries_Call) Return(_a0 error) *mockPermitDenyService_DelPermListEntries_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockPermitDenyService_DelPermListEntries_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNAC_0x09_0x06_PermitDenyDelPermListEntries) error) *mockPermitDenyService_DelPermListEntries_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RightsQuery provides a mock function with given fields: _a0, frame
+func (_m *mockPermitDenyService) RightsQuery(_a0 context.Context, frame wire.SNACFrame) wire.SNACMessage {
+	ret := _m.Called(_a0, frame)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RightsQuery")
+	}
+
+	var r0 wire.SNACMessage
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame) wire.SNACMessage); ok {
+		r0 = rf(_a0, frame)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	return r0
+}
+
+// mockPermitDenyService_RightsQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RightsQuery'
+type mockPermitDenyService_RightsQuery_Call struct {
+	*mock.Call
+}
+
+// RightsQuery is a helper method to define mock.On call
+//   - _a0 context.Context
+//   - frame wire.SNACFrame
+func (_e *mockPermitDenyService_Expecter) RightsQuery(_a0 interface{}, frame interface{}) *mockPermitDenyService_RightsQuery_Call {
+	return &mockPermitDenyService_RightsQuery_Call{Call: _e.mock.On("RightsQuery", _a0, frame)}
+}
+
+func (_c *mockPermitDenyService_RightsQuery_Call) Run(run func(_a0 context.Context, frame wire.SNACFrame)) *mockPermitDenyService_RightsQuery_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame))
+	})
+	return _c
+}
+
+func (_c *mockPermitDenyService_RightsQuery_Call) Return(_a0 wire.SNACMessage) *mockPermitDenyService_RightsQuery_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockPermitDenyService_RightsQuery_Call) RunAndReturn(run func(context.Context, wire.SNACFrame) wire.SNACMessage) *mockPermitDenyService_RightsQuery_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockPermitDenyService creates a new instance of mockPermitDenyService. 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 newMockPermitDenyService(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockPermitDenyService {
+	mock := &mockPermitDenyService{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 140 - 0
server/toc/mock_toc_config_store_test.go

@@ -0,0 +1,140 @@
+// Code generated by mockery v2.51.1. DO NOT EDIT.
+
+package toc
+
+import (
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockTOCConfigStore is an autogenerated mock type for the TOCConfigStore type
+type mockTOCConfigStore struct {
+	mock.Mock
+}
+
+type mockTOCConfigStore_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockTOCConfigStore) EXPECT() *mockTOCConfigStore_Expecter {
+	return &mockTOCConfigStore_Expecter{mock: &_m.Mock}
+}
+
+// SetTOCConfig provides a mock function with given fields: user, config
+func (_m *mockTOCConfigStore) SetTOCConfig(user state.IdentScreenName, config string) error {
+	ret := _m.Called(user, config)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetTOCConfig")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName, string) error); ok {
+		r0 = rf(user, config)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockTOCConfigStore_SetTOCConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetTOCConfig'
+type mockTOCConfigStore_SetTOCConfig_Call struct {
+	*mock.Call
+}
+
+// SetTOCConfig is a helper method to define mock.On call
+//   - user state.IdentScreenName
+//   - config string
+func (_e *mockTOCConfigStore_Expecter) SetTOCConfig(user interface{}, config interface{}) *mockTOCConfigStore_SetTOCConfig_Call {
+	return &mockTOCConfigStore_SetTOCConfig_Call{Call: _e.mock.On("SetTOCConfig", user, config)}
+}
+
+func (_c *mockTOCConfigStore_SetTOCConfig_Call) Run(run func(user state.IdentScreenName, config string)) *mockTOCConfigStore_SetTOCConfig_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName), args[1].(string))
+	})
+	return _c
+}
+
+func (_c *mockTOCConfigStore_SetTOCConfig_Call) Return(_a0 error) *mockTOCConfigStore_SetTOCConfig_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockTOCConfigStore_SetTOCConfig_Call) RunAndReturn(run func(state.IdentScreenName, string) error) *mockTOCConfigStore_SetTOCConfig_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// User provides a mock function with given fields: screenName
+func (_m *mockTOCConfigStore) User(screenName state.IdentScreenName) (*state.User, error) {
+	ret := _m.Called(screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for User")
+	}
+
+	var r0 *state.User
+	var r1 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) (*state.User, error)); ok {
+		return rf(screenName)
+	}
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) *state.User); ok {
+		r0 = rf(screenName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*state.User)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(state.IdentScreenName) error); ok {
+		r1 = rf(screenName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockTOCConfigStore_User_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'User'
+type mockTOCConfigStore_User_Call struct {
+	*mock.Call
+}
+
+// User is a helper method to define mock.On call
+//   - screenName state.IdentScreenName
+func (_e *mockTOCConfigStore_Expecter) User(screenName interface{}) *mockTOCConfigStore_User_Call {
+	return &mockTOCConfigStore_User_Call{Call: _e.mock.On("User", screenName)}
+}
+
+func (_c *mockTOCConfigStore_User_Call) Run(run func(screenName state.IdentScreenName)) *mockTOCConfigStore_User_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockTOCConfigStore_User_Call) Return(_a0 *state.User, _a1 error) *mockTOCConfigStore_User_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockTOCConfigStore_User_Call) RunAndReturn(run func(state.IdentScreenName) (*state.User, error)) *mockTOCConfigStore_User_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockTOCConfigStore creates a new instance of mockTOCConfigStore. 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 newMockTOCConfigStore(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockTOCConfigStore {
+	mock := &mockTOCConfigStore{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 373 - 0
server/toc/server.go

@@ -0,0 +1,373 @@
+package toc
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"net"
+	"net/http"
+	"strings"
+	"sync"
+	"time"
+
+	"golang.org/x/sync/errgroup"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// bufferedConn is a wrapper around net.Conn that allows peeking into the
+// incoming connection without consuming data. It is useful for multiplexing
+// TOC/HTTP and TOC/FLAP connections.
+//
+// It embeds net.Conn, so all standard connection methods remain available.
+type bufferedConn struct {
+	r *bufio.Reader
+	net.Conn
+}
+
+// newBufferedConn wraps a net.Conn with buffered reading capabilities.
+func newBufferedConn(c net.Conn) bufferedConn {
+	return bufferedConn{bufio.NewReader(c), c}
+}
+
+// Peek returns the next n bytes from the buffer without advancing the reader.
+// If fewer than n bytes are available, it returns an error.
+func (b bufferedConn) Peek(n int) ([]byte, error) {
+	return b.r.Peek(n)
+}
+
+// Read reads data into p from the buffered connection.
+// It prioritizes buffered data before reading from the underlying connection.
+func (b bufferedConn) Read(p []byte) (int, error) {
+	return b.r.Read(p)
+}
+
+// channelListener is an implementation of net.Listener that accepts connections
+// from a channel instead of a network socket. It is useful for attaching an
+// HTTP service to a connection on the fly.
+type channelListener struct {
+	ch chan net.Conn // Channel used to receive connections.
+}
+
+// Accept waits for and returns the next connection from the channel.
+// If the channel is closed, it returns io.EOF to indicate no more connections.
+func (l *channelListener) Accept() (net.Conn, error) {
+	ch, ok := <-l.ch
+	if !ok {
+		return nil, io.EOF
+	}
+	return ch, nil
+}
+
+// Close closes the listener. Since channelListener does not manage an actual
+// network connection, this is a no-op and always returns nil.
+func (l *channelListener) Close() error {
+	return nil
+}
+
+// Addr returns the network address of the listener.
+// Since channelListener is not bound to a real network address, it returns nil.
+func (l *channelListener) Addr() net.Addr {
+	return nil
+}
+
+// Server implements a TOC protocol server that multiplexes TOC/HTTP and
+// TOC/FLAP requests. It acts as a gateway, forwarding all TOC requests
+// to the OSCAR server for processing.
+type Server struct {
+	BOSProxy   OSCARProxy
+	ListenAddr string
+	Logger     *slog.Logger
+}
+
+func (rt Server) Start(ctx context.Context) error {
+	listener, err := net.Listen("tcp", rt.ListenAddr)
+	if err != nil {
+		return fmt.Errorf("unable to start TOC server: %w", err)
+	}
+
+	rt.Logger.InfoContext(ctx, "starting server", "listen_host", rt.ListenAddr)
+
+	go func() {
+		<-ctx.Done()
+		listener.Close()
+	}()
+
+	httpServer := &http.Server{
+		Handler: rt.BOSProxy.NewServeMux(),
+		BaseContext: func(net.Listener) context.Context {
+			return ctx
+		},
+	}
+
+	httpCh := make(chan net.Conn)
+	defer close(httpCh)
+
+	go func() {
+		_ = httpServer.Serve(&channelListener{ch: httpCh})
+	}()
+
+	wg := sync.WaitGroup{}
+	for {
+		conn, err := listener.Accept()
+		if err != nil {
+			if errors.Is(err, net.ErrClosed) {
+				break
+			}
+			rt.Logger.ErrorContext(ctx, "accept failed", "err", err.Error())
+			continue
+		}
+
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			bufCon := newBufferedConn(conn)
+			b, err := bufCon.Peek(6)
+			if err != nil {
+				rt.Logger.ErrorContext(ctx, "peek failed", "err", err.Error())
+				return
+			}
+			switch {
+			case string(b) == "FLAPON":
+				ctx = context.WithValue(ctx, "ip", conn.RemoteAddr().String())
+				if err := rt.handleTOCOverFLAP(ctx, bufCon); err != nil {
+					rt.Logger.ErrorContext(ctx, "handleTOCOverFLAP failed", "err", err.Error())
+					return
+				}
+			case strings.HasPrefix(string(b), "GET /"):
+				select {
+				case httpCh <- bufCon:
+				case <-ctx.Done():
+					return
+				}
+			}
+		}()
+	}
+
+	if !waitForShutdown(&wg) {
+		rt.Logger.ErrorContext(ctx, "shutdown complete, but connections didn't close cleanly")
+	} else {
+		rt.Logger.InfoContext(ctx, "shutdown complete")
+	}
+
+	return nil
+}
+
+func (rt Server) handleTOCOverFLAP(ctx context.Context, conn io.ReadWriteCloser) error {
+	defer func() {
+		conn.Close()
+	}()
+
+	if err := rt.handshake(conn); err != nil {
+		return fmt.Errorf("handshake failed: %w", err)
+	}
+
+	clientFlap, err := rt.initFLAP(conn)
+	if err != nil {
+		return err
+	}
+
+	sessBOS, err := rt.login(ctx, clientFlap)
+	if err != nil {
+		return fmt.Errorf("rt.login: %w", err)
+	}
+	if sessBOS == nil {
+		return nil // user not found
+	}
+
+	ctx = context.WithValue(ctx, "screenName", sessBOS.IdentScreenName())
+
+	defer rt.BOSProxy.Signout(ctx, sessBOS)
+
+	// messages from TOC client
+	fromCh := make(chan wire.FLAPFrame, 1)
+	// messages to TOC client
+	toCh := make(chan []byte, 2)
+
+	// read in messages from client. when client disconnects, it closes fromCh.
+	go rt.readFromClient(ctx, fromCh, clientFlap)
+
+	g, gCtx := errgroup.WithContext(ctx)
+
+	chatRegistry := NewChatRegistry()
+
+	g.Go(func() error {
+		return rt.BOSProxy.RecvBOS(gCtx, sessBOS, chatRegistry, toCh)
+	})
+	g.Go(func() error {
+		return rt.sendToClient(gCtx, toCh, clientFlap)
+	})
+	g.Go(func() error {
+		return rt.processCommands(gCtx, g.Go, sessBOS, chatRegistry, fromCh, toCh)
+	})
+
+	err = g.Wait()
+	if errors.Is(err, errDisconnect) {
+		err = nil
+	}
+	return err
+}
+
+func (rt Server) processCommands(
+	ctx context.Context,
+	doAsync func(f func() error),
+	sessBOS *state.Session,
+	chatRegistry *ChatRegistry,
+	fromCh <-chan wire.FLAPFrame,
+	toCh chan<- []byte,
+) error {
+
+	for {
+		select {
+		case <-ctx.Done():
+			return nil
+		case clientFrame, ok := <-fromCh:
+			if !ok {
+				return errDisconnect
+			}
+			clientFrame.Payload = bytes.TrimRight(clientFrame.Payload, "\x00") // trim null terminator
+
+			if len(clientFrame.Payload) == 0 {
+				return errors.New("no givenPayload in flapon signal")
+			}
+
+			msg, ok := rt.BOSProxy.RecvClientCmd(ctx, sessBOS, chatRegistry, clientFrame.Payload, toCh, doAsync)
+			if !ok {
+				return nil
+			}
+			if len(msg) > 0 {
+				select {
+				case toCh <- []byte(msg):
+				case <-ctx.Done():
+					return nil
+				}
+			}
+		}
+	}
+	return nil
+}
+
+func (rt Server) sendToClient(ctx context.Context, toClient <-chan []byte, clientFlap *wire.FlapClient) error {
+	for {
+		select {
+		case <-ctx.Done():
+			return nil
+		case msg := <-toClient:
+			if err := clientFlap.SendDataFrame(msg); err != nil {
+				return fmt.Errorf("clientFlap.SendDataFrame: %w", err)
+			}
+			if rt.Logger.Enabled(ctx, slog.LevelDebug) {
+				rt.Logger.DebugContext(ctx, "server response", "command", msg)
+			} else {
+				// just log the command, omit params
+				idx := len(msg)
+				if col := bytes.IndexByte(msg, ':'); col > -1 {
+					idx = col
+				}
+				rt.Logger.InfoContext(ctx, "server response", "command", msg[0:idx])
+			}
+		}
+	}
+}
+
+func (rt Server) login(ctx context.Context, clientFlap *wire.FlapClient) (*state.Session, error) {
+	clientFrame, err := clientFlap.ReceiveFLAP()
+	if err != nil {
+		if errors.Is(err, io.EOF) {
+			return nil, nil
+		}
+		return nil, fmt.Errorf("clientFlap.ReceiveFLAP: %w", err)
+	}
+
+	sessBOS, reply := rt.BOSProxy.Signon(ctx, clientFrame.Payload)
+	for _, m := range reply {
+		if err := clientFlap.SendDataFrame([]byte(m)); err != nil {
+			return nil, fmt.Errorf("clientFlap.SendDataFrame: %w", err)
+		}
+	}
+
+	return sessBOS, nil
+}
+
+func (rt Server) readFromClient(ctx context.Context, msgCh chan<- wire.FLAPFrame, clientFlap *wire.FlapClient) {
+	defer close(msgCh)
+
+	for {
+		clientFrame, err := clientFlap.ReceiveFLAP()
+		if err != nil {
+			if !(errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed)) {
+				rt.Logger.ErrorContext(ctx, "ReceiveFLAP error", "err", err.Error())
+			}
+			break
+		}
+
+		if clientFrame.FrameType == wire.FLAPFrameSignoff {
+			break // client disconnected
+		}
+		if clientFrame.FrameType == wire.FLAPFrameKeepAlive {
+			continue // keep alive heartbeat
+		}
+		if clientFrame.FrameType != wire.FLAPFrameData {
+			rt.Logger.ErrorContext(ctx, "unexpected clientFlap clientFrame type", "type", clientFrame.FrameType)
+			break
+		}
+		msgCh <- clientFrame
+	}
+}
+
+func (rt Server) handshake(clientConn io.ReadWriter) error {
+	reader := bufio.NewReader(clientConn)
+
+	line, _, err := reader.ReadLine()
+	if err != nil {
+		return fmt.Errorf("read line failed: %w", err)
+	}
+	if string(line) != "FLAPON" {
+		return fmt.Errorf("unexpected line: %s", string(line))
+	}
+	line, _, err = reader.ReadLine()
+	if err != nil {
+		return fmt.Errorf("read line failed: %w", err)
+	}
+	return nil
+}
+
+func (rt Server) initFLAP(clientConn io.ReadWriter) (*wire.FlapClient, error) {
+	clientFlap := wire.NewFlapClient(0, clientConn, clientConn)
+
+	if err := clientFlap.SendSignonFrame(nil); err != nil {
+		return nil, fmt.Errorf("send flapon signal failed: %w", err)
+	}
+
+	_, err := clientFlap.ReceiveSignonFrame()
+	if err != nil {
+		return nil, fmt.Errorf("send flapon signal failed: %w", err)
+	}
+
+	return clientFlap, nil
+}
+
+// waitForShutdown returns when either the wg completes or 5 seconds has
+// passed. This is a temporary hack to ensure that the server shuts down even
+// if all the TCP connections do not drain. Return true if the shutdown is
+// clean.
+func waitForShutdown(wg *sync.WaitGroup) bool {
+	ch := make(chan struct{})
+
+	go func() {
+		wg.Wait() // goroutine leak if wg never completes
+		close(ch)
+	}()
+
+	select {
+	case <-ch:
+		return true
+	case <-time.After(time.Second * 5):
+		return false
+	}
+}

+ 1 - 0
server/toc/server_test.go

@@ -0,0 +1 @@
+package toc

+ 90 - 0
server/toc/types.go

@@ -0,0 +1,90 @@
+package toc
+
+import (
+	"context"
+
+	"github.com/google/uuid"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+type BuddyService interface {
+	AddBuddies(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) error
+	BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error
+	DelBuddies(_ context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x05_BuddyDelBuddies) error
+	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+}
+
+type ChatService interface {
+	ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error)
+}
+
+type ChatNavService interface {
+	CreateRoom(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error)
+	ExchangeInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) (wire.SNACMessage, error)
+	RequestChatRights(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+	RequestRoomInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (wire.SNACMessage, error)
+}
+
+type ICBMService interface {
+	ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
+	ClientEvent(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error
+	EvilRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error)
+	ParameterQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+	ClientErr(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x04_0x0B_ICBMClientErr) error
+}
+
+type OServiceService interface {
+	ClientOnline(ctx context.Context, _ wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error
+	IdleNotification(ctx context.Context, sess *state.Session, bodyIn wire.SNAC_0x01_0x11_OServiceIdleNotification) error
+	ServiceRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest) (wire.SNACMessage, error)
+}
+
+type AuthService interface {
+	BUCPChallenge(bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
+	BUCPLogin(bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)
+	FLAPLogin(frame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error)
+	RegisterBOSSession(ctx context.Context, authCookie []byte) (*state.Session, error)
+	RetrieveBOSSession(authCookie []byte) (*state.Session, error)
+	RegisterChatSession(ctx context.Context, authCookie []byte) (*state.Session, error)
+	Signout(ctx context.Context, sess *state.Session)
+	SignoutChat(ctx context.Context, sess *state.Session)
+}
+
+type LocateService interface {
+	SetDirInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error)
+	SetInfo(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x02_0x04_LocateSetInfo) error
+	UserInfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error)
+	DirInfo(ctx context.Context, inFrame wire.SNACFrame, body wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error)
+}
+
+type DirSearchService interface {
+	InfoQuery(_ context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)
+}
+
+type PermitDenyService interface {
+	AddDenyListEntries(ctx context.Context, sess *state.Session, body wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries) error
+	AddPermListEntries(ctx context.Context, sess *state.Session, body wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries) error
+	DelDenyListEntries(ctx context.Context, sess *state.Session, body wire.SNAC_0x09_0x08_PermitDenyDelDenyListEntries) error
+	DelPermListEntries(ctx context.Context, sess *state.Session, body wire.SNAC_0x09_0x06_PermitDenyDelPermListEntries) error
+	RightsQuery(_ context.Context, frame wire.SNACFrame) wire.SNACMessage
+}
+
+// BuddyListRegistry is the interface for keeping track of users with active
+// buddy lists. Once registered, a user becomes visible to other users' buddy
+// lists and vice versa.
+type BuddyListRegistry interface {
+	RegisterBuddyList(user state.IdentScreenName) error
+	UnregisterBuddyList(user state.IdentScreenName) error
+}
+
+type TOCConfigStore interface {
+	SetTOCConfig(user state.IdentScreenName, config string) error
+	User(screenName state.IdentScreenName) (*state.User, error)
+}
+
+type CookieBaker interface {
+	Crack(data []byte) ([]byte, error)
+	Issue(data []byte) ([]byte, error)
+}

+ 0 - 0
state/migrations/0011_toc_config.down.sql


+ 2 - 0
state/migrations/0011_toc_config.up.sql

@@ -0,0 +1,2 @@
+ALTER TABLE users
+	ADD COLUMN tocConfig TEXT NOT NULL DEFAULT '';

+ 13 - 2
state/user.go

@@ -196,9 +196,11 @@ type User struct {
 	ICQNotes ICQUserNotes
 	// ICQWorkInfo contains the user's professional information, including
 	// their workplace address and job-related details.
-	ICQWorkInfo ICQWorkInfo
-
+	ICQWorkInfo      ICQWorkInfo
 	AIMDirectoryInfo AIMNameAndAddr
+	// TOCConfig is the user's saved server-side info (buddy list, etc) for
+	// on the TOC service.
+	TOCConfig string
 }
 
 // AIMNameAndAddr holds name and address AIM directory information.
@@ -404,6 +406,15 @@ func (u *User) ValidateRoastedPass(roastedPass []byte) bool {
 	return bytes.Equal(u.WeakMD5Pass, md5Hash)
 }
 
+// ValidateRoastedTOCPass checks if the provided roasted password matches the MD5
+// hash of the user's actual password. A roasted password is a XOR-obfuscated
+// form of the real password, intended to add a simple layer of security. // todo toc description
+func (u *User) ValidateRoastedTOCPass(roastedPass []byte) bool {
+	clearPass := wire.RoastTOCPassword(roastedPass)
+	md5Hash := wire.WeakMD5PasswordHash(string(clearPass), u.AuthKey) // todo remove string conversion
+	return bytes.Equal(u.WeakMD5Pass, md5Hash)
+}
+
 // HashPassword computes MD5 hashes of the user's password. It computes both
 // weak and strong variants and stores them in the struct.
 func (u *User) HashPassword(passwd string) error {

+ 32 - 4
state/user_store.go

@@ -422,7 +422,8 @@ func (f SQLiteUserStore) queryUsers(whereClause string, queryParams []any) ([]Us
 			aim_city,
 			aim_nickName,
 			aim_zipCode,
-			aim_address
+			aim_address,
+			tocConfig
 		FROM users
 		WHERE %s
 	`
@@ -513,6 +514,7 @@ func (f SQLiteUserStore) queryUsers(whereClause string, queryParams []any) ([]Us
 			&u.AIMDirectoryInfo.NickName,
 			&u.AIMDirectoryInfo.ZIPCode,
 			&u.AIMDirectoryInfo.Address,
+			&u.TOCConfig,
 		)
 		if err != nil {
 			return nil, err
@@ -1855,19 +1857,19 @@ func (f SQLiteUserStore) DeleteKeyword(id uint8) error {
 // Categories
 //
 //	ID: The category ID
-//	Name: The category name
+//	Cookie: The category name
 //	Type: [wire.ODirKeywordCategory]
 //
 // Keywords
 //
 //	ID: The parent category ID
-//	Name: The keyword name
+//	Cookie: The keyword name
 //	Type: [wire.ODirKeyword]
 //
 // Top-level Keywords
 //
 //	ID: 0 (does not have a parent category)
-//	Name: The keyword name
+//	Cookie: The keyword name
 //	Type: [wire.ODirKeyword]
 //
 // Keywords are grouped contiguously by category and preceded by the category
@@ -1944,3 +1946,29 @@ func (f SQLiteUserStore) InterestList() ([]wire.ODirKeywordListItem, error) {
 
 	return list, nil
 }
+
+// SetTOCConfig sets the user's TOC config. The TOC config is the server-side
+// buddy list functionality for TOC. This configuration is not available to
+// OSCAR clients.
+func (f SQLiteUserStore) SetTOCConfig(user IdentScreenName, config string) error {
+	q := `
+		UPDATE users
+		SET tocConfig = ?
+		WHERE identScreenName = ?
+	`
+	res, err := f.db.Exec(q,
+		config,
+		user.String(),
+	)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
+	c, err := res.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("rows affected: %w", err)
+	}
+	if c == 0 {
+		return ErrNoUser
+	}
+	return nil
+}

+ 6 - 6
state/user_store_test.go

@@ -1477,7 +1477,7 @@ func TestSQLiteUserStore_FindByICQName(t *testing.T) {
 		return false
 	}
 
-	t.Run("Find Users by First Name", func(t *testing.T) {
+	t.Run("Find Users by First Cookie", func(t *testing.T) {
 		// Search for users with the first name "John"
 		users, err := f.FindByICQName("John", "", "")
 		assert.NoError(t, err)
@@ -1488,7 +1488,7 @@ func TestSQLiteUserStore_FindByICQName(t *testing.T) {
 		assert.True(t, containsUserWithScreenName(users, user3.IdentScreenName))
 	})
 
-	t.Run("Find Users by Last Name", func(t *testing.T) {
+	t.Run("Find Users by Last Cookie", func(t *testing.T) {
 		// Search for users with the last name "Smith"
 		users, err := f.FindByICQName("", "Smith", "")
 		assert.NoError(t, err)
@@ -1590,7 +1590,7 @@ func TestSQLiteUserStore_FindByDirectoryInfo(t *testing.T) {
 		return false
 	}
 
-	t.Run("Find Users by First Name", func(t *testing.T) {
+	t.Run("Find Users by First Cookie", func(t *testing.T) {
 		// Search for users with the first name "John"
 		users, err := f.FindByAIMNameAndAddr(AIMNameAndAddr{FirstName: "John"})
 		assert.NoError(t, err)
@@ -1601,7 +1601,7 @@ func TestSQLiteUserStore_FindByDirectoryInfo(t *testing.T) {
 		assert.True(t, containsUserWithScreenName(users, user3.IdentScreenName))
 	})
 
-	t.Run("Find Users by Last Name", func(t *testing.T) {
+	t.Run("Find Users by Last Cookie", func(t *testing.T) {
 		// Search for users with the last name "Smith"
 		users, err := f.FindByAIMNameAndAddr(AIMNameAndAddr{LastName: "Smith"})
 		assert.NoError(t, err)
@@ -2206,7 +2206,7 @@ func TestSQLiteUserStore_CreateCategory(t *testing.T) {
 		}
 	})
 
-	t.Run("Duplicate Category Name", func(t *testing.T) {
+	t.Run("Duplicate Category Cookie", func(t *testing.T) {
 		defer func() {
 			assert.NoError(t, os.Remove(testFile))
 		}()
@@ -2403,7 +2403,7 @@ func TestSQLiteUserStore_CreateKeyword(t *testing.T) {
 		assert.ErrorIs(t, err, ErrKeywordCategoryNotFound)
 	})
 
-	t.Run("Duplicate Keyword Name", func(t *testing.T) {
+	t.Run("Duplicate Keyword Cookie", func(t *testing.T) {
 		defer func() {
 			assert.NoError(t, os.Remove(testFile))
 		}()

+ 15 - 0
wire/frames.go

@@ -184,6 +184,21 @@ func (f *FlapClient) SendSNAC(frame SNACFrame, body any) error {
 	return nil
 }
 
+func (f *FlapClient) SendDataFrame(payload []byte) error {
+	flap := FLAPFrame{
+		StartMarker: 42,
+		FrameType:   FLAPFrameData,
+		Sequence:    uint16(f.sequence),
+		Payload:     payload,
+	}
+	if err := MarshalBE(flap, f.w); err != nil {
+		return err
+	}
+
+	f.sequence++
+	return nil
+}
+
 // ReceiveSNAC receives a SNAC message wrapped in a FLAP frame.
 func (f *FlapClient) ReceiveSNAC(frame *SNACFrame, body any) error {
 	flap := FLAPFrame{}

+ 63 - 1
wire/snacs.go

@@ -88,6 +88,7 @@ const (
 	LoginTLVTagsAuthorizationCookie uint16 = 0x06
 	LoginTLVTagsErrorSubcode        uint16 = 0x08
 	LoginTLVTagsPasswordHash        uint16 = 0x25
+	LoginTLVTagsRoastedTOCPassword  uint16 = 0x1337
 )
 
 const (
@@ -399,7 +400,7 @@ type SNAC_0x02_0x0A_LocateSetDirReply struct {
 }
 
 type SNAC_0x02_0x0B_LocateGetDirInfo struct {
-	WatcherScreenNames string `oscar:"len_prefix=uint8"`
+	ScreenName string `oscar:"len_prefix=uint8"`
 }
 
 type SNAC_0x02_0x0C_LocateGetDirReply struct {
@@ -581,6 +582,33 @@ const (
 	ICBMMsgTypeAutoNA   uint8 = 0xEA // Auto not available message
 	ICBMMsgTypeAutoDND  uint8 = 0xEB // Auto do not disturb message
 	ICBMMsgTypeAutoFFC  uint8 = 0xEC // Auto free for chat message
+
+	ICBMRdvTLVTagsRdvChan             uint16 = 0x0001 //	uint16 (word)	ICBM channel on which the rendezvous is to occur
+	ICBMRdvTLVTagsRdvIP               uint16 = 0x0002 //	uint32 (dword)	IP address proposed for the rendezvous
+	ICBMRdvTLVTagsRequesterIP         uint16 = 0x0003 //	uint32 (dword)	IP address of the proposing client
+	ICBMRdvTLVTagsVerifiedIP          uint16 = 0x0004 //	uint32 (dword)	IP address of the proposing client as seen by server; NOTE - this TLV may only be added by the server
+	ICBMRdvTLVTagsPort                uint16 = 0x0005 //	uint16 (word)	Port value of the client for rendezvous
+	ICBMRdvTLVTagsDownloadURL         uint16 = 0x0006 //	string	URL for downloading software to support the service
+	ICBMRdvTLVTagsDownloadURL2        uint16 = 0x0007 //	string	NOT CURRENTLY DOCUMENTED
+	ICBMRdvTLVTagsVerifiedDownloadURL uint16 = 0x0008 //	string	Same as DOWNLOAD_URL, but added by server if the service is well known; Note - this TLV may only be added by the server
+	ICBMRdvTLVTagsSeqNum              uint16 = 0x000A // uint16 (word)	Identifies which proposal this is in the rendezvous conversation; the initial proposal has sequence_num 1; NOTE - this tag is required in *all* rendezvous *proposal* payloads and may only occur in proposal payloads; each proposal applying to a given rendezvous cookie increments the sequence_num by one
+	ICBMRdvTLVTagsCancelReason        uint16 = 0x000B // uint16 (word)	[Class: ICBM__RENDEZVOUS_CANCEL_REASONS] Reason for cancelling a rendezvous; this tag must be present in all RENDEZVOUS_CANCEL payloads.
+	ICBMRdvTLVTagsInvitation          uint16 = 0x000C // string	Text inviting the other player to join
+	ICBMRdvTLVTagsInviteMIMECharset   uint16 = 0x000D // string	Charset used by the data
+	ICBMRdvTLVTagsInviteMIMELang      uint16 = 0x000E // string	Language used by the data
+	ICBMRdvTLVTagsRequestHostChk      uint16 = 0x000F // empty	Requests that the server check caps for recipient
+	ICBMRdvTLVTagsUseARS              uint16 = 0x0010 // empty	Requests that the Rendezvous Server be used as a transport for the data
+	ICBMRdvTLVTagsRequestSecure       uint16 = 0x0011 // empty	Requests that SSL be used for the connection
+	ICBMRdvTLVTagsMaxProtoVersion     uint16 = 0x0012 // uint16 (word)	Maximum application protocol version supported
+	ICBMRdvTLVTagsMinProtoVersion     uint16 = 0x0013 // uint16 (word)	Minimum application protocol version supported
+	ICBMRdvTLVTagsCounterReason       uint16 = 0x0014 // uint16 (word)	Reason for a counter proposal
+	ICBMRdvTLVTagsInviteMIMEType      uint16 = 0x0015 // string	Content-type used by the data
+	ICBMRdvTLVTagsIPXOR               uint16 = 0x0016 // uint32 (dword)	Contains IP_ADDR ^ 0xFFFFFFFF - Used with recent clients to ensure that the IP_ADDR tlv is not tampered with or modified by a NAT since some NATs will change the IP_ADDR tlv when the proposal is sent
+	ICBMRdvTLVTagsPortXOR             uint16 = 0x0017 // uint16 (word)	PORT ^ 0xFFFF - Used with recent clients to ensure that the PORT tlv is not tampered with or modified by a NAT since some NATs will change the PORT tlv when the proposal is sent
+	ICBMRdvTLVTagsAddrList            uint16 = 0x0018 // Array of string08	List of "IP port" pairs to try
+	ICBMRdvTLVTagsSessID              uint16 = 0x0019 // string	Identifier for session
+	ICBMRdvTLVTagsRolloverID          uint16 = 0x001A // string	Identifier of session to rollover
+	ICBMRdvTLVTagsSvcData             uint16 = 0x2711 //	blob	Service specific data
 )
 
 // ICBMCh1Fragment represents an ICBM channel 1 (instant message) message
@@ -591,6 +619,19 @@ type ICBMCh1Fragment struct {
 	Payload []byte `oscar:"len_prefix=uint16"`
 }
 
+type ICBMCh2Fragment struct {
+	Type       uint16
+	Cookie     uint64
+	Capability [16]byte
+	TLVRestBlock
+}
+
+type ICBMRoomInfo struct {
+	Exchange uint16
+	Cookie   string `oscar:"len_prefix=uint8"`
+	Instance uint16
+}
+
 // ICBMCh1Message represents the text component of an ICBM channel 1 (instant
 // message) message.
 type ICBMCh1Message struct {
@@ -1047,6 +1088,22 @@ type SNAC_0x0E_0x06_ChatChannelMsgToClient struct {
 	TLVRestBlock
 }
 
+// UnmarshalChatMessageText extracts message text from a chat message. Param b
+// is a slice from TLV wire.ChatTLVMessageInfo.
+func UnmarshalChatMessageText(b []byte) (string, error) {
+	block := TLVRestBlock{}
+	if err := UnmarshalBE(&block, bytes.NewReader(b)); err != nil {
+		return "", fmt.Errorf("UnmarshalBE: %w", err)
+	}
+
+	b, hasMsg := block.Bytes(ChatTLVMessageInfoText)
+	if !hasMsg {
+		return "", errors.New("SNAC(0x0E,0x05) has no chat msg text TLV")
+	}
+
+	return string(b), nil
+}
+
 //
 // 0x0F: ODir
 //
@@ -1995,6 +2052,11 @@ type TLVUserInfo struct {
 	TLVBlock
 }
 
+func (t TLVUserInfo) IsAway() bool {
+	flags, _ := t.Uint16BE(OServiceUserInfoUserFlags)
+	return flags&OServiceUserFlagUnavailable == OServiceUserFlagUnavailable
+}
+
 type FeedbagItem struct {
 	Name    string `oscar:"len_prefix=uint16"`
 	GroupID uint16

+ 48 - 0
wire/snacs_test.go

@@ -1,6 +1,7 @@
 package wire
 
 import (
+	"bytes"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -99,3 +100,50 @@ func TestSNAC_0x01_0x14_OServiceSetPrivacyFlags_MemberFlag(t *testing.T) {
 		})
 	}
 }
+
+func TestUnmarshalChatMessageText(t *testing.T) {
+	tests := []struct {
+		name    string
+		b       []byte
+		want    string
+		wantErr string
+	}{
+		{
+			name: "happy path",
+			b: func() []byte {
+				tlv := TLVRestBlock{
+					TLVList: TLVList{
+						NewTLVBE(ChatTLVMessageInfoText, "<p>hello world!</p>"),
+					},
+				}
+				b := &bytes.Buffer{}
+				err := MarshalBE(tlv, b)
+				assert.NoError(t, err)
+				return b.Bytes()
+			}(),
+			want: "<p>hello world!</p>",
+		},
+		{
+			name: "missing ChatTLVMessageInfoText",
+			b: func() []byte {
+				tlv := TLVRestBlock{TLVList: TLVList{}}
+				b := &bytes.Buffer{}
+				err := MarshalBE(tlv, b)
+				assert.NoError(t, err)
+				return b.Bytes()
+			}(),
+			wantErr: "has no chat msg text TLV",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := UnmarshalChatMessageText(tt.b)
+			if tt.wantErr != "" {
+				assert.ErrorContains(t, err, tt.wantErr)
+			} else {
+				assert.NoError(t, err)
+				assert.Equal(t, tt.want, got)
+			}
+		})
+	}
+}

+ 12 - 0
wire/user.go

@@ -43,3 +43,15 @@ func RoastPassword(roastedPass []byte) []byte {
 	}
 	return clearPass
 }
+
+// RoastPassword toggles password obfuscation using a roasting algorithm for
+// AIM v1.0-v3.0 auth. The first call obfuscates the password, and the second
+// call de-obfuscates the password, and so on.
+func RoastTOCPassword(roastedPass []byte) []byte {
+	var roastTable = []byte("Tic/Toc")
+	clearPass := make([]byte, len(roastedPass))
+	for i := range roastedPass {
+		clearPass[i] = roastedPass[i] ^ roastTable[i%len(roastTable)]
+	}
+	return clearPass
+}