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

support multi-homed connections, consolidate svc ports

wip

wip

login works!

so far

so far

make snac handler code a lot less fancy

chat + odir works

consolidate handlers

set up listener configuration

fix up TOC service dependencies

refactoring

impl multi-listener tcp server

unit tests

shutdown handlers

shutdown handlers

refactor TOC

fix toc http bug

fix toc shutdown handling

safer toc shutdown handling

fix up oservice tests

fix oservice tests

fix auth service tests

fix TOC tests

fix handler tests

increase test coverage on handlers

increase OSCAR server test coverage

add startup check to ensure proper config migration

reconcile settings.env and ras.service

document new connection settings
Mike 11 месяцев назад
Родитель
Сommit
c94576abeb
96 измененных файлов с 10610 добавлено и 8857 удалено
  1. 53 44
      .mockery.yaml
  2. 178 348
      cmd/server/factory.go
  3. 27 23
      cmd/server/main.go
  4. 85 17
      config/config.go
  5. 5 12
      config/ras.service
  6. 59 49
      config/settings.env
  7. 47 72
      foodgroup/auth.go
  8. 108 224
      foodgroup/auth_test.go
  9. 217 308
      foodgroup/oservice.go
  10. 348 170
      foodgroup/oservice_test.go
  11. 15 42
      server/http/mgmt_api.go
  12. 122 0
      server/kerberos/kerberos.go
  13. 31 9
      server/kerberos/kerberos_test.go
  14. 98 0
      server/kerberos/mock_auth_test.go
  15. 0 224
      server/oscar/admin.go
  16. 0 284
      server/oscar/auth.go
  17. 0 118
      server/oscar/auth_test.go
  18. 0 216
      server/oscar/bos.go
  19. 0 113
      server/oscar/bos_test.go
  20. 0 117
      server/oscar/chat.go
  21. 0 94
      server/oscar/chat_test.go
  22. 0 151
      server/oscar/connection.go
  23. 0 222
      server/oscar/connection_test.go
  24. 1083 0
      server/oscar/handler.go
  25. 0 68
      server/oscar/handler/admin.go
  26. 0 140
      server/oscar/handler/admin_test.go
  27. 0 36
      server/oscar/handler/alert.go
  28. 0 45
      server/oscar/handler/alert_test.go
  29. 0 57
      server/oscar/handler/bart.go
  30. 0 86
      server/oscar/handler/bart_test.go
  31. 0 60
      server/oscar/handler/buddy.go
  32. 0 122
      server/oscar/handler/buddy_test.go
  33. 0 47
      server/oscar/handler/chat.go
  34. 0 81
      server/oscar/handler/chat_nav.go
  35. 0 195
      server/oscar/handler/chat_nav_test.go
  36. 0 79
      server/oscar/handler/chat_test.go
  37. 0 141
      server/oscar/handler/feedbag.go
  38. 0 372
      server/oscar/handler/feedbag_test.go
  39. 0 96
      server/oscar/handler/icbm.go
  40. 0 197
      server/oscar/handler/icbm_test.go
  41. 0 262
      server/oscar/handler/icq.go
  42. 0 1026
      server/oscar/handler/icq_test.go
  43. 0 122
      server/oscar/handler/locate.go
  44. 0 308
      server/oscar/handler/locate_test.go
  45. 0 53
      server/oscar/handler/odir.go
  46. 0 96
      server/oscar/handler/odir_test.go
  47. 0 133
      server/oscar/handler/oservice.go
  48. 0 501
      server/oscar/handler/oservice_test.go
  49. 0 136
      server/oscar/handler/permit_deny.go
  50. 0 201
      server/oscar/handler/permit_deny_test.go
  51. 0 205
      server/oscar/handler/routes.go
  52. 0 42
      server/oscar/handler/stats.go
  53. 0 46
      server/oscar/handler/stats_test.go
  54. 0 43
      server/oscar/handler/user_lookup.go
  55. 0 54
      server/oscar/handler/user_lookup_test.go
  56. 5330 0
      server/oscar/handler_test.go
  57. 0 110
      server/oscar/kerberos.go
  58. 1 1
      server/oscar/mock_admin_service_test.go
  59. 125 66
      server/oscar/mock_auth_test.go
  60. 1 1
      server/oscar/mock_bart_service_test.go
  61. 177 0
      server/oscar/mock_buddy_list_registry_test.go
  62. 1 1
      server/oscar/mock_buddy_service_test.go
  63. 1 1
      server/oscar/mock_chat_nav_service_test.go
  64. 1 1
      server/oscar/mock_chat_service_test.go
  65. 68 0
      server/oscar/mock_chat_session_manager_test.go
  66. 84 0
      server/oscar/mock_departure_notifier_test.go
  67. 1 1
      server/oscar/mock_feedbag_service_test.go
  68. 0 91
      server/oscar/mock_handler_test.go
  69. 1 1
      server/oscar/mock_icbm_service_test.go
  70. 1 1
      server/oscar/mock_icq_service_test.go
  71. 1 1
      server/oscar/mock_locate_service_test.go
  72. 1 1
      server/oscar/mock_odir_service_test.go
  73. 11 10
      server/oscar/mock_online_notifier_test.go
  74. 39 35
      server/oscar/mock_oservice_service_test.go
  75. 1 1
      server/oscar/mock_permit_deny_service_test.go
  76. 2 3
      server/oscar/mock_response_writer_test.go
  77. 1 1
      server/oscar/mock_stats_service_test.go
  78. 1 1
      server/oscar/mock_user_lookup_service_test.go
  79. 0 73
      server/oscar/router.go
  80. 0 57
      server/oscar/router_test.go
  81. 645 0
      server/oscar/server.go
  82. 601 0
      server/oscar/server_test.go
  83. 170 0
      server/oscar/types.go
  84. 35 27
      server/toc/cmd_client.go
  85. 429 251
      server/toc/cmd_client_test.go
  86. 6 0
      server/toc/cmd_server.go
  87. 36 20
      server/toc/cmd_server_test.go
  88. 10 4
      server/toc/helpers_test.go
  89. 110 52
      server/toc/mock_auth_service_test.go
  90. 28 25
      server/toc/mock_oservice_service_test.go
  91. 161 87
      server/toc/server.go
  92. 15 19
      server/toc/server_test.go
  93. 8 7
      server/toc/types.go
  94. 9 0
      state/cookie.go
  95. 21 0
      wire/frames.go
  96. 1 0
      wire/snacs.go

+ 53 - 44
.mockery.yaml

@@ -14,58 +14,21 @@ packages:
       OnlineNotifier:
         config:
           filename: "mock_online_notifier_test.go"
-      ResponseWriter:
-        config:
-          inpackage: False
-          filename: "mock_response_writer_test.go"
-          dir: "./server/oscar/handler"
-          outpkg: "handler"
       UserManager:
         config:
           filename: "mock_user_manager_test.go"
       RateLimitUpdater:
         config:
           filename: "mock_rate_limit_updater_test.go"
-  github.com/mk6i/retro-aim-server/server/http:
-    interfaces:
-      AccountManager:
-        config:
-          filename: "mock_account_manager_test.go"
-      AccountRetriever:
-        config:
-          filename: "mock_account_retriever_test.go"
-      BuddyIconRetriever:
-        config:
-          filename: "mock_buddy_icon_retriever_test.go"
-      ChatRoomCreator:
-        config:
-          filename: "mock_chat_room_creator_test.go"
-      ChatRoomRetriever:
-        config:
-          filename: "mock_chat_room_retriever_test.go"
-      ChatSessionRetriever:
-        config:
-          filename: "mock_chat_session_retriever_test.go"
-      DirectoryManager:
-        config:
-          filename: "mock_directory_manager_test.go"
-      FeedBagRetriever:
-        config:
-          filename: "mock_feedbag_retriever_test.go"
-      MessageRelayer:
-        config:
-          filename: "mock_message_relayer_test.go"
-      ProfileRetriever:
+      BuddyListRegistry:
         config:
-          filename: "mock_profile_retriever_test.go"
-      SessionRetriever:
+          filename: "mock_buddy_list_registry_test.go"
+      DepartureNotifier:
         config:
-          filename: "mock_session_retriever_test.go"
-      UserManager:
+          filename: "mock_departure_notifier_test.go"
+      ChatSessionManager:
         config:
-          filename: "mock_user_manager_test.go"
-  github.com/mk6i/retro-aim-server/server/oscar/handler:
-    interfaces:
+          filename: "mock_chat_session_manager_test.go"
       AdminService:
         config:
           filename: "mock_admin_service_test.go"
@@ -102,12 +65,53 @@ packages:
       PermitDenyService:
         config:
           filename: "mock_permit_deny_service_test.go"
+      ResponseWriter:
+        config:
+          filename: "mock_response_writer_test.go"
       StatsService:
         config:
           filename: "mock_stats_service_test.go"
       UserLookupService:
         config:
           filename: "mock_user_lookup_service_test.go"
+  github.com/mk6i/retro-aim-server/server/http:
+    interfaces:
+      AccountManager:
+        config:
+          filename: "mock_account_manager_test.go"
+      AccountRetriever:
+        config:
+          filename: "mock_account_retriever_test.go"
+      BuddyIconRetriever:
+        config:
+          filename: "mock_buddy_icon_retriever_test.go"
+      ChatRoomCreator:
+        config:
+          filename: "mock_chat_room_creator_test.go"
+      ChatRoomRetriever:
+        config:
+          filename: "mock_chat_room_retriever_test.go"
+      ChatSessionRetriever:
+        config:
+          filename: "mock_chat_session_retriever_test.go"
+      DirectoryManager:
+        config:
+          filename: "mock_directory_manager_test.go"
+      FeedBagRetriever:
+        config:
+          filename: "mock_feedbag_retriever_test.go"
+      MessageRelayer:
+        config:
+          filename: "mock_message_relayer_test.go"
+      ProfileRetriever:
+        config:
+          filename: "mock_profile_retriever_test.go"
+      SessionRetriever:
+        config:
+          filename: "mock_session_retriever_test.go"
+      UserManager:
+        config:
+          filename: "mock_user_manager_test.go"
   github.com/mk6i/retro-aim-server/foodgroup:
     interfaces:
       AccountManager:
@@ -207,4 +211,9 @@ packages:
           filename: "mock_toc_config_store_test.go"
       CookieBaker:
         config:
-          filename: "mock_cookie_baker_test.go"
+          filename: "mock_cookie_baker_test.go"
+  github.com/mk6i/retro-aim-server/server/kerberos:
+    interfaces:
+      AuthService:
+        config:
+          filename: "mock_auth_test.go"

+ 178 - 348
cmd/server/factory.go

@@ -1,10 +1,12 @@
 package main
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"log/slog"
-	"net"
+	"os"
+	"strings"
 	"time"
 
 	"github.com/kelseyhightower/envconfig"
@@ -13,8 +15,8 @@ import (
 	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/foodgroup"
 	"github.com/mk6i/retro-aim-server/server/http"
+	"github.com/mk6i/retro-aim-server/server/kerberos"
 	"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"
@@ -31,21 +33,25 @@ type Container struct {
 	rateLimitClasses       wire.RateLimitClasses
 	snacRateLimits         wire.SNACRateLimits
 	sqLiteUserStore        *state.SQLiteUserStore
+	Listeners              []config.Listener
 }
 
 // MakeCommonDeps creates common dependencies used by the food group services.
 func MakeCommonDeps() (Container, error) {
 	c := Container{}
 
+	if err := validateConfigMigration(); err != nil {
+		return c, fmt.Errorf("unable to validate config migration: %s\n", err.Error())
+	}
+
 	err := envconfig.Process("", &c.cfg)
 	if err != nil {
 		return c, fmt.Errorf("unable to process app config: %s\n", err.Error())
 	}
 
-	if c.cfg.OSCARHost == "0.0.0.0" {
-		return c, errors.New("invalid config: OSCAR_HOST cannot be set to " +
-			"the 'all interfaces' IP (0.0.0.0). it must be a specific IP " +
-			"address or hostname reachable by AIM/ICQ clients")
+	c.Listeners, err = config.ParseListenersCfg(c.cfg.BOSListeners, c.cfg.BOSAdvertisedHosts, c.cfg.KerberosListeners)
+	if err != nil {
+		return c, fmt.Errorf("unable to parse listener config: %s\n", err.Error())
 	}
 
 	c.sqLiteUserStore, err = state.NewSQLiteUserStore(c.cfg.DBPath)
@@ -66,183 +72,146 @@ func MakeCommonDeps() (Container, error) {
 	return c, nil
 }
 
-// Admin creates an OSCAR server for the Admin food group.
-func Admin(deps Container) oscar.AdminServer {
-	logger := deps.logger.With("svc", "ADMIN")
-
-	adminService := foodgroup.NewAdminService(
-		deps.sqLiteUserStore,
-		deps.sqLiteUserStore,
-		deps.sqLiteUserStore,
-		deps.inMemorySessionManager,
-		deps.inMemorySessionManager,
-		deps.logger,
-	)
-	authService := foodgroup.NewAuthService(
-		deps.cfg,
-		deps.inMemorySessionManager,
-		deps.chatSessionManager,
-		deps.sqLiteUserStore,
-		deps.hmacCookieBaker,
-		deps.chatSessionManager,
-		deps.sqLiteUserStore,
-		deps.inMemorySessionManager,
-		deps.rateLimitClasses,
-	)
-	oServiceService := foodgroup.NewOServiceServiceForAdmin(
-		deps.cfg,
-		logger,
-		deps.inMemorySessionManager,
-		deps.sqLiteUserStore,
-		deps.inMemorySessionManager,
-		deps.sqLiteUserStore,
-		deps.rateLimitClasses,
-		deps.snacRateLimits,
-	)
+func validateConfigMigration() error {
+	// Old environment variables that should be removed
+	oldEnvVars := []string{
+		"API_HOST",
+		"API_PORT",
+		"KERBEROS_PORT",
+		"ALERT_PORT",
+		"AUTH_PORT",
+		"BART_PORT",
+		"BOS_PORT",
+		"CHAT_NAV_PORT",
+		"CHAT_PORT",
+		"ADMIN_PORT",
+		"ODIR_PORT",
+		"OSCAR_HOST",
+		"TOC_HOST",
+		"TOC_PORT",
+	}
 
-	return oscar.AdminServer{
-		AuthService: authService,
-		Config:      deps.cfg,
-		Handler: handler.NewAdminRouter(handler.Handlers{
-			AdminHandler:    handler.NewAdminHandler(logger, adminService),
-			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
-		}),
-		Logger:           logger,
-		OnlineNotifier:   oServiceService,
-		ListenAddr:       net.JoinHostPort("", deps.cfg.AdminPort),
-		RateLimitUpdater: oServiceService,
-		SNACRateLimits:   deps.snacRateLimits,
+	// New environment variables that should be present
+	newEnvVars := []string{
+		"API_LISTENER",
+		"OSCAR_ADVERTISED_LISTENERS",
+		"OSCAR_LISTENERS",
+		"KERBEROS_LISTENERS",
+		"TOC_LISTENERS",
 	}
-}
 
-// Alert creates an OSCAR server for the Alert food group.
-func Alert(deps Container) oscar.BOSServer {
-	logger := deps.logger.With("svc", "ALERT")
+	var oldEnvVarsFound []string
+	var newEnvVarsMissing []string
 
-	sessionManager := state.NewInMemorySessionManager(logger)
-	authService := foodgroup.NewAuthService(
-		deps.cfg,
-		sessionManager,
-		deps.chatSessionManager,
-		deps.sqLiteUserStore,
-		deps.hmacCookieBaker,
-		deps.chatSessionManager,
-		deps.sqLiteUserStore,
-		nil,
-		deps.rateLimitClasses,
-	)
-	oServiceService := foodgroup.NewOServiceServiceForAlert(
-		deps.cfg,
-		logger,
-		sessionManager,
-		deps.sqLiteUserStore,
-		sessionManager,
-		deps.sqLiteUserStore,
-		deps.rateLimitClasses,
-		deps.snacRateLimits,
-	)
+	// Check for old environment variables that should be removed
+	for _, envVar := range oldEnvVars {
+		if os.Getenv(envVar) != "" {
+			oldEnvVarsFound = append(oldEnvVarsFound, envVar)
+		}
+	}
 
-	return oscar.BOSServer{
-		AuthService: authService,
-		Config:      deps.cfg,
-		Handler: handler.NewAlertRouter(handler.Handlers{
-			AlertHandler:    handler.NewAlertHandler(logger),
-			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
-		}),
-		Logger:           logger,
-		OnlineNotifier:   oServiceService,
-		ListenAddr:       net.JoinHostPort("", deps.cfg.AlertPort),
-		RateLimitUpdater: oServiceService,
-		SNACRateLimits:   deps.snacRateLimits,
+	// Check for new environment variables that should be present
+	for _, envVar := range newEnvVars {
+		if os.Getenv(envVar) == "" {
+			newEnvVarsMissing = append(newEnvVarsMissing, envVar)
+		}
 	}
-}
 
-// Auth creates an OSCAR server for the Auth food group.
-func Auth(deps Container) oscar.AuthServer {
-	logger := deps.logger.With("svc", "AUTH")
+	// If there are any issues, return an error with details
+	if len(oldEnvVarsFound) > 0 || len(newEnvVarsMissing) > 0 {
+		var errorMsg strings.Builder
+		errorMsg.WriteString("Retro AIM Server v0.19.0 introduced some breaking configuration changes that you need to fix.\n")
+
+		if len(oldEnvVarsFound) > 0 {
+			errorMsg.WriteString("\nOld environment variables that must be removed:\n\n")
+			for _, envVar := range oldEnvVarsFound {
+				errorMsg.WriteString(fmt.Sprintf("  - %s\n", envVar))
+			}
+		}
+
+		if len(newEnvVarsMissing) > 0 {
+			errorMsg.WriteString("\nNew environment variables that must be provided:\n\n")
+			for _, envVar := range newEnvVarsMissing {
+				errorMsg.WriteString(fmt.Sprintf("  - %s\n", envVar))
+			}
+
+			// Generate export commands based on old environment variables
+			errorMsg.WriteString("\nCopy/paste this updated configuration into your settings file:\n\n")
+
+			if contains(newEnvVarsMissing, "API_LISTENER") {
+				apiHost := getEnvOrDefault("API_HOST", "127.0.0.1")
+				apiPort := getEnvOrDefault("API_PORT", "8080")
+				errorMsg.WriteString(fmt.Sprintf("export API_LISTENER=%s:%s\n", apiHost, apiPort))
+			}
+
+			if contains(newEnvVarsMissing, "OSCAR_ADVERTISED_LISTENERS") {
+				oscarHost := getEnvOrDefault("OSCAR_HOST", "127.0.0.1")
+				authPort := getEnvOrDefault("AUTH_PORT", "5190")
+				errorMsg.WriteString(fmt.Sprintf("export OSCAR_ADVERTISED_LISTENERS=EXTERNAL://%s:%s\n", oscarHost, authPort))
+			}
+
+			if contains(newEnvVarsMissing, "OSCAR_LISTENERS") {
+				authPort := getEnvOrDefault("AUTH_PORT", "5190")
+				errorMsg.WriteString(fmt.Sprintf("export OSCAR_LISTENERS=EXTERNAL://0.0.0.0:%s\n", authPort))
+			}
+
+			if contains(newEnvVarsMissing, "KERBEROS_LISTENERS") {
+				kerberosPort := getEnvOrDefault("KERBEROS_PORT", "1088")
+				errorMsg.WriteString(fmt.Sprintf("export KERBEROS_LISTENERS=EXTERNAL://0.0.0.0:%s\n", kerberosPort))
+			}
+
+			if contains(newEnvVarsMissing, "TOC_LISTENERS") {
+				tocHost := getEnvOrDefault("TOC_HOST", "0.0.0.0")
+				tocPort := getEnvOrDefault("TOC_PORT", "9898")
+				errorMsg.WriteString(fmt.Sprintf("export TOC_LISTENERS=%s:%s\n", tocHost, tocPort))
+			}
+		}
+
+		return errors.New(errorMsg.String())
+	}
 
-	authHandler := foodgroup.NewAuthService(
-		deps.cfg,
-		deps.inMemorySessionManager,
-		deps.chatSessionManager,
-		deps.sqLiteUserStore,
-		deps.hmacCookieBaker,
-		deps.chatSessionManager,
-		deps.sqLiteUserStore,
-		nil,
-		deps.rateLimitClasses,
-	)
+	return nil
+}
 
-	return oscar.AuthServer{
-		AuthService:   authHandler,
-		Config:        deps.cfg,
-		Logger:        logger,
-		IPRateLimiter: oscar.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
+// Helper function to check if a slice contains a string
+func contains(slice []string, item string) bool {
+	for _, s := range slice {
+		if s == item {
+			return true
+		}
 	}
+	return false
 }
 
-// BART creates an OSCAR server for the BART food group.
-func BART(deps Container) oscar.BOSServer {
-	logger := deps.logger.With("svc", "BART")
+// Helper function to get environment variable or return default
+func getEnvOrDefault(key, defaultValue string) string {
+	if value := os.Getenv(key); value != "" {
+		return value
+	}
+	return defaultValue
+}
 
-	sessionManager := state.NewInMemorySessionManager(logger)
-	bartService := foodgroup.NewBARTService(
-		logger,
-		deps.sqLiteUserStore,
-		sessionManager,
-		deps.sqLiteUserStore,
-		sessionManager,
-	)
-	authService := foodgroup.NewAuthService(
-		deps.cfg,
-		sessionManager,
-		deps.chatSessionManager,
-		deps.sqLiteUserStore,
-		deps.hmacCookieBaker,
-		deps.chatSessionManager,
+// OSCAR creates an OSCAR server for the OSCAR food group.
+func OSCAR(deps Container) *oscar.Server {
+	logger := deps.logger.With("svc", "OSCAR")
+
+	adminService := foodgroup.NewAdminService(
 		deps.sqLiteUserStore,
-		nil,
-		deps.rateLimitClasses,
-	)
-	oServiceService := foodgroup.NewOServiceServiceForBART(
-		deps.cfg,
-		logger,
-		sessionManager,
 		deps.sqLiteUserStore,
-		sessionManager,
 		deps.sqLiteUserStore,
-		deps.rateLimitClasses,
-		deps.snacRateLimits,
+		deps.inMemorySessionManager,
+		deps.inMemorySessionManager,
+		deps.logger,
 	)
-
-	return oscar.BOSServer{
-		AuthService: authService,
-		Config:      deps.cfg,
-		Handler: handler.NewBARTRouter(handler.Handlers{
-			BARTHandler:     handler.NewBARTHandler(logger, bartService),
-			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
-		}),
-		ListenAddr:       net.JoinHostPort("", deps.cfg.BARTPort),
-		Logger:           logger,
-		OnlineNotifier:   oServiceService,
-		RateLimitUpdater: oServiceService,
-		SNACRateLimits:   deps.snacRateLimits,
-	}
-}
-
-// BOS creates an OSCAR server for the BOS food group.
-func BOS(deps Container) oscar.BOSServer {
-	logger := deps.logger.With("svc", "BOS")
-
 	authService := foodgroup.NewAuthService(
 		deps.cfg,
 		deps.inMemorySessionManager,
+		deps.inMemorySessionManager,
 		deps.chatSessionManager,
 		deps.sqLiteUserStore,
 		deps.hmacCookieBaker,
 		deps.chatSessionManager,
 		deps.sqLiteUserStore,
-		nil,
 		deps.rateLimitClasses,
 	)
 	bartService := foodgroup.NewBARTService(
@@ -259,6 +228,7 @@ func BOS(deps Container) oscar.BOSServer {
 		deps.inMemorySessionManager,
 		deps.sqLiteUserStore,
 	)
+	chatService := foodgroup.NewChatService(deps.chatSessionManager)
 	chatNavService := foodgroup.NewChatNavService(logger, deps.sqLiteUserStore)
 	feedbagService := foodgroup.NewFeedbagService(
 		logger,
@@ -292,7 +262,7 @@ func BOS(deps Container) oscar.BOSServer {
 		deps.sqLiteUserStore,
 		deps.inMemorySessionManager,
 	)
-	oServiceService := foodgroup.NewOServiceServiceForBOS(
+	oServiceService := foodgroup.NewOServiceService(
 		deps.cfg,
 		deps.inMemorySessionManager,
 		logger,
@@ -303,139 +273,54 @@ func BOS(deps Container) oscar.BOSServer {
 		deps.sqLiteUserStore,
 		deps.rateLimitClasses,
 		deps.snacRateLimits,
+		deps.chatSessionManager,
 	)
 	userLookupService := foodgroup.NewUserLookupService(deps.sqLiteUserStore)
 	statsService := foodgroup.NewStatsService()
+	oDirService := foodgroup.NewODirService(logger, deps.sqLiteUserStore)
 
-	return oscar.BOSServer{
-		AuthService:        authService,
-		BuddyListRegistry:  deps.sqLiteUserStore,
-		Config:             deps.cfg,
-		DepartureNotifier:  buddyService,
-		ChatSessionManager: deps.chatSessionManager,
-		Handler: handler.NewBOSRouter(handler.Handlers{
-			AlertHandler:      handler.NewAlertHandler(logger),
-			BARTHandler:       handler.NewBARTHandler(logger, bartService),
-			BuddyHandler:      handler.NewBuddyHandler(logger, buddyService),
-			ChatNavHandler:    handler.NewChatNavHandler(chatNavService, logger),
-			FeedbagHandler:    handler.NewFeedbagHandler(logger, feedbagService),
-			ICQHandler:        handler.NewICQHandler(logger, icqService),
-			ICBMHandler:       handler.NewICBMHandler(logger, icbmService),
-			LocateHandler:     handler.NewLocateHandler(locateService, logger),
-			OServiceHandler:   handler.NewOServiceHandler(logger, oServiceService),
-			PermitDenyHandler: handler.NewPermitDenyHandler(logger, permitDenyService),
-			StatsHandler:      handler.NewStatsHandler(logger, statsService),
-			UserLookupHandler: handler.NewUserLookupHandler(logger, userLookupService),
-		}),
-		Logger:           logger,
-		OnlineNotifier:   oServiceService,
-		ListenAddr:       net.JoinHostPort("", deps.cfg.BOSPort),
-		RateLimitUpdater: oServiceService,
-		SNACRateLimits:   deps.snacRateLimits,
-	}
-}
-
-// Chat creates an OSCAR server for the Chat food group.
-func Chat(deps Container) oscar.ChatServer {
-	logger := deps.logger.With("svc", "CHAT")
-
-	sessionManager := state.NewInMemorySessionManager(logger)
-	authService := foodgroup.NewAuthService(
-		deps.cfg,
-		sessionManager,
-		deps.chatSessionManager,
-		deps.sqLiteUserStore,
-		deps.hmacCookieBaker,
-		deps.chatSessionManager,
-		deps.sqLiteUserStore,
-		nil,
-		deps.rateLimitClasses,
-	)
-	chatService := foodgroup.NewChatService(deps.chatSessionManager)
-	oServiceService := foodgroup.NewOServiceServiceForChat(
-		deps.cfg,
-		logger,
-		sessionManager,
-		deps.sqLiteUserStore,
-		deps.chatSessionManager,
-		deps.sqLiteUserStore,
-		sessionManager,
-		deps.sqLiteUserStore,
-		deps.rateLimitClasses,
-		deps.snacRateLimits,
-	)
-
-	return oscar.ChatServer{
-		AuthService: authService,
-		Config:      deps.cfg,
-		Handler: handler.NewChatRouter(handler.Handlers{
-			ChatHandler:     handler.NewChatHandler(logger, chatService),
-			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
-		}),
-		Logger:           logger,
-		OnlineNotifier:   oServiceService,
-		SNACRateLimits:   deps.snacRateLimits,
-		RateLimitUpdater: oServiceService,
+	if err := deps.sqLiteUserStore.ClearBuddyListRegistry(context.Background()); err != nil {
+		panic(err)
 	}
-}
-
-// ChatNav creates an OSCAR server for the ChatNav food group.
-func ChatNav(deps Container) oscar.BOSServer {
-	logger := deps.logger.With("svc", "CHAT_NAV")
 
-	sessionManager := state.NewInMemorySessionManager(logger)
-	authService := foodgroup.NewAuthService(
-		deps.cfg,
-		sessionManager,
-		deps.chatSessionManager,
+	return oscar.NewServer(
+		authService,
 		deps.sqLiteUserStore,
-		deps.hmacCookieBaker,
 		deps.chatSessionManager,
-		deps.sqLiteUserStore,
-		nil,
-		deps.rateLimitClasses,
-	)
-	chatNavService := foodgroup.NewChatNavService(logger, deps.sqLiteUserStore)
-	oServiceService := foodgroup.NewOServiceServiceForChatNav(
-		deps.cfg,
+		buddyService,
 		logger,
-		sessionManager,
-		deps.sqLiteUserStore,
-		sessionManager,
-		deps.sqLiteUserStore,
-		deps.rateLimitClasses,
+		oServiceService,
+		oscar.Handler{
+			AdminService:      adminService,
+			BARTService:       bartService,
+			BuddyService:      buddyService,
+			ChatNavService:    chatNavService,
+			ChatService:       chatService,
+			FeedbagService:    feedbagService,
+			ICBMService:       icbmService,
+			ICQService:        icqService,
+			LocateService:     locateService,
+			ODirService:       oDirService,
+			OServiceService:   oServiceService,
+			PermitDenyService: permitDenyService,
+			StatsService:      statsService,
+			UserLookupService: userLookupService,
+			RouteLogger: middleware.RouteLogger{
+				Logger: logger,
+			},
+		}.Handle,
+		oServiceService,
 		deps.snacRateLimits,
+		oscar.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
+		deps.Listeners,
 	)
-
-	return oscar.BOSServer{
-		AuthService: authService,
-		Config:      deps.cfg,
-		Handler: handler.NewChatNavRouter(handler.Handlers{
-			ChatNavHandler:  handler.NewChatNavHandler(chatNavService, logger),
-			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
-		}),
-		Logger:           logger,
-		OnlineNotifier:   oServiceService,
-		ListenAddr:       net.JoinHostPort("", deps.cfg.ChatNavPort),
-		RateLimitUpdater: oServiceService,
-		SNACRateLimits:   deps.snacRateLimits,
-	}
 }
 
 // KerberosAPI creates an HTTP server for the Kerberos server.
-func KerberosAPI(deps Container) *oscar.KerberosServer {
-	authService := foodgroup.NewAuthService(
-		deps.cfg,
-		deps.inMemorySessionManager,
-		deps.chatSessionManager,
-		deps.sqLiteUserStore,
-		deps.hmacCookieBaker,
-		deps.chatSessionManager,
-		deps.sqLiteUserStore,
-		nil,
-		deps.rateLimitClasses,
-	)
-	return oscar.NewKerberosServer(deps.cfg, deps.logger, authService)
+func KerberosAPI(deps Container) *kerberos.Server {
+	logger := deps.logger.With("svc", "kerberos")
+	authService := foodgroup.NewAuthService(deps.cfg, deps.inMemorySessionManager, deps.inMemorySessionManager, deps.chatSessionManager, deps.sqLiteUserStore, deps.hmacCookieBaker, deps.chatSessionManager, deps.sqLiteUserStore, deps.rateLimitClasses)
+	return kerberos.NewKerberosServer(deps.Listeners, logger, authService)
 }
 
 // MgmtAPI creates an HTTP server for the management API.
@@ -445,63 +330,19 @@ func MgmtAPI(deps Container) *http.Server {
 		Commit:  commit,
 		Date:    date,
 	}
-	return http.NewManagementAPI(bld, deps.cfg, deps.sqLiteUserStore, deps.inMemorySessionManager, deps.sqLiteUserStore,
+	logger := deps.logger.With("svc", "API")
+	return http.NewManagementAPI(bld, deps.cfg.APIListener, deps.sqLiteUserStore, deps.inMemorySessionManager, deps.sqLiteUserStore,
 		deps.sqLiteUserStore, deps.chatSessionManager, deps.sqLiteUserStore, deps.inMemorySessionManager,
-		deps.sqLiteUserStore, deps.sqLiteUserStore, deps.sqLiteUserStore, deps.sqLiteUserStore, deps.logger)
-}
-
-// ODir creates an OSCAR server for the ODir food group.
-func ODir(deps Container) oscar.BOSServer {
-	logger := deps.logger.With("svc", "ODIR")
-
-	sessionManager := state.NewInMemorySessionManager(logger)
-	authService := foodgroup.NewAuthService(
-		deps.cfg,
-		sessionManager,
-		deps.chatSessionManager,
-		deps.sqLiteUserStore,
-		deps.hmacCookieBaker,
-		deps.chatSessionManager,
-		deps.sqLiteUserStore,
-		nil,
-		deps.rateLimitClasses,
-	)
-
-	oServiceService := foodgroup.NewOServiceServiceForODir(
-		deps.cfg,
-		logger,
-		deps.rateLimitClasses,
-		deps.snacRateLimits,
-		deps.inMemorySessionManager,
-		deps.sqLiteUserStore,
-		deps.inMemorySessionManager,
-		deps.sqLiteUserStore,
-	)
-	oDirService := foodgroup.NewODirService(logger, deps.sqLiteUserStore)
-
-	return oscar.BOSServer{
-		AuthService: authService,
-		Config:      deps.cfg,
-		Handler: handler.NewODirRouter(handler.Handlers{
-			OServiceHandler: handler.NewOServiceHandler(logger, oServiceService),
-			ODirHandler:     handler.NewODirHandler(logger, oDirService),
-		}),
-		Logger:           logger,
-		OnlineNotifier:   oServiceService,
-		ListenAddr:       net.JoinHostPort("", deps.cfg.ODirPort),
-		RateLimitUpdater: oServiceService,
-		SNACRateLimits:   deps.snacRateLimits,
-	}
+		deps.sqLiteUserStore, deps.sqLiteUserStore, deps.sqLiteUserStore, deps.sqLiteUserStore, logger)
 }
 
 // TOC creates a TOC server.
-func TOC(deps Container) 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{
+	return toc.NewServer(
+		strings.Split(deps.cfg.TOCListeners, ","),
+		logger,
+		toc.OSCARProxy{
 			AdminService: foodgroup.NewAdminService(
 				deps.sqLiteUserStore,
 				deps.sqLiteUserStore,
@@ -513,12 +354,12 @@ func TOC(deps Container) toc.Server {
 			AuthService: foodgroup.NewAuthService(
 				deps.cfg,
 				deps.inMemorySessionManager,
+				deps.inMemorySessionManager,
 				deps.chatSessionManager,
 				deps.sqLiteUserStore,
 				deps.hmacCookieBaker,
 				deps.chatSessionManager,
 				deps.sqLiteUserStore,
-				nil,
 				deps.rateLimitClasses,
 			),
 			BuddyListRegistry: deps.sqLiteUserStore,
@@ -547,7 +388,7 @@ func TOC(deps Container) toc.Server {
 				deps.inMemorySessionManager,
 			),
 			Logger: logger,
-			OServiceServiceBOS: foodgroup.NewOServiceServiceForBOS(
+			OServiceService: foodgroup.NewOServiceService(
 				deps.cfg,
 				deps.inMemorySessionManager,
 				logger,
@@ -558,6 +399,7 @@ func TOC(deps Container) toc.Server {
 				deps.sqLiteUserStore,
 				deps.rateLimitClasses,
 				deps.snacRateLimits,
+				deps.chatSessionManager,
 			),
 			PermitDenyService: foodgroup.NewPermitDenyService(
 				deps.sqLiteUserStore,
@@ -566,24 +408,12 @@ func TOC(deps Container) toc.Server {
 				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,
-				deps.sqLiteUserStore,
-				deps.rateLimitClasses,
-				deps.snacRateLimits,
-			),
+			TOCConfigStore:    deps.sqLiteUserStore,
+			ChatService:       foodgroup.NewChatService(deps.chatSessionManager),
 			ChatNavService:    foodgroup.NewChatNavService(logger, deps.sqLiteUserStore),
 			SNACRateLimits:    deps.snacRateLimits,
 			HTTPIPRateLimiter: toc.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 		},
-		LoginIPRateLimiter: toc.NewIPRateLimiter(rate.Every(10*time.Minute), 10, 20*time.Minute),
-	}
+		toc.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
+	)
 }

+ 27 - 23
cmd/server/main.go

@@ -7,6 +7,7 @@ import (
 	"os"
 	"os/signal"
 	"syscall"
+	"time"
 
 	"github.com/joho/godotenv"
 	"golang.org/x/sync/errgroup"
@@ -49,35 +50,38 @@ func main() {
 	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
 	defer stop()
 
-	type starter interface {
-		Start(ctx context.Context) error
-	}
-
-	var g errgroup.Group
-	start := func(fn starter) {
-		g.Go(func() error { return fn.Start(ctx) })
-	}
-
 	deps, err := MakeCommonDeps()
 	if err != nil {
 		fmt.Printf("error initializing common deps: %v\n", err)
 		os.Exit(1)
 	}
 
-	start(Admin(deps))
-	start(Alert(deps))
-	start(Auth(deps))
-	start(BART(deps))
-	start(BOS(deps))
-	start(Chat(deps))
-	start(ChatNav(deps))
-	start(KerberosAPI(deps))
-	start(MgmtAPI(deps))
-	start(ODir(deps))
-	start(TOC(deps))
-
-	if err := g.Wait(); err != nil {
-		fmt.Println(err.Error())
+	g, ctx := errgroup.WithContext(ctx)
+
+	oscar := OSCAR(deps)
+	g.Go(oscar.ListenAndServe)
+
+	kerb := KerberosAPI(deps)
+	g.Go(kerb.ListenAndServe)
+
+	api := MgmtAPI(deps)
+	g.Go(api.ListenAndServe)
+
+	toc := TOC(deps)
+	g.Go(toc.ListenAndServe)
+
+	select {
+	case <-ctx.Done():
+		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer cancel()
+		_ = oscar.Shutdown(shutdownCtx)
+		_ = kerb.Shutdown(shutdownCtx)
+		_ = api.Shutdown(shutdownCtx)
+		_ = toc.Shutdown(shutdownCtx)
+	}
+
+	if err = g.Wait(); err != nil {
+		deps.logger.Error("server initialization failed", "err", err.Error())
 		os.Exit(1)
 	}
 }

+ 85 - 17
config/config.go

@@ -1,24 +1,24 @@
 package config
 
+import (
+	"errors"
+	"fmt"
+	"net"
+	"net/url"
+	"strings"
+)
+
 //go:generate go run github.com/mk6i/retro-aim-server/cmd/config_generator unix settings.env
 type Config struct {
-	ApiHost      string `envconfig:"API_HOST" require:"true" val:"127.0.0.1" description:"Specifies the IP address or hostname that the management API binds to for incoming connections (127.0.0.1 restricts to same machine only)."`
-	ApiPort      string `envconfig:"API_PORT" required:"true" val:"8080" description:"The port that the management API service binds to."`
-	KerberosPort string `envconfig:"KERBEROS_PORT" required:"true" val:"1088" description:"The port that the Kerberos server binds to."`
-	AlertPort    string `envconfig:"ALERT_PORT" required:"true" val:"5194" description:"The port that the Alert service binds to."`
-	AuthPort     string `envconfig:"AUTH_PORT" required:"true" val:"5190" description:"The port that the auth service binds to."`
-	BARTPort     string `envconfig:"BART_PORT" required:"true" val:"5195" description:"The port that the BART service binds to."`
-	BOSPort      string `envconfig:"BOS_PORT" required:"true" val:"5191" description:"The port that the BOS service binds to."`
-	ChatNavPort  string `envconfig:"CHAT_NAV_PORT" required:"true" val:"5193" description:"The port that the chat nav service binds to."`
-	ChatPort     string `envconfig:"CHAT_PORT" required:"true" val:"5192" description:"The port that the chat service binds to."`
-	AdminPort    string `envconfig:"ADMIN_PORT" required:"true" val:"5196" description:"The port that the admin service binds to."`
-	ODirPort     string `envconfig:"ODIR_PORT" required:"true" val:"5197" description:"The port that the ODir service binds to."`
-	DBPath       string `envconfig:"DB_PATH" required:"true" val:"oscar.sqlite" description:"The path to the SQLite database file. The file and DB schema are auto-created if they doesn't exist."`
-	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:"0.0.0.0" description:"Specifies the IP address or hostname that the TOC service binds to for incoming connections (0.0.0.0 listens on all interfaces)."`
-	TOCPort      string `envconfig:"TOC_PORT" required:"true" val:"9898" description:"The port that the TOC service binds to."`
+	BOSListeners       string `envconfig:"OSCAR_LISTENERS" required:"true" val:"LOCAL://0.0.0.0:5190" description:"Network listeners for core OSCAR services. For multi-homed servers, allows users to connect from multiple networks. For example, you can allow both LAN and Internet clients to connect to the same server using different connection settings.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Listener names and ports must be unique\n\t- Listener names are user-defined\n\t- Each listener needs OSCAR_ADVERTISED_LISTENERS/KERBEROS_LISTENERS configs\n\nExamples:\n\t// Listen on all interfaces\n\tLAN://0.0.0.0:5190\n\t// Separate Internet and LAN config\n\tWAN://142.250.176.206:5190,LAN://192.168.1.10:5191"`
+	BOSAdvertisedHosts string `envconfig:"OSCAR_ADVERTISED_LISTENERS" required:"true" val:"LOCAL://127.0.0.1:5190" description:"Hostnames published by the server that clients connect to for accessing various OSCAR services. These hostnames are NOT the bind addresses. For multi-homed use servers, allows clients to connect using separate hostnames per network.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Each listener config must correspond to a config in OSCAR_LISTENERS\n\t- Clients MUST be able to connect to these hostnames\n\nExamples:\n\t// Local LAN config, server behind NAT\n\tLAN://0.0.0.0:5190\n\t// Separate Internet and LAN config\n\tWAN://aim.example.com:5190,LAN://192.168.1.10:5191"`
+	KerberosListeners  string `envconfig:"KERBEROS_LISTENERS" required:"true" val:"LOCAL://0.0.0.0:1088" description:"Network listeners for Kerberos authentication. See OSCAR_LISTENERS doc for more details.\n\nExamples:\n\t// Listen on all interfaces\n\tLAN://0.0.0.0:1088\n\t// Separate Internet and LAN config\n\tWAN://142.250.176.206:1088,LAN://192.168.1.10:1087"`
+	TOCListeners       string `envconfig:"TOC_LISTENERS" required:"true" val:"0.0.0.0:9898" description:"Network listeners for TOC protocol service.\n\nFormat: Comma-separated list of hostname:port pairs.\n\nExamples:\n\t// All interfaces\n\t0.0.0.0:9898\n\t// Multiple listeners\n\t0.0.0.0:9898,192.168.1.10:9899"`
+	APIListener        string `envconfig:"API_LISTENER" required:"true" val:"127.0.0.1:8080" description:"Network listener for management API binds to. Only 1 listener can be specified. (Default 127.0.0.1 restricts to same machine only)."`
+
+	DBPath      string `envconfig:"DB_PATH" required:"true" val:"oscar.sqlite" description:"The path to the SQLite database file. The file and DB schema are auto-created if they doesn't exist."`
+	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'."`
 }
 
 type Build struct {
@@ -26,3 +26,71 @@ type Build struct {
 	Commit  string `json:"commit"`
 	Date    string `json:"date"`
 }
+
+type Listener struct {
+	BOSListenAddress      string
+	BOSAdvertisedHost     string
+	KerberosListenAddress string
+}
+
+func ParseListenersCfg(BOSListeners string, BOSAdvertisedListeners string, kerberosListeners string) ([]Listener, error) {
+	m := make(map[string]*Listener)
+
+	for _, lStr := range strings.Split(BOSListeners, ",") {
+		u, err := url.Parse(lStr)
+		if err != nil {
+			return nil, fmt.Errorf("parsing listener URI: %w", err)
+		}
+		if _, ok := m[u.Scheme]; !ok {
+			m[u.Scheme] = &Listener{}
+		}
+		if m[u.Scheme].BOSListenAddress != "" {
+			return nil, errors.New("duplicate listener definition")
+		}
+		m[u.Scheme].BOSListenAddress = net.JoinHostPort(u.Hostname(), u.Port())
+	}
+
+	for _, lStr := range strings.Split(BOSAdvertisedListeners, ",") {
+		u, err := url.Parse(lStr)
+		if err != nil {
+			return nil, fmt.Errorf("parsing listener URI: %w", err)
+		}
+		if _, ok := m[u.Scheme]; !ok {
+			m[u.Scheme] = &Listener{}
+		}
+		if m[u.Scheme].BOSAdvertisedHost != "" {
+			return nil, errors.New("duplicate listener definition")
+		}
+		m[u.Scheme].BOSAdvertisedHost = net.JoinHostPort(u.Hostname(), u.Port())
+	}
+
+	for _, lStr := range strings.Split(kerberosListeners, ",") {
+		u, err := url.Parse(lStr)
+		if err != nil {
+			return nil, fmt.Errorf("parsing listener URI: %w", err)
+		}
+		if _, ok := m[u.Scheme]; !ok {
+			m[u.Scheme] = &Listener{}
+		}
+		if m[u.Scheme].KerberosListenAddress != "" {
+			return nil, errors.New("duplicate listener definition")
+		}
+		m[u.Scheme].KerberosListenAddress = net.JoinHostPort(u.Hostname(), u.Port())
+	}
+
+	ret := make([]Listener, 0, len(m))
+
+	for k, v := range m {
+		switch {
+		case v.BOSAdvertisedHost == "":
+			return nil, fmt.Errorf("missing BOS advertise address for listener `%s://`", k)
+		case v.BOSListenAddress == "":
+			return nil, fmt.Errorf("missing BOS listen address for listener `%s://`", k)
+		case v.KerberosListenAddress == "":
+			return nil, fmt.Errorf("missing kerberos listen address for listener `%s://`", k)
+		}
+		ret = append(ret, *v)
+	}
+
+	return ret, nil
+}

+ 5 - 12
config/ras.service

@@ -5,21 +5,14 @@ Description=Retro AIM Server
 Type=simple
 User=ras
 Group=ras
-Environment="ADMIN_PORT=5196"
-Environment="ALERT_PORT=5194"
-Environment="API_PORT=8080"
-Environment="AUTH_PORT=5190"
-Environment="BART_PORT=5195"
-Environment="BOS_PORT=5191"
-Environment="CHAT_NAV_PORT=5193"
-Environment="CHAT_PORT=5192"
+Environment="API_LISTENER=127.0.0.1:8080"
+Environment="OSCAR_ADVERTISED_LISTENERS=EXTERNAL://127.0.0.1:5190"
+Environment="OSCAR_LISTENERS=EXTERNAL://0.0.0.0:5190"
+Environment="KERBEROS_LISTENERS=EXTERNAL://0.0.0.0:1088"
+Environment="TOC_LISTENERS=0.0.0.0:9898"
 Environment="DB_PATH=/var/ras/oscar.sqlite"
 Environment="DISABLE_AUTH=true"
 Environment="LOG_LEVEL=info"
-Environment="ODIR_PORT=5197"
-Environment="OSCAR_HOST=127.0.0.1"
-Environment="TOC_HOST=0.0.0.0"
-Environment="TOC_PORT=9898"
 ExecStart=/opt/ras/retro_aim_server
 Restart=on-failure
 

+ 59 - 49
config/settings.env

@@ -1,36 +1,62 @@
-# Specifies the IP address or hostname that the management API binds to for
-# incoming connections (127.0.0.1 restricts to same machine only).
-export API_HOST=127.0.0.1
-
-# The port that the management API service binds to.
-export API_PORT=8080
-
-# The port that the Kerberos server binds to.
-export KERBEROS_PORT=1088
-
-# The port that the Alert service binds to.
-export ALERT_PORT=5194
-
-# The port that the auth service binds to.
-export AUTH_PORT=5190
-
-# The port that the BART service binds to.
-export BART_PORT=5195
-
-# The port that the BOS service binds to.
-export BOS_PORT=5191
-
-# The port that the chat nav service binds to.
-export CHAT_NAV_PORT=5193
-
-# The port that the chat service binds to.
-export CHAT_PORT=5192
-
-# The port that the admin service binds to.
-export ADMIN_PORT=5196
-
-# The port that the ODir service binds to.
-export ODIR_PORT=5197
+# Network listeners for core OSCAR services. For multi-homed servers, allows
+# users to connect from multiple networks. For example, you can allow both LAN
+# and Internet clients to connect to the same server using different connection
+# settings.
+# 
+# Format:
+# 	- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]
+# 	- Listener names and ports must be unique
+# 	- Listener names are user-defined
+# 	- Each listener needs OSCAR_ADVERTISED_LISTENERS/KERBEROS_LISTENERS configs
+# 
+# Examples:
+# 	// Listen on all interfaces
+# 	LAN://0.0.0.0:5190
+# 	// Separate Internet and LAN config
+# 	WAN://142.250.176.206:5190,LAN://192.168.1.10:5191
+export OSCAR_LISTENERS=LOCAL://0.0.0.0:5190
+
+# Hostnames published by the server that clients connect to for accessing
+# various OSCAR services. These hostnames are NOT the bind addresses. For
+# multi-homed use servers, allows clients to connect using separate hostnames
+# per network.
+# 
+# Format:
+# 	- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]
+# 	- Each listener config must correspond to a config in OSCAR_LISTENERS
+# 	- Clients MUST be able to connect to these hostnames
+# 
+# Examples:
+# 	// Local LAN config, server behind NAT
+# 	LAN://0.0.0.0:5190
+# 	// Separate Internet and LAN config
+# 	WAN://aim.example.com:5190,LAN://192.168.1.10:5191
+export OSCAR_ADVERTISED_LISTENERS=LOCAL://127.0.0.1:5190
+
+# Network listeners for Kerberos authentication. See OSCAR_LISTENERS doc for
+# more details.
+# 
+# Examples:
+# 	// Listen on all interfaces
+# 	LAN://0.0.0.0:1088
+# 	// Separate Internet and LAN config
+# 	WAN://142.250.176.206:1088,LAN://192.168.1.10:1087
+export KERBEROS_LISTENERS=LOCAL://0.0.0.0:1088
+
+# Network listeners for TOC protocol service.
+# 
+# Format: Comma-separated list of hostname:port pairs.
+# 
+# Examples:
+# 	// All interfaces
+# 	0.0.0.0:9898
+# 	// Multiple listeners
+# 	0.0.0.0:9898,192.168.1.10:9899
+export TOC_LISTENERS=0.0.0.0:9898
+
+# Network listener for management API binds to. Only 1 listener can be
+# specified. (Default 127.0.0.1 restricts to same machine only).
+export API_LISTENER=127.0.0.1:8080
 
 # The path to the SQLite database file. The file and DB schema are auto-created
 # if they doesn't exist.
@@ -45,19 +71,3 @@ export DISABLE_AUTH=true
 # 'error'.
 export LOG_LEVEL=info
 
-# 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.
-export OSCAR_HOST=127.0.0.1
-
-# Specifies the IP address or hostname that the TOC service binds to for
-# incoming connections (0.0.0.0 listens on all interfaces).
-export TOC_HOST=0.0.0.0
-
-# The port that the TOC service binds to.
-export TOC_PORT=9898
-

+ 47 - 72
foodgroup/auth.go

@@ -5,7 +5,6 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"net"
 	"strconv"
 	"strings"
 	"time"
@@ -21,12 +20,12 @@ import (
 func NewAuthService(
 	cfg config.Config,
 	sessionManager SessionRegistry,
+	sessionRetriever SessionRetriever,
 	chatSessionRegistry ChatSessionRegistry,
 	userManager UserManager,
 	cookieBaker CookieBaker,
 	chatMessageRelayer ChatMessageRelayer,
 	accountManager AccountManager,
-	adminServerSessionRetriever SessionRetriever,
 	classes wire.RateLimitClasses,
 ) *AuthService {
 	return &AuthService{
@@ -34,13 +33,12 @@ func NewAuthService(
 		config:              cfg,
 		cookieBaker:         cookieBaker,
 		sessionManager:      sessionManager,
+		sessionRetriever:    sessionRetriever,
 		userManager:         userManager,
 		chatMessageRelayer:  chatMessageRelayer,
 		accountManager:      accountManager,
-		// hack - adminServerSessionRetriever is just used for admin server
-		adminServerSessionRetriever: adminServerSessionRetriever,
-		rateLimitClasses:            classes,
-		timeNow:                     time.Now,
+		rateLimitClasses:    classes,
+		timeNow:             time.Now,
 	}
 }
 
@@ -48,16 +46,16 @@ func NewAuthService(
 // supports both FLAP (AIM v1.0-v3.0) and BUCP (AIM v3.5-v5.9) authentication
 // modes.
 type AuthService struct {
-	chatMessageRelayer          ChatMessageRelayer
-	chatSessionRegistry         ChatSessionRegistry
-	config                      config.Config
-	cookieBaker                 CookieBaker
-	sessionManager              SessionRegistry
-	userManager                 UserManager
-	accountManager              AccountManager
-	adminServerSessionRetriever SessionRetriever
-	rateLimitClasses            wire.RateLimitClasses
-	timeNow                     func() time.Time
+	chatMessageRelayer  ChatMessageRelayer
+	chatSessionRegistry ChatSessionRegistry
+	config              config.Config
+	cookieBaker         CookieBaker
+	sessionManager      SessionRegistry
+	sessionRetriever    SessionRetriever
+	userManager         UserManager
+	accountManager      AccountManager
+	rateLimitClasses    wire.RateLimitClasses
+	timeNow             func() time.Time
 }
 
 // RegisterChatSession adds a user to a chat room. The authCookie param is an
@@ -67,16 +65,8 @@ 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(ctx context.Context, authCookie []byte) (*state.Session, error) {
-	token, err := s.cookieBaker.Crack(authCookie)
-	if err != nil {
-		return nil, err
-	}
-	c := chatLoginCookie{}
-	if err := wire.UnmarshalBE(&c, bytes.NewBuffer(token)); err != nil {
-		return nil, err
-	}
-	sess, err := s.chatSessionRegistry.AddSession(ctx, c.ChatCookie, c.ScreenName)
+func (s AuthService) RegisterChatSession(ctx context.Context, serverCookie state.ServerCookie) (*state.Session, error) {
+	sess, err := s.chatSessionRegistry.AddSession(ctx, serverCookie.ChatCookie, serverCookie.ScreenName)
 	if err != nil {
 		return nil, fmt.Errorf("AddSession: %w", err)
 	}
@@ -86,26 +76,24 @@ func (s AuthService) RegisterChatSession(ctx context.Context, authCookie []byte)
 	return sess, err
 }
 
-// bosCookie represents a token containing client metadata passed to the BOS
-// service upon connection.
-type bosCookie struct {
-	ScreenName state.DisplayScreenName `oscar:"len_prefix=uint8"`
-	ClientID   string                  `oscar:"len_prefix=uint8"`
-}
+func (s AuthService) CrackCookie(authCookie []byte) (state.ServerCookie, error) {
+	c := state.ServerCookie{}
 
-// RegisterBOSSession adds a new session to the session registry.
-func (s AuthService) RegisterBOSSession(ctx context.Context, authCookie []byte) (*state.Session, error) {
 	buf, err := s.cookieBaker.Crack(authCookie)
 	if err != nil {
-		return nil, err
+		return c, err
 	}
 
-	c := bosCookie{}
 	if err := wire.UnmarshalBE(&c, bytes.NewBuffer(buf)); err != nil {
-		return nil, err
+		return c, err
 	}
 
-	u, err := s.userManager.User(ctx, c.ScreenName.IdentScreenName())
+	return c, nil
+}
+
+// RegisterBOSSession adds a new session to the session registry.
+func (s AuthService) RegisterBOSSession(ctx context.Context, serverCookie state.ServerCookie) (*state.Session, error) {
+	u, err := s.userManager.User(ctx, serverCookie.ScreenName.IdentScreenName())
 	if err != nil {
 		return nil, fmt.Errorf("failed to retrieve user: %w", err)
 	}
@@ -135,7 +123,7 @@ func (s AuthService) RegisterBOSSession(ctx context.Context, authCookie []byte)
 	sess.SetRateClasses(time.Now(), s.rateLimitClasses)
 
 	// set string containing OSCAR client name and version
-	sess.SetClientID(c.ClientID)
+	sess.SetClientID(serverCookie.ClientID)
 
 	if u.DisplayScreenName.IsUIN() {
 		sess.SetUserInfoFlag(wire.OServiceUserFlagICQ)
@@ -151,18 +139,8 @@ func (s AuthService) RegisterBOSSession(ctx context.Context, authCookie []byte)
 }
 
 // RetrieveBOSSession returns a user's existing session
-func (s AuthService) RetrieveBOSSession(ctx context.Context, authCookie []byte) (*state.Session, error) {
-	buf, err := s.cookieBaker.Crack(authCookie)
-	if err != nil {
-		return nil, err
-	}
-
-	c := bosCookie{}
-	if err := wire.UnmarshalBE(&c, bytes.NewBuffer(buf)); err != nil {
-		return nil, err
-	}
-
-	u, err := s.userManager.User(ctx, c.ScreenName.IdentScreenName())
+func (s AuthService) RetrieveBOSSession(ctx context.Context, serverCookie state.ServerCookie) (*state.Session, error) {
+	u, err := s.userManager.User(ctx, serverCookie.ScreenName.IdentScreenName())
 	if err != nil {
 		return nil, fmt.Errorf("failed to retrieve user: %w", err)
 	}
@@ -170,7 +148,7 @@ func (s AuthService) RetrieveBOSSession(ctx context.Context, authCookie []byte)
 		return nil, fmt.Errorf("user not found")
 	}
 
-	return s.adminServerSessionRetriever.RetrieveSession(u.IdentScreenName), nil
+	return s.sessionRetriever.RetrieveSession(u.IdentScreenName), nil
 }
 
 // Signout removes this user's session and notifies users who have this user on
@@ -251,9 +229,9 @@ func (s AuthService) BUCPChallenge(ctx context.Context, bodyIn wire.SNAC_0x17_0x
 // (wire.LoginTLVTagsReconnectHere) and an authorization cookie
 // (wire.LoginTLVTagsAuthorizationCookie). Else, an error code is set
 // (wire.LoginTLVTagsErrorSubcode).
-func (s AuthService) BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error) {
+func (s AuthService) BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error) {
 
-	block, err := s.login(ctx, bodyIn.TLVList, newUserFn)
+	block, err := s.login(ctx, bodyIn.TLVList, newUserFn, advertisedHost)
 	if err != nil {
 		return wire.SNACMessage{}, err
 	}
@@ -279,8 +257,8 @@ func (s AuthService) BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_B
 // (wire.LoginTLVTagsReconnectHere) and an authorization cookie
 // (wire.LoginTLVTagsAuthorizationCookie). Else, an error code is set
 // (wire.LoginTLVTagsErrorSubcode).
-func (s AuthService) FLAPLogin(ctx context.Context, frame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error) {
-	return s.login(ctx, frame.TLVList, newUserFn)
+func (s AuthService) FLAPLogin(ctx context.Context, frame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.TLVRestBlock, error) {
+	return s.login(ctx, frame.TLVList, newUserFn, advertisedHost)
 }
 
 // KerberosLogin handles AIM-style Kerberos authentication for AIM 6.0+.
@@ -291,11 +269,7 @@ func (s AuthService) FLAPLogin(ctx context.Context, frame wire.FLAPSignonFrame,
 //
 // Several values in the response are poorly understood but necessary for proper
 // processing on the client side.
-func (s AuthService) KerberosLogin(
-	ctx context.Context,
-	inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest,
-	newUserFn func(screenName state.DisplayScreenName) (state.User, error),
-) (wire.SNACMessage, error) {
+func (s AuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error) {
 
 	b, ok := inBody.TicketRequestMetadata.Bytes(wire.KerberosTLVTicketRequest)
 	if !ok {
@@ -312,7 +286,7 @@ func (s AuthService) KerberosLogin(
 		wire.NewTLVBE(wire.LoginTLVTagsPlaintextPassword, info.Password),
 	}
 
-	result, err := s.login(ctx, list, newUserFn)
+	result, err := s.login(ctx, list, newUserFn, "") //todo
 	if err != nil {
 		return wire.SNACMessage{}, fmt.Errorf("login: %w", err)
 	}
@@ -362,7 +336,7 @@ func (s AuthService) KerberosLogin(
 								Unknown: 1,
 								ConnectionInfo: wire.TLVBlock{
 									TLVList: wire.TLVList{
-										wire.NewTLVBE(wire.KerberosTLVHostname, net.JoinHostPort(s.config.OSCARHost, s.config.BOSPort)),
+										wire.NewTLVBE(wire.KerberosTLVHostname, advertisedHost),
 										wire.NewTLVBE(wire.KerberosTLVCookie, cookie),
 									},
 								},
@@ -436,7 +410,7 @@ func (l *loginProperties) fromTLV(list wire.TLVList) error {
 
 // login validates a user's credentials and creates their session. it returns
 // metadata used in both BUCP and FLAP authentication responses.
-func (s AuthService) login(ctx context.Context, tlv wire.TLVList, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error) {
+func (s AuthService) login(ctx context.Context, tlv wire.TLVList, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.TLVRestBlock, error) {
 
 	props := loginProperties{}
 	if err := props.fromTLV(tlv); err != nil {
@@ -452,7 +426,7 @@ func (s AuthService) login(ctx context.Context, tlv wire.TLVList, newUserFn func
 		// user not found
 		if s.config.DisableAuth {
 			// auth disabled, create the user
-			return s.createUser(ctx, props, newUserFn)
+			return s.createUser(ctx, props, newUserFn, advertisedHost)
 		}
 		// auth enabled, return separate login errors for ICQ and AIM
 		loginErr := wire.LoginErrInvalidUsernameOrPassword
@@ -469,7 +443,7 @@ func (s AuthService) login(ctx context.Context, tlv wire.TLVList, newUserFn func
 
 	if s.config.DisableAuth {
 		// user exists, but don't validate
-		return s.loginSuccessResponse(props)
+		return s.loginSuccessResponse(props, advertisedHost)
 	}
 
 	var loginOK bool
@@ -490,10 +464,10 @@ func (s AuthService) login(ctx context.Context, tlv wire.TLVList, newUserFn func
 		return loginFailureResponse(props, wire.LoginErrInvalidPassword), nil
 	}
 
-	return s.loginSuccessResponse(props)
+	return s.loginSuccessResponse(props, advertisedHost)
 }
 
-func (s AuthService) createUser(ctx context.Context, props loginProperties, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error) {
+func (s AuthService) createUser(ctx context.Context, props loginProperties, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.TLVRestBlock, error) {
 
 	var err error
 	if props.screenName.IsUIN() {
@@ -523,11 +497,12 @@ func (s AuthService) createUser(ctx context.Context, props loginProperties, newU
 		return wire.TLVRestBlock{}, err
 	}
 
-	return s.loginSuccessResponse(props)
+	return s.loginSuccessResponse(props, advertisedHost)
 }
 
-func (s AuthService) loginSuccessResponse(props loginProperties) (wire.TLVRestBlock, error) {
-	loginCookie := bosCookie{
+func (s AuthService) loginSuccessResponse(props loginProperties, advertisedHost string) (wire.TLVRestBlock, error) {
+	loginCookie := state.ServerCookie{
+		Service:    wire.BOS,
 		ScreenName: props.screenName,
 		ClientID:   props.clientID,
 	}
@@ -544,7 +519,7 @@ func (s AuthService) loginSuccessResponse(props loginProperties) (wire.TLVRestBl
 	return wire.TLVRestBlock{
 		TLVList: []wire.TLV{
 			wire.NewTLVBE(wire.LoginTLVTagsScreenName, props.screenName),
-			wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, net.JoinHostPort(s.config.OSCARHost, s.config.BOSPort)),
+			wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, advertisedHost),
 			wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, cookie),
 		},
 	}, nil

+ 108 - 224
foodgroup/auth_test.go

@@ -28,6 +28,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
+		// advertisedHost is the BOS host the client will connect to upon successful login
+		advertisedHost string
 		// cfg is the app configuration
 		cfg config.Config
 		// inputSNAC is the SNAC sent from the client to the server
@@ -43,11 +45,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 		wantErr error
 	}{
 		{
-			name: "AIM account exists, correct password, login OK",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "AIM account exists, correct password, login OK",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -69,7 +68,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 					cookieIssueParams: cookieIssueParams{
 						{
 							dataIn: func() []byte {
-								loginCookie := bosCookie{
+								loginCookie := state.ServerCookie{
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -90,7 +89,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
-							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:1234"),
+							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 							wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
 						},
 					},
@@ -98,11 +97,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name: "ICQ account exists, correct password, login OK",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "ICQ account exists, correct password, login OK",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -125,7 +121,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 					cookieIssueParams: cookieIssueParams{
 						{
 							dataIn: func() []byte {
-								loginCookie := bosCookie{
+								loginCookie := state.ServerCookie{
 									ScreenName: user.DisplayScreenName,
 									ClientID:   "ICQ 2000b",
 								}
@@ -147,7 +143,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
-							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:1234"),
+							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 							wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
 						},
 					},
@@ -155,11 +151,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name: "AIM account exists, incorrect password, login fails",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "AIM account exists, incorrect password, login fails",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -194,11 +187,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name: "AIM account doesn't exist, login fails",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "AIM account doesn't exist, login fails",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -233,11 +223,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name: "AIM account is suspended",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "AIM account is suspended",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -274,11 +261,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name: "ICQ account doesn't exist, login fails",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "ICQ account doesn't exist, login fails",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -313,10 +297,9 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name: "account doesn't exist, authentication is disabled, account is created, login succeeds",
+			name:           "account doesn't exist, authentication is disabled, account is created, login succeeds",
+			advertisedHost: "127.0.0.1:5190",
 			cfg: config.Config{
-				OSCARHost:   "127.0.0.1",
-				BOSPort:     "1234",
 				DisableAuth: true,
 			},
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
@@ -345,7 +328,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 					cookieIssueParams: cookieIssueParams{
 						{
 							dataIn: func() []byte {
-								loginCookie := bosCookie{
+								loginCookie := state.ServerCookie{
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -369,7 +352,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
-							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:1234"),
+							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 							wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
 						},
 					},
@@ -377,10 +360,9 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name: "AIM account doesn't exist, authentication is disabled, screen name has bad format, login fails",
+			name:           "AIM account doesn't exist, authentication is disabled, screen name has bad format, login fails",
+			advertisedHost: "127.0.0.1:5190",
 			cfg: config.Config{
-				OSCARHost:   "127.0.0.1",
-				BOSPort:     "1234",
 				DisableAuth: true,
 			},
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
@@ -417,10 +399,9 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name: "ICQ account doesn't exist, authentication is disabled, UIN has bad format, login fails",
+			name:           "ICQ account doesn't exist, authentication is disabled, UIN has bad format, login fails",
+			advertisedHost: "127.0.0.1:5190",
 			cfg: config.Config{
-				OSCARHost:   "127.0.0.1",
-				BOSPort:     "1234",
 				DisableAuth: true,
 			},
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
@@ -457,10 +438,9 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name: "account exists, password is invalid, authentication is disabled, login succeeds",
+			name:           "account exists, password is invalid, authentication is disabled, login succeeds",
+			advertisedHost: "127.0.0.1:5190",
 			cfg: config.Config{
-				OSCARHost:   "127.0.0.1",
-				BOSPort:     "1234",
 				DisableAuth: true,
 			},
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
@@ -484,7 +464,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 					cookieIssueParams: cookieIssueParams{
 						{
 							dataIn: func() []byte {
-								loginCookie := bosCookie{
+								loginCookie := state.ServerCookie{
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -508,7 +488,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
-							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:1234"),
+							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 							wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
 						},
 					},
@@ -538,11 +518,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			wantErr: io.EOF,
 		},
 		{
-			name: "login with TOC client - success",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "login with TOC client - success",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -564,7 +541,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 					cookieIssueParams: cookieIssueParams{
 						{
 							dataIn: func() []byte {
-								loginCookie := bosCookie{
+								loginCookie := state.ServerCookie{
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -585,7 +562,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 					TLVRestBlock: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
-							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:1234"),
+							wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 							wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
 						},
 					},
@@ -593,11 +570,8 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 			},
 		},
 		{
-			name: "login with TOC client - failed",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "login with TOC client - failed",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -658,7 +632,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 				cookieBaker: cookieBaker,
 				userManager: userManager,
 			}
-			outputSNAC, err := svc.BUCPLogin(context.Background(), tc.inputSNAC, tc.newUserFn)
+			outputSNAC, err := svc.BUCPLogin(context.Background(), tc.inputSNAC, tc.newUserFn, tc.advertisedHost)
 			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
@@ -676,6 +650,8 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
+		// advertisedHost is the BOS host the client will connect to upon successful login
+		advertisedHost string
 		// cfg is the app configuration
 		cfg config.Config
 		// inputSNAC is the authentication FLAP frame sent from the client to the server
@@ -691,11 +667,8 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 		wantErr error
 	}{
 		{
-			name: "AIM account exists, correct password, login OK",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "AIM account exists, correct password, login OK",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.FLAPSignonFrame{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -717,7 +690,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 					cookieIssueParams: cookieIssueParams{
 						{
 							dataIn: func() []byte {
-								loginCookie := bosCookie{
+								loginCookie := state.ServerCookie{
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -732,17 +705,14 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			expectOutput: wire.TLVRestBlock{
 				TLVList: wire.TLVList{
 					wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
-					wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:1234"),
+					wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 					wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
 				},
 			},
 		},
 		{
-			name: "ICQ account exists, correct password, login OK",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "ICQ account exists, correct password, login OK",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.FLAPSignonFrame{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -765,7 +735,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 					cookieIssueParams: cookieIssueParams{
 						{
 							dataIn: func() []byte {
-								loginCookie := bosCookie{
+								loginCookie := state.ServerCookie{
 									ScreenName: user.DisplayScreenName,
 									ClientID:   "ICQ 2000b",
 								}
@@ -781,17 +751,14 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			expectOutput: wire.TLVRestBlock{
 				TLVList: wire.TLVList{
 					wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
-					wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:1234"),
+					wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 					wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
 				},
 			},
 		},
 		{
-			name: "AIM account exists, incorrect password, login fails",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "AIM account exists, incorrect password, login fails",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.FLAPSignonFrame{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -818,11 +785,8 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			},
 		},
 		{
-			name: "AIM account doesn't exist, login fails",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "AIM account doesn't exist, login fails",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.FLAPSignonFrame{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -849,11 +813,8 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			},
 		},
 		{
-			name: "ICQ account doesn't exist, login fails",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "ICQ account doesn't exist, login fails",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.FLAPSignonFrame{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -881,10 +842,9 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			},
 		},
 		{
-			name: "account doesn't exist, authentication is disabled, account is created, login succeeds",
+			name:           "account doesn't exist, authentication is disabled, account is created, login succeeds",
+			advertisedHost: "127.0.0.1:5190",
 			cfg: config.Config{
-				OSCARHost:   "127.0.0.1",
-				BOSPort:     "1234",
 				DisableAuth: true,
 			},
 			inputSNAC: wire.FLAPSignonFrame{
@@ -913,7 +873,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 					cookieIssueParams: cookieIssueParams{
 						{
 							dataIn: func() []byte {
-								loginCookie := bosCookie{
+								loginCookie := state.ServerCookie{
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -931,16 +891,15 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			expectOutput: wire.TLVRestBlock{
 				TLVList: wire.TLVList{
 					wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
-					wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:1234"),
+					wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 					wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
 				},
 			},
 		},
 		{
-			name: "account exists, password is invalid, authentication is disabled, login succeeds",
+			name:           "account exists, password is invalid, authentication is disabled, login succeeds",
+			advertisedHost: "127.0.0.1:5190",
 			cfg: config.Config{
-				OSCARHost:   "127.0.0.1",
-				BOSPort:     "1234",
 				DisableAuth: true,
 			},
 			inputSNAC: wire.FLAPSignonFrame{
@@ -964,7 +923,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 					cookieIssueParams: cookieIssueParams{
 						{
 							dataIn: func() []byte {
-								loginCookie := bosCookie{
+								loginCookie := state.ServerCookie{
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -982,7 +941,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			expectOutput: wire.TLVRestBlock{
 				TLVList: wire.TLVList{
 					wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
-					wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:1234"),
+					wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 					wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
 				},
 			},
@@ -1010,11 +969,8 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			wantErr: io.EOF,
 		},
 		{
-			name: "login with AIM 1.1.19 for Java - success",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "login with AIM 1.1.19 for Java - success",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.FLAPSignonFrame{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -1037,7 +993,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 					cookieIssueParams: cookieIssueParams{
 						{
 							dataIn: func() []byte {
-								loginCookie := bosCookie{
+								loginCookie := state.ServerCookie{
 									ScreenName: user.DisplayScreenName,
 									ClientID:   "AOL Instant Messenger (TM) version 1.1.19 for Java",
 								}
@@ -1053,17 +1009,14 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 			expectOutput: wire.TLVRestBlock{
 				TLVList: wire.TLVList{
 					wire.NewTLVBE(wire.LoginTLVTagsScreenName, user.DisplayScreenName),
-					wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:1234"),
+					wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "127.0.0.1:5190"),
 					wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("the-cookie")),
 				},
 			},
 		},
 		{
-			name: "login with AIM 1.1.19 for Java - failed",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "login with AIM 1.1.19 for Java - failed",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.FLAPSignonFrame{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -1116,7 +1069,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 				cookieBaker: cookieBaker,
 				userManager: userManager,
 			}
-			outputSNAC, err := svc.FLAPLogin(context.Background(), tc.inputSNAC, tc.newUserFn)
+			outputSNAC, err := svc.FLAPLogin(context.Background(), tc.inputSNAC, tc.newUserFn, tc.advertisedHost)
 			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
@@ -1134,6 +1087,8 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
+		// advertisedHost is the BOS host the client will connect to upon successful login
+		advertisedHost string
 		// cfg is the app configuration
 		cfg config.Config
 		// inputSNAC is the kerberos SNAC sent from the client to the server
@@ -1151,11 +1106,8 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 		timeNow func() time.Time
 	}{
 		{
-			name: "AIM account exists, correct password, login OK",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "AIM account exists, correct password, login OK",
+			advertisedHost: "127.0.0.1:5190",
 			timeNow: func() time.Time {
 				return time.Unix(1000, 0)
 			},
@@ -1183,7 +1135,7 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 					cookieIssueParams: cookieIssueParams{
 						{
 							dataIn: func() []byte {
-								loginCookie := bosCookie{
+								loginCookie := state.ServerCookie{
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -1224,7 +1176,7 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 										Unknown: 1,
 										ConnectionInfo: wire.TLVBlock{
 											TLVList: wire.TLVList{
-												wire.NewTLVBE(wire.KerberosTLVHostname, "127.0.0.1:1234"),
+												wire.NewTLVBE(wire.KerberosTLVHostname, "127.0.0.1:5190"),
 												wire.NewTLVBE(wire.KerberosTLVCookie, []byte("the-cookie")),
 											},
 										},
@@ -1237,11 +1189,8 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 			},
 		},
 		{
-			name: "AIM account exists, incorrect password, login failed",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "AIM account exists, incorrect password, login failed",
+			advertisedHost: "127.0.0.1:5190",
 			timeNow: func() time.Time {
 				return time.Unix(1000, 0)
 			},
@@ -1301,7 +1250,7 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 				userManager: userManager,
 				timeNow:     tc.timeNow,
 			}
-			outputSNAC, err := svc.KerberosLogin(context.Background(), tc.inputSNAC, tc.newUserFn)
+			outputSNAC, err := svc.KerberosLogin(context.Background(), tc.inputSNAC, tc.newUserFn, tc.advertisedHost)
 			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
@@ -1313,6 +1262,8 @@ func TestAuthService_BUCPChallengeRequest(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
+		// advertisedHost is the BOS host the client will connect to upon successful login
+		advertisedHost string
 		// cfg is the app configuration
 		cfg config.Config
 		// inputSNAC is the SNAC sent from the client to the server
@@ -1326,11 +1277,8 @@ func TestAuthService_BUCPChallengeRequest(t *testing.T) {
 		wantErr error
 	}{
 		{
-			name: "login with valid username, expect OK login response",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "login with valid username, expect OK login response",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.SNAC_0x17_0x06_BUCPChallengeRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -1364,9 +1312,8 @@ func TestAuthService_BUCPChallengeRequest(t *testing.T) {
 		{
 			name: "login with invalid username, expect OK login response (Cfg.DisableAuth=true)",
 			cfg: config.Config{
-				OSCARHost:   "127.0.0.1",
-				BOSPort:     "1234",
-				DisableAuth: true,
+				BOSAdvertisedHosts: "127.0.0.1:5190",
+				DisableAuth:        true,
 			},
 			inputSNAC: wire.SNAC_0x17_0x06_BUCPChallengeRequest{
 				TLVRestBlock: wire.TLVRestBlock{
@@ -1396,11 +1343,8 @@ func TestAuthService_BUCPChallengeRequest(t *testing.T) {
 			},
 		},
 		{
-			name: "login with invalid username, expect failed login response (Cfg.DisableAuth=false)",
-			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				BOSPort:   "1234",
-			},
+			name:           "login with invalid username, expect failed login response (Cfg.DisableAuth=false)",
+			advertisedHost: "127.0.0.1:5190",
 			inputSNAC: wire.SNAC_0x17_0x06_BUCPChallengeRequest{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
@@ -1480,54 +1424,41 @@ func TestAuthService_BUCPChallengeRequest(t *testing.T) {
 func TestAuthService_RegisterChatSession_HappyPath(t *testing.T) {
 	sess := newTestSession("ScreenName")
 
-	chatCookie := "the-chat-cookie"
+	serverCookie := state.ServerCookie{
+		ChatCookie: "the-chat-cookie",
+		ScreenName: sess.DisplayScreenName(),
+	}
+
 	chatSessionRegistry := newMockChatSessionRegistry(t)
 	chatSessionRegistry.EXPECT().
-		AddSession(mock.Anything, chatCookie, sess.DisplayScreenName()).
+		AddSession(mock.Anything, serverCookie.ChatCookie, sess.DisplayScreenName()).
 		Return(sess, nil)
 
-	c := chatLoginCookie{
-		ChatCookie: chatCookie,
-		ScreenName: sess.DisplayScreenName(),
-	}
 	chatCookieBuf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(c, chatCookieBuf))
-
-	authCookie := []byte("the-auth-cookie")
-	cookieBaker := newMockCookieBaker(t)
-	cookieBaker.EXPECT().
-		Crack(authCookie).
-		Return(chatCookieBuf.Bytes(), nil)
+	assert.NoError(t, wire.MarshalBE(serverCookie, chatCookieBuf))
 
-	svc := NewAuthService(config.Config{}, nil, chatSessionRegistry, nil, cookieBaker, nil, nil, nil, wire.DefaultRateLimitClasses())
+	svc := NewAuthService(config.Config{}, nil, nil, chatSessionRegistry, nil, nil, nil, nil, wire.DefaultRateLimitClasses())
 
-	have, err := svc.RegisterChatSession(context.Background(), authCookie)
+	have, err := svc.RegisterChatSession(context.Background(), serverCookie)
 	assert.NoError(t, err)
 	assert.Equal(t, sess, have)
 }
 
 func TestAuthService_RegisterBOSSession(t *testing.T) {
 	screenName := state.DisplayScreenName("UserScreenName")
-	aimAuthCookie := bosCookie{
+	aimAuthCookie := state.ServerCookie{
 		ScreenName: screenName,
 	}
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(aimAuthCookie, buf))
-	aimCookie := buf.Bytes()
-
 	uin := state.DisplayScreenName("100003")
-	icqAuthCookie := bosCookie{
+	icqAuthCookie := state.ServerCookie{
 		ScreenName: uin,
 	}
-	buf = &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(icqAuthCookie, buf))
-	icqCookie := buf.Bytes()
 
 	cases := []struct {
 		// name is the unit test name
 		name string
 		// cookieOut is the auth cookieOut that contains session information
-		cookie []byte
+		cookie state.ServerCookie
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		mockParams mockParams
@@ -1538,16 +1469,8 @@ func TestAuthService_RegisterBOSSession(t *testing.T) {
 	}{
 		{
 			name:   "successfully register an AIM session",
-			cookie: aimCookie,
+			cookie: aimAuthCookie,
 			mockParams: mockParams{
-				cookieBakerParams: cookieBakerParams{
-					cookieCrackParams: cookieCrackParams{
-						{
-							dataOut:  aimCookie,
-							cookieIn: aimCookie,
-						},
-					},
-				},
 				sessionRegistryParams: sessionRegistryParams{
 					addSessionParams: addSessionParams{
 						{
@@ -1582,16 +1505,8 @@ func TestAuthService_RegisterBOSSession(t *testing.T) {
 		},
 		{
 			name:   "successfully register an AIM bot session",
-			cookie: aimCookie,
+			cookie: aimAuthCookie,
 			mockParams: mockParams{
-				cookieBakerParams: cookieBakerParams{
-					cookieCrackParams: cookieCrackParams{
-						{
-							dataOut:  aimCookie,
-							cookieIn: aimCookie,
-						},
-					},
-				},
 				sessionRegistryParams: sessionRegistryParams{
 					addSessionParams: addSessionParams{
 						{
@@ -1627,16 +1542,8 @@ func TestAuthService_RegisterBOSSession(t *testing.T) {
 		},
 		{
 			name:   "successfully register an ICQ session",
-			cookie: icqCookie,
+			cookie: icqAuthCookie,
 			mockParams: mockParams{
-				cookieBakerParams: cookieBakerParams{
-					cookieCrackParams: cookieCrackParams{
-						{
-							dataOut:  icqCookie,
-							cookieIn: icqCookie,
-						},
-					},
-				},
 				sessionRegistryParams: sessionRegistryParams{
 					addSessionParams: addSessionParams{
 						{
@@ -1681,12 +1588,6 @@ func TestAuthService_RegisterBOSSession(t *testing.T) {
 					AddSession(mock.Anything, params.screenName).
 					Return(params.result, params.err)
 			}
-			cookieBaker := newMockCookieBaker(t)
-			for _, params := range tc.mockParams.cookieCrackParams {
-				cookieBaker.EXPECT().
-					Crack(params.cookieIn).
-					Return(params.dataOut, nil)
-			}
 			userManager := newMockUserManager(t)
 			for _, params := range tc.mockParams.userManagerParams.getUserParams {
 				userManager.EXPECT().
@@ -1700,7 +1601,7 @@ func TestAuthService_RegisterBOSSession(t *testing.T) {
 					Return(params.confirmStatus, nil)
 			}
 
-			svc := NewAuthService(config.Config{}, sessionRegistry, nil, userManager, cookieBaker, nil, accountManager, nil, wire.DefaultRateLimitClasses())
+			svc := NewAuthService(config.Config{}, sessionRegistry, nil, nil, userManager, nil, nil, accountManager, wire.DefaultRateLimitClasses())
 
 			have, err := svc.RegisterBOSSession(context.Background(), tc.cookie)
 			assert.NoError(t, err)
@@ -1716,31 +1617,23 @@ func TestAuthService_RegisterBOSSession(t *testing.T) {
 func TestAuthService_RetrieveBOSSession_HappyPath(t *testing.T) {
 	sess := newTestSession("screenName")
 
-	aimAuthCookie := bosCookie{
+	aimAuthCookie := state.ServerCookie{
 		ScreenName: sess.DisplayScreenName(),
 	}
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(aimAuthCookie, buf))
-	authCookie := buf.Bytes()
 
 	sessionRetriever := newMockSessionRetriever(t)
 	sessionRetriever.EXPECT().
 		RetrieveSession(sess.IdentScreenName()).
 		Return(sess)
 
-	cookieBaker := newMockCookieBaker(t)
-	cookieBaker.EXPECT().
-		Crack(authCookie).
-		Return(authCookie, nil)
-
 	userManager := newMockUserManager(t)
 	userManager.EXPECT().
 		User(matchContext(), sess.IdentScreenName()).
 		Return(&state.User{IdentScreenName: sess.IdentScreenName()}, nil)
 
-	svc := NewAuthService(config.Config{}, nil, nil, userManager, cookieBaker, nil, nil, sessionRetriever, wire.DefaultRateLimitClasses())
+	svc := NewAuthService(config.Config{}, nil, sessionRetriever, nil, userManager, nil, nil, nil, wire.DefaultRateLimitClasses())
 
-	have, err := svc.RetrieveBOSSession(context.Background(), authCookie)
+	have, err := svc.RetrieveBOSSession(context.Background(), aimAuthCookie)
 	assert.NoError(t, err)
 	assert.Equal(t, sess, have)
 }
@@ -1748,32 +1641,23 @@ func TestAuthService_RetrieveBOSSession_HappyPath(t *testing.T) {
 func TestAuthService_RetrieveBOSSession_SessionNotFound(t *testing.T) {
 	sess := newTestSession("screenName")
 
-	aimAuthCookie := bosCookie{
+	aimAuthCookie := state.ServerCookie{
 		ScreenName: sess.DisplayScreenName(),
 	}
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(aimAuthCookie, buf))
-	authCookie := buf.Bytes()
 
 	sessionRetriever := newMockSessionRetriever(t)
 	sessionRetriever.EXPECT().
 		RetrieveSession(sess.IdentScreenName()).
 		Return(nil)
 
-	cookieBaker := newMockCookieBaker(t)
-
-	cookieBaker.EXPECT().
-		Crack(authCookie).
-		Return(authCookie, nil)
-
 	userManager := newMockUserManager(t)
 	userManager.EXPECT().
 		User(matchContext(), sess.IdentScreenName()).
 		Return(&state.User{IdentScreenName: sess.IdentScreenName()}, nil)
 
-	svc := NewAuthService(config.Config{}, nil, nil, userManager, cookieBaker, nil, nil, sessionRetriever, wire.DefaultRateLimitClasses())
+	svc := NewAuthService(config.Config{}, nil, sessionRetriever, nil, userManager, nil, nil, nil, wire.DefaultRateLimitClasses())
 
-	have, err := svc.RetrieveBOSSession(context.Background(), authCookie)
+	have, err := svc.RetrieveBOSSession(context.Background(), aimAuthCookie)
 	assert.NoError(t, err)
 	assert.Nil(t, have)
 }
@@ -1864,7 +1748,7 @@ func TestAuthService_SignoutChat(t *testing.T) {
 					RemoveSession(matchSession(params.screenName))
 			}
 
-			svc := NewAuthService(config.Config{}, nil, sessionManager, nil, nil, chatMessageRelayer, nil, nil, wire.DefaultRateLimitClasses())
+			svc := NewAuthService(config.Config{}, nil, nil, sessionManager, nil, nil, chatMessageRelayer, nil, wire.DefaultRateLimitClasses())
 			svc.SignoutChat(context.Background(), tt.userSession)
 		})
 	}

+ 217 - 308
foodgroup/oservice.go

@@ -6,7 +6,6 @@ import (
 	"errors"
 	"fmt"
 	"log/slog"
-	"net"
 	"time"
 
 	"github.com/mk6i/retro-aim-server/config"
@@ -18,12 +17,44 @@ import (
 // provides an assortment of services useful across multiple food groups.
 type OServiceService struct {
 	buddyBroadcaster buddyBroadcaster
-	cfg              config.Config
+	cfg              config.Config // todo remove
 	logger           *slog.Logger
-	foodGroups       []uint16
 	rateLimitClasses wire.RateLimitClasses
 	snacRateLimits   wire.SNACRateLimits
 	timeNow          func() time.Time
+
+	chatRoomManager    ChatRoomRegistry
+	cookieIssuer       CookieBaker
+	messageRelayer     MessageRelayer
+	chatMessageRelayer ChatMessageRelayer
+}
+
+// NewOServiceService creates a new instance of NewOServiceService.
+func NewOServiceService(
+	cfg config.Config,
+	messageRelayer MessageRelayer,
+	logger *slog.Logger,
+	cookieIssuer CookieBaker,
+	chatRoomManager ChatRoomRegistry,
+	relationshipFetcher RelationshipFetcher,
+	sessionRetriever SessionRetriever,
+	buddyIconManager BuddyIconManager,
+	rateLimitClasses wire.RateLimitClasses,
+	snacRateLimits wire.SNACRateLimits,
+	chatMessageRelayer ChatMessageRelayer,
+) *OServiceService {
+	return &OServiceService{
+		cookieIssuer:       cookieIssuer,
+		messageRelayer:     messageRelayer,
+		buddyBroadcaster:   newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
+		cfg:                cfg,
+		logger:             logger,
+		rateLimitClasses:   rateLimitClasses,
+		snacRateLimits:     snacRateLimits,
+		timeNow:            time.Now,
+		chatRoomManager:    chatRoomManager,
+		chatMessageRelayer: chatMessageRelayer,
+	}
 }
 
 // ClientVersions informs the server what food group versions the client
@@ -273,40 +304,125 @@ func (s OServiceService) RateParamsSubAdd(ctx context.Context, sess *state.Sessi
 	sess.SubscribeRateLimits(ids)
 }
 
-func (s OServiceService) ServiceRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest) (wire.SNACMessage, error) {
-	return wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceErr,
-			RequestID: frame.RequestID,
-		},
-		Body: wire.SNACError{
-			Code: wire.ErrorCodeNotSupportedByHost,
-		},
-	}, nil
-}
-
-// ClientOnline informs the server that the client is ready.
-func (s OServiceService) ClientOnline(ctx context.Context, bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error {
-	s.logger.DebugContext(ctx, "client is online", "group_versions", bodyIn.GroupVersions)
-	return nil
-}
+// HostOnline returns SNAC wire.OServiceHostOnline containing the list of food
+// groups supported by the particular service.
+func (s OServiceService) HostOnline(service uint16) wire.SNACMessage {
+	switch service {
+	case wire.Admin:
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceHostOnline,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+				FoodGroups: []uint16{
+					wire.OService,
+					wire.Admin,
+				},
+			},
+		}
+	case wire.Alert:
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceHostOnline,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+				FoodGroups: []uint16{
+					wire.Alert,
+					wire.OService,
+				},
+			},
+		}
+	case wire.BART:
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceHostOnline,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+				FoodGroups: []uint16{
+					wire.BART,
+					wire.OService,
+				},
+			},
+		}
+	case wire.BOS:
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceHostOnline,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+				FoodGroups: []uint16{
+					wire.Alert,
+					wire.BART,
+					wire.Buddy,
+					wire.Feedbag,
+					wire.ICBM,
+					wire.ICQ,
+					wire.Locate,
+					wire.OService,
+					wire.PermitDeny,
+					wire.UserLookup,
+					wire.Invite,
+					wire.Popup,
+					wire.Stats,
+				},
+			},
+		}
+	case wire.Chat:
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceHostOnline,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+				FoodGroups: []uint16{
+					wire.OService,
+					wire.Chat,
+				},
+			},
+		}
+	case wire.ChatNav:
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceHostOnline,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+				FoodGroups: []uint16{
+					wire.ChatNav,
+					wire.OService,
+				},
+			},
+		}
+	case wire.ODir:
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceHostOnline,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+				FoodGroups: []uint16{
+					wire.ODir,
+					wire.OService,
+				},
+			},
+		}
+	}
 
-// HostOnline initiates the Alert protocol sequence.
-// It returns SNAC wire.OServiceHostOnline containing the list of food groups
-// supported by the Alert service.
-// Alert is provided by BOS in addition to the standalone Alert service.
-// AIM 4.x always creates a secondary TCP connection for Alert, whereas 5.x
-// can use the existing BOS connection for Alert services.
-func (s OServiceService) HostOnline() wire.SNACMessage {
 	return wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceHostOnline,
-			RequestID: wire.ReqIDFromServer,
-		},
-		Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
-			FoodGroups: s.foodGroups,
+			SubGroup:  wire.OServiceErr,
 		},
 	}
 }
@@ -394,68 +510,22 @@ func buildRateLimitUpdate(code uint16, curRate state.RateClassState, sess *state
 	}
 }
 
-// NewOServiceServiceForBOS creates a new instance of OServiceServiceForBOS.
-func NewOServiceServiceForBOS(
-	cfg config.Config,
-	messageRelayer MessageRelayer,
-	logger *slog.Logger,
-	cookieIssuer CookieBaker,
-	chatRoomManager ChatRoomRegistry,
-	relationshipFetcher RelationshipFetcher,
-	sessionRetriever SessionRetriever,
-	buddyIconManager BuddyIconManager,
-	rateLimitClasses wire.RateLimitClasses,
-	snacRateLimits wire.SNACRateLimits,
-) *OServiceServiceForBOS {
-	return &OServiceServiceForBOS{
-		chatRoomManager: chatRoomManager,
-		cookieIssuer:    cookieIssuer,
-		messageRelayer:  messageRelayer,
-		OServiceService: OServiceService{
-			buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
-			cfg:              cfg,
-			logger:           logger,
-			foodGroups: []uint16{
-				wire.Alert,
-				wire.BART,
-				wire.Buddy,
-				wire.Feedbag,
-				wire.ICBM,
-				wire.ICQ,
-				wire.Locate,
-				wire.OService,
-				wire.PermitDeny,
-				wire.UserLookup,
-				wire.Invite,
-				wire.Popup,
-				wire.Stats,
-			},
-			rateLimitClasses: rateLimitClasses,
-			snacRateLimits:   snacRateLimits,
-			timeNow:          time.Now,
-		},
-	}
-}
-
-// OServiceServiceForBOS provides functionality for the OService food group
-// running on the BOS server.
-type OServiceServiceForBOS struct {
-	OServiceService
-	chatRoomManager ChatRoomRegistry
-	cookieIssuer    CookieBaker
-	messageRelayer  MessageRelayer
-}
-
-// chatLoginCookie represents credentials used to authenticate a user chat
-// session.
-type chatLoginCookie struct {
-	ChatCookie string                  `oscar:"len_prefix=uint8"`
-	ScreenName state.DisplayScreenName `oscar:"len_prefix=uint8"`
-}
-
 // ServiceRequest handles service discovery, providing a host name and metadata
 // for connecting to the food group service specified in inFrame.
-func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest) (wire.SNACMessage, error) {
+func (s OServiceService) ServiceRequest(ctx context.Context, service uint16, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, advertisedHost string) (wire.SNACMessage, error) {
+	if service != wire.BOS {
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceErr,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNACError{
+				Code: wire.ErrorCodeNotSupportedByHost,
+			},
+		}, nil
+	}
+
 	fnIssueCookie := func(val any) ([]byte, error) {
 		buf := &bytes.Buffer{}
 		if err := wire.MarshalBE(val, buf); err != nil {
@@ -466,7 +536,8 @@ func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.S
 
 	switch inBody.FoodGroup {
 	case wire.Admin:
-		cookie, err := fnIssueCookie(bosCookie{
+		cookie, err := fnIssueCookie(state.ServerCookie{
+			Service:    wire.Admin,
 			ScreenName: sess.DisplayScreenName(),
 		})
 		if err != nil {
@@ -481,7 +552,7 @@ func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.S
 			Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
-						wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, net.JoinHostPort(s.cfg.OSCARHost, s.cfg.AdminPort)),
+						wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, advertisedHost),
 						wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, cookie),
 						wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.Admin),
 						wire.NewTLVBE(wire.OServiceTLVTagsSSLCertName, ""),
@@ -491,7 +562,8 @@ func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.S
 			},
 		}, nil
 	case wire.Alert:
-		cookie, err := fnIssueCookie(bosCookie{
+		cookie, err := fnIssueCookie(state.ServerCookie{
+			Service:    wire.Alert,
 			ScreenName: sess.DisplayScreenName(),
 		})
 		if err != nil {
@@ -506,7 +578,7 @@ func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.S
 			Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
-						wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, net.JoinHostPort(s.cfg.OSCARHost, s.cfg.AlertPort)),
+						wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, advertisedHost),
 						wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, cookie),
 						wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.Alert),
 						wire.NewTLVBE(wire.OServiceTLVTagsSSLCertName, ""),
@@ -516,7 +588,8 @@ func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.S
 			},
 		}, nil
 	case wire.BART:
-		cookie, err := fnIssueCookie(bosCookie{
+		cookie, err := fnIssueCookie(state.ServerCookie{
+			Service:    wire.BART,
 			ScreenName: sess.DisplayScreenName(),
 		})
 		if err != nil {
@@ -531,7 +604,7 @@ func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.S
 			Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
-						wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, net.JoinHostPort(s.cfg.OSCARHost, s.cfg.BARTPort)),
+						wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, advertisedHost),
 						wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, cookie),
 						wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.BART),
 						wire.NewTLVBE(wire.OServiceTLVTagsSSLCertName, ""),
@@ -541,7 +614,8 @@ func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.S
 			},
 		}, nil
 	case wire.ChatNav:
-		cookie, err := fnIssueCookie(bosCookie{
+		cookie, err := fnIssueCookie(state.ServerCookie{
+			Service:    wire.ChatNav,
 			ScreenName: sess.DisplayScreenName(),
 		})
 		if err != nil {
@@ -556,7 +630,7 @@ func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.S
 			Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
-						wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, net.JoinHostPort(s.cfg.OSCARHost, s.cfg.ChatNavPort)),
+						wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, advertisedHost),
 						wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, cookie),
 						wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.ChatNav),
 						wire.NewTLVBE(wire.OServiceTLVTagsSSLCertName, ""),
@@ -581,7 +655,8 @@ func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.S
 			return wire.SNACMessage{}, fmt.Errorf("unable to retrieve room info: %w", err)
 		}
 
-		cookie, err := fnIssueCookie(chatLoginCookie{
+		cookie, err := fnIssueCookie(state.ServerCookie{
+			Service:    wire.Chat,
 			ChatCookie: room.Cookie(),
 			ScreenName: sess.DisplayScreenName(),
 		})
@@ -598,7 +673,7 @@ func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.S
 			Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
-						wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, net.JoinHostPort(s.cfg.OSCARHost, s.cfg.ChatPort)),
+						wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, advertisedHost),
 						wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, cookie),
 						wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.Chat),
 						wire.NewTLVBE(wire.OServiceTLVTagsSSLCertName, ""),
@@ -608,7 +683,8 @@ func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.S
 			},
 		}, nil
 	case wire.ODir:
-		cookie, err := fnIssueCookie(bosCookie{
+		cookie, err := fnIssueCookie(state.ServerCookie{
+			Service:    wire.ODir,
 			ScreenName: sess.DisplayScreenName(),
 		})
 		if err != nil {
@@ -623,7 +699,7 @@ func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.S
 			Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
-						wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, net.JoinHostPort(s.cfg.OSCARHost, s.cfg.ODirPort)),
+						wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, advertisedHost),
 						wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, cookie),
 						wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.ODir),
 						wire.NewTLVBE(wire.OServiceTLVTagsSSLCertName, ""),
@@ -648,217 +724,50 @@ func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.S
 }
 
 // ClientOnline runs when the current user is ready to join.
-// It announces current user's arrival to users who have the current user on
-// their buddy list.
-func (s OServiceServiceForBOS) ClientOnline(ctx context.Context, _ wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error {
-	sess.SetSignonComplete()
-
-	if err := s.buddyBroadcaster.BroadcastVisibility(ctx, sess, nil, false); err != nil {
-		return fmt.Errorf("unable to send buddy arrival notification: %w", err)
-	}
-
-	msg := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Stats,
-			SubGroup:  wire.StatsSetMinReportInterval,
-			RequestID: wire.ReqIDFromServer,
-		},
-		Body: wire.SNAC_0x0B_0x02_StatsSetMinReportInterval{
-			MinReportInterval: 1,
-		},
-	}
-	s.messageRelayer.RelayToScreenName(ctx, sess.IdentScreenName(), msg)
-
-	return nil
-}
-
-// NewOServiceServiceForChat creates a new instance of NewOServiceServiceForChat.
-func NewOServiceServiceForChat(
-	cfg config.Config,
-	logger *slog.Logger,
-	messageRelayer MessageRelayer,
-	chatRoomManager ChatRoomRegistry,
-	chatMessageRelayer ChatMessageRelayer,
-	relationshipFetcher RelationshipFetcher,
-	sessionRetriever SessionRetriever,
-	buddyIconManager BuddyIconManager,
-	rateLimitClasses wire.RateLimitClasses,
-	snacRateLimits wire.SNACRateLimits,
-) *OServiceServiceForChat {
-	return &OServiceServiceForChat{
-		OServiceService: OServiceService{
-			buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
-			cfg:              cfg,
-			logger:           logger,
-			foodGroups: []uint16{
-				wire.OService,
-				wire.Chat,
-			},
-			rateLimitClasses: rateLimitClasses,
-			snacRateLimits:   snacRateLimits,
-			timeNow:          time.Now,
-		},
-		chatRoomManager:    chatRoomManager,
-		chatMessageRelayer: chatMessageRelayer,
-	}
-}
-
-// OServiceServiceForChat provides functionality for the OService food group
-// running on the Chat server.
-type OServiceServiceForChat struct {
-	OServiceService
-	chatRoomManager    ChatRoomRegistry
-	chatMessageRelayer ChatMessageRelayer
-}
-
-// ClientOnline runs when the current user is ready to join the chat.
-// Trigger the following actions:
+// If BOS:
+//   - Announce current user's arrival to users who have the current user on their buddy list
+//
+// If Chat:
 //   - Send current user the chat room metadata
 //   - Announce current user's arrival to other chat room participants
 //   - Send current user the chat room participant list
-func (s OServiceServiceForChat) ClientOnline(ctx context.Context, _ wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error {
-	room, err := s.chatRoomManager.ChatRoomByCookie(ctx, sess.ChatRoomCookie())
-	if err != nil {
-		return fmt.Errorf("error getting chat room: %w", err)
-	}
-
-	// Do not change the order of the following 3 methods. macOS client v4.0.9
-	// requires this exact sequence, otherwise the chat session prematurely
-	// closes seconds after users join a chat room.
-	setOnlineChatUsers(ctx, sess, s.chatMessageRelayer)
-	sendChatRoomInfoUpdate(ctx, sess, s.chatMessageRelayer, room)
-	alertUserJoined(ctx, sess, s.chatMessageRelayer)
+func (s OServiceService) ClientOnline(ctx context.Context, service uint16, bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error {
+	sess.SetSignonComplete()
 
-	return nil
-}
+	switch service {
+	case wire.BOS:
 
-// NewOServiceServiceForChatNav creates a new instance of OServiceService for
-// ChatNav.
-func NewOServiceServiceForChatNav(
-	cfg config.Config,
-	logger *slog.Logger,
-	messageRelayer MessageRelayer,
-	relationshipFetcher RelationshipFetcher,
-	sessionRetriever SessionRetriever,
-	buddyIconManager BuddyIconManager,
-	rateLimitClasses wire.RateLimitClasses,
-	snacRateLimits wire.SNACRateLimits,
-) *OServiceService {
-	return &OServiceService{
-		buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
-		cfg:              cfg,
-		logger:           logger,
-		foodGroups: []uint16{
-			wire.ChatNav,
-			wire.OService,
-		},
-		rateLimitClasses: rateLimitClasses,
-		snacRateLimits:   snacRateLimits,
-		timeNow:          time.Now,
-	}
-}
-
-// NewOServiceServiceForAlert creates a new instance of OServiceService for the Alert
-// server.
-func NewOServiceServiceForAlert(
-	cfg config.Config,
-	logger *slog.Logger,
-	messageRelayer MessageRelayer,
-	relationshipFetcher RelationshipFetcher,
-	sessionRetriever SessionRetriever,
-	buddyIconManager BuddyIconManager,
-	rateLimitClasses wire.RateLimitClasses,
-	snacRateLimits wire.SNACRateLimits,
-) *OServiceService {
-	return &OServiceService{
-		buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
-		cfg:              cfg,
-		logger:           logger,
-		foodGroups: []uint16{
-			wire.Alert,
-			wire.OService,
-		},
-		rateLimitClasses: rateLimitClasses,
-		snacRateLimits:   snacRateLimits,
-		timeNow:          time.Now,
-	}
-}
-
-// NewOServiceServiceForODir creates a new instance of OServiceService for the
-// ODir server.
-func NewOServiceServiceForODir(
-	cfg config.Config,
-	logger *slog.Logger,
-	rateLimitClasses wire.RateLimitClasses,
-	snacRateLimits wire.SNACRateLimits,
-	messageRelayer MessageRelayer,
-	relationshipFetcher RelationshipFetcher,
-	sessionRetriever SessionRetriever,
-	buddyIconManager BuddyIconManager,
-) *OServiceService {
-	return &OServiceService{
-		buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
-		cfg:              cfg,
-		logger:           logger,
-		foodGroups: []uint16{
-			wire.ODir,
-			wire.OService,
-		},
-		rateLimitClasses: rateLimitClasses,
-		snacRateLimits:   snacRateLimits,
-		timeNow:          time.Now,
-	}
-}
+		if err := s.buddyBroadcaster.BroadcastVisibility(ctx, sess, nil, false); err != nil {
+			return fmt.Errorf("unable to send buddy arrival notification: %w", err)
+		}
 
-// NewOServiceServiceForAdmin creates a new instance of OServiceService for Admin server.
-func NewOServiceServiceForAdmin(
-	cfg config.Config,
-	logger *slog.Logger,
-	messageRelayer MessageRelayer,
-	relationshipFetcher RelationshipFetcher,
-	sessionRetriever SessionRetriever,
-	buddyIconManager BuddyIconManager,
-	rateLimitClasses wire.RateLimitClasses,
-	snacRateLimits wire.SNACRateLimits,
-) *OServiceService {
-	return &OServiceService{
-		buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
-		cfg:              cfg,
-		logger:           logger,
-		foodGroups: []uint16{
-			wire.OService,
-			wire.Admin,
-		},
-		rateLimitClasses: rateLimitClasses,
-		snacRateLimits:   snacRateLimits,
-		timeNow:          time.Now,
-	}
-}
+		msg := wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Stats,
+				SubGroup:  wire.StatsSetMinReportInterval,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: wire.SNAC_0x0B_0x02_StatsSetMinReportInterval{
+				MinReportInterval: 1,
+			},
+		}
+		s.messageRelayer.RelayToScreenName(ctx, sess.IdentScreenName(), msg)
+	case wire.Chat:
+		room, err := s.chatRoomManager.ChatRoomByCookie(ctx, sess.ChatRoomCookie())
+		if err != nil {
+			return fmt.Errorf("error getting chat room: %w", err)
+		}
 
-// NewOServiceServiceForBART creates a new instance of OServiceService for the
-// BART server.
-func NewOServiceServiceForBART(
-	cfg config.Config,
-	logger *slog.Logger,
-	messageRelayer MessageRelayer,
-	relationshipFetcher RelationshipFetcher,
-	sessionRetriever SessionRetriever,
-	buddyIconManager BuddyIconManager,
-	rateLimitClasses wire.RateLimitClasses,
-	snacRateLimits wire.SNACRateLimits,
-) *OServiceService {
-	return &OServiceService{
-		buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
-		cfg:              cfg,
-		logger:           logger,
-		foodGroups: []uint16{
-			wire.BART,
-			wire.OService,
-		},
-		rateLimitClasses: rateLimitClasses,
-		snacRateLimits:   snacRateLimits,
-		timeNow:          time.Now,
+		// Do not change the order of the following 3 methods. macOS client v4.0.9
+		// requires this exact sequence, otherwise the chat session prematurely
+		// closes seconds after users join a chat room.
+		setOnlineChatUsers(ctx, sess, s.chatMessageRelayer)
+		sendChatRoomInfoUpdate(ctx, sess, s.chatMessageRelayer, room)
+		alertUserJoined(ctx, sess, s.chatMessageRelayer)
+	default:
+		s.logger.DebugContext(ctx, "client is online", "group_versions", bodyIn.GroupVersions)
 	}
+	return nil
 }
 
 // newOServiceUserInfoUpdate constructs SNAC(0x01,0x0F) for user info updates.

+ 348 - 170
foodgroup/oservice_test.go

@@ -15,12 +15,14 @@ import (
 	"github.com/mk6i/retro-aim-server/wire"
 )
 
-func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
+func TestOServiceService_ServiceRequest(t *testing.T) {
 	chatRoom := state.NewChatRoom("the-chat-room", state.NewIdentScreenName(""), state.PrivateExchange)
 
 	cases := []struct {
 		// name is the unit test name
 		name string
+		// service is the OSCAR service type
+		service uint16
 		// config is the application config
 		cfg config.Config
 		// userSession is the session of the user requesting the chat service
@@ -38,32 +40,59 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 		expectErr error
 	}{
 		{
-			name:        "request info for ICBM service, return invalid SNAC err",
+			name:    "request info for connecting to admin svc, return admin svc connection metadata",
+			service: wire.BOS,
+			cfg: config.Config{
+				BOSAdvertisedHosts: "127.0.0.1:1234",
+			},
 			userSession: newTestSession("me"),
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x01_0x04_OServiceServiceRequest{
-					FoodGroup: wire.ICBM,
+					FoodGroup: wire.Admin,
 				},
 			},
 			expectOutput: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					FoodGroup: wire.OService,
-					SubGroup:  wire.OServiceErr,
+					SubGroup:  wire.OServiceServiceResponse,
 					RequestID: 1234,
 				},
-				Body: wire.SNACError{
-					Code: wire.ErrorCodeServiceUnavailable,
+				Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, "127.0.0.1:1234"),
+							wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("the-cookie")),
+							wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.Admin),
+							wire.NewTLVBE(wire.OServiceTLVTagsSSLCertName, ""),
+							wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				cookieBakerParams: cookieBakerParams{
+					cookieIssueParams: cookieIssueParams{
+						{
+							dataIn: []byte{
+								0x00, 0x07, // admin service
+								0x02, 'm', 'e',
+								0x0, // no client ID
+								0x0, // no chat cookie
+							},
+							cookieOut: []byte("the-cookie"),
+						},
+					},
 				},
 			},
 		},
 		{
-			name: "request info for connecting to chat nav, return chat nav connection metadata",
+			name:    "request info for connecting to alert svc, return alert svc connection metadata",
+			service: wire.BOS,
 			cfg: config.Config{
-				OSCARHost:   "127.0.0.1",
-				ChatNavPort: "1234",
+				BOSAdvertisedHosts: "127.0.0.1:1234",
 			},
 			userSession: newTestSession("me"),
 			inputSNAC: wire.SNACMessage{
@@ -71,7 +100,7 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x01_0x04_OServiceServiceRequest{
-					FoodGroup: wire.ChatNav,
+					FoodGroup: wire.Alert,
 				},
 			},
 			expectOutput: wire.SNACMessage{
@@ -85,7 +114,7 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 						TLVList: wire.TLVList{
 							wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, "127.0.0.1:1234"),
 							wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("the-cookie")),
-							wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.ChatNav),
+							wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.Alert),
 							wire.NewTLVBE(wire.OServiceTLVTagsSSLCertName, ""),
 							wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
 						},
@@ -97,8 +126,10 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 					cookieIssueParams: cookieIssueParams{
 						{
 							dataIn: []byte{
+								0x00, 0x18, // alert service
 								0x02, 'm', 'e',
 								0x0, // no client ID
+								0x0, // no chat cookie
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -107,10 +138,10 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 			},
 		},
 		{
-			name: "request info for connecting to alert svc, return alert svc connection metadata",
+			name:    "request info for connecting to BART service, return BART connection metadata",
+			service: wire.BOS,
 			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				AlertPort: "1234",
+				BOSAdvertisedHosts: "127.0.0.1:1234",
 			},
 			userSession: newTestSession("me"),
 			inputSNAC: wire.SNACMessage{
@@ -118,7 +149,7 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x01_0x04_OServiceServiceRequest{
-					FoodGroup: wire.Alert,
+					FoodGroup: wire.BART,
 				},
 			},
 			expectOutput: wire.SNACMessage{
@@ -132,7 +163,7 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 						TLVList: wire.TLVList{
 							wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, "127.0.0.1:1234"),
 							wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("the-cookie")),
-							wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.Alert),
+							wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.BART),
 							wire.NewTLVBE(wire.OServiceTLVTagsSSLCertName, ""),
 							wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
 						},
@@ -144,8 +175,10 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 					cookieIssueParams: cookieIssueParams{
 						{
 							dataIn: []byte{
+								0x00, 0x10, // chatnav service
 								0x02, 'm', 'e',
 								0x0, // no client ID
+								0x0, // no chat cookie
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -154,10 +187,10 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 			},
 		},
 		{
-			name: "request info for connecting to admin svc, return admin svc connection metadata",
+			name:    "request info for connecting to chat nav, return chat nav connection metadata",
+			service: wire.BOS,
 			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				AdminPort: "1234",
+				BOSAdvertisedHosts: "127.0.0.1:1234",
 			},
 			userSession: newTestSession("me"),
 			inputSNAC: wire.SNACMessage{
@@ -165,7 +198,7 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 					RequestID: 1234,
 				},
 				Body: wire.SNAC_0x01_0x04_OServiceServiceRequest{
-					FoodGroup: wire.Admin,
+					FoodGroup: wire.ChatNav,
 				},
 			},
 			expectOutput: wire.SNACMessage{
@@ -179,7 +212,7 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 						TLVList: wire.TLVList{
 							wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, "127.0.0.1:1234"),
 							wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("the-cookie")),
-							wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.Admin),
+							wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.ChatNav),
 							wire.NewTLVBE(wire.OServiceTLVTagsSSLCertName, ""),
 							wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
 						},
@@ -191,8 +224,10 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 					cookieIssueParams: cookieIssueParams{
 						{
 							dataIn: []byte{
+								0x00, 0x0d, // chatnav service
 								0x02, 'm', 'e',
 								0x0, // no client ID
+								0x0, // no chat cookie
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -201,10 +236,10 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 			},
 		},
 		{
-			name: "request info for connecting to chat room, return chat service and chat room metadata",
+			name:    "request info for connecting to chat room, return chat service and chat room metadata",
+			service: wire.BOS,
 			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				ChatPort:  "1234",
+				BOSAdvertisedHosts: "127.0.0.1:1234",
 			},
 			userSession: newTestSession("me"),
 			inputSNAC: wire.SNACMessage{
@@ -256,8 +291,10 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 						cookieIssueParams: cookieIssueParams{
 							{
 								dataIn: []byte{
+									0x00, 0x0e, // chat service,
+									0x02, 'm', 'e', // screen name
+									0x00, // no client ID
 									0x11, '4', '-', '0', '-', 't', 'h', 'e', '-', 'c', 'h', 'a', 't', '-', 'r', 'o', 'o', 'm',
-									0x02, 'm', 'e',
 								},
 								cookieOut: []byte("the-auth-cookie"),
 							},
@@ -267,10 +304,10 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 			}(),
 		},
 		{
-			name: "request info for connecting to non-existent chat room, return ErrChatRoomNotFound",
+			name:    "request info for connecting to non-existent chat room, return ErrChatRoomNotFound",
+			service: wire.BOS,
 			cfg: config.Config{
-				OSCARHost: "127.0.0.1",
-				ChatPort:  "1234",
+				BOSAdvertisedHosts: "127.0.0.1:1234",
 			},
 			userSession: newTestSession("me"),
 			inputSNAC: wire.SNACMessage{
@@ -302,6 +339,101 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 			},
 			expectErr: state.ErrChatRoomNotFound,
 		},
+		{
+			name:        "request info from a non-BOS service",
+			service:     wire.Chat,
+			userSession: newTestSession("me"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x04_OServiceServiceRequest{
+					FoodGroup: wire.ICBM,
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceErr,
+					RequestID: 1234,
+				},
+				Body: wire.SNACError{
+					Code: wire.ErrorCodeNotSupportedByHost,
+				},
+			},
+		},
+		{
+			name:        "request info for ICBM service, return invalid SNAC err",
+			service:     wire.BOS,
+			userSession: newTestSession("me"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x04_OServiceServiceRequest{
+					FoodGroup: wire.ICBM,
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceErr,
+					RequestID: 1234,
+				},
+				Body: wire.SNACError{
+					Code: wire.ErrorCodeServiceUnavailable,
+				},
+			},
+		},
+		{
+			name:    "request info for connecting to BART service, return BART connection metadata",
+			service: wire.BOS,
+			cfg: config.Config{
+				BOSAdvertisedHosts: "127.0.0.1:1234",
+			},
+			userSession: newTestSession("me"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x04_OServiceServiceRequest{
+					FoodGroup: wire.ODir,
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceServiceResponse,
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.OServiceTLVTagsReconnectHere, "127.0.0.1:1234"),
+							wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("the-cookie")),
+							wire.NewTLVBE(wire.OServiceTLVTagsGroupID, wire.ODir),
+							wire.NewTLVBE(wire.OServiceTLVTagsSSLCertName, ""),
+							wire.NewTLVBE(wire.OServiceTLVTagsSSLState, uint8(0x00)),
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				cookieBakerParams: cookieBakerParams{
+					cookieIssueParams: cookieIssueParams{
+						{
+							dataIn: []byte{
+								0x00, 0x0F, // chatnav service
+								0x02, 'm', 'e',
+								0x0, // no client ID
+								0x0, // no chat cookie
+							},
+							cookieOut: []byte("the-cookie"),
+						},
+					},
+				},
+			},
+		},
 	}
 
 	for _, tc := range cases {
@@ -321,13 +453,15 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 					Issue(params.dataIn).
 					Return(params.cookieOut, params.err)
 			}
+			chatMessageRelayer := newMockChatMessageRelayer(t)
+
 			//
 			// send input SNAC
 			//
-			svc := NewOServiceServiceForBOS(tc.cfg, nil, slog.Default(), cookieIssuer, chatRoomManager, nil, nil, nil, wire.DefaultRateLimitClasses(), wire.DefaultSNACRateLimits())
+			svc := NewOServiceService(tc.cfg, nil, slog.Default(), cookieIssuer, chatRoomManager, nil, nil, nil, wire.DefaultRateLimitClasses(), wire.DefaultSNACRateLimits(), chatMessageRelayer)
 
-			outputSNAC, err := svc.ServiceRequest(context.Background(), tc.userSession, tc.inputSNAC.Frame,
-				tc.inputSNAC.Body.(wire.SNAC_0x01_0x04_OServiceServiceRequest))
+			outputSNAC, err := svc.ServiceRequest(context.Background(), tc.service, tc.userSession, tc.inputSNAC.Frame,
+				tc.inputSNAC.Body.(wire.SNAC_0x01_0x04_OServiceServiceRequest), tc.cfg.BOSAdvertisedHosts)
 			assert.ErrorIs(t, err, tc.expectErr)
 			if tc.expectErr != nil {
 				return
@@ -340,7 +474,7 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) {
 	}
 }
 
-func TestSetUserInfoFields(t *testing.T) {
+func TestOServiceService_SetUserInfoFields(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
@@ -1066,58 +1200,165 @@ func TestOServiceService_RateParamsQuery(t *testing.T) {
 	}
 }
 
-func TestOServiceServiceForBOS_OServiceHostOnline(t *testing.T) {
-	cookieIssuer := newMockCookieBaker(t)
-	svc := NewOServiceServiceForBOS(config.Config{}, nil, slog.Default(), cookieIssuer, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), wire.DefaultSNACRateLimits())
-
-	want := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceHostOnline,
-			RequestID: wire.ReqIDFromServer,
+func TestOServiceService_HostOnline(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// service is the OSCAR service type
+		service uint16
+		// expectSNACFrame is the SNAC frame sent from the server to the recipient
+		// client
+		expectOutput wire.SNACMessage
+	}{
+		{
+			name:    "Admin service",
+			service: wire.Admin,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceHostOnline,
+					RequestID: wire.ReqIDFromServer,
+				},
+				Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+					FoodGroups: []uint16{
+						wire.OService,
+						wire.Admin,
+					},
+				},
+			},
+		},
+		{
+			name:    "Alert service",
+			service: wire.Alert,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceHostOnline,
+					RequestID: wire.ReqIDFromServer,
+				},
+				Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+					FoodGroups: []uint16{
+						wire.Alert,
+						wire.OService,
+					},
+				},
+			},
 		},
-		Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
-			FoodGroups: []uint16{
-				wire.Alert,
-				wire.BART,
-				wire.Buddy,
-				wire.Feedbag,
-				wire.ICBM,
-				wire.ICQ,
-				wire.Locate,
-				wire.OService,
-				wire.PermitDeny,
-				wire.UserLookup,
-				wire.Invite,
-				wire.Popup,
-				wire.Stats,
+		{
+			name:    "BART service",
+			service: wire.BART,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceHostOnline,
+					RequestID: wire.ReqIDFromServer,
+				},
+				Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+					FoodGroups: []uint16{
+						wire.BART,
+						wire.OService,
+					},
+				},
 			},
 		},
-	}
-
-	have := svc.HostOnline()
-	assert.Equal(t, want, have)
-}
-
-func TestOServiceServiceForChat_OServiceHostOnline(t *testing.T) {
-	svc := NewOServiceServiceForChat(config.Config{}, slog.Default(), nil, nil, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), wire.DefaultSNACRateLimits())
-
-	want := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceHostOnline,
-			RequestID: wire.ReqIDFromServer,
+		{
+			name:    "BOS service",
+			service: wire.BOS,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceHostOnline,
+					RequestID: wire.ReqIDFromServer,
+				},
+				Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+					FoodGroups: []uint16{
+						wire.Alert,
+						wire.BART,
+						wire.Buddy,
+						wire.Feedbag,
+						wire.ICBM,
+						wire.ICQ,
+						wire.Locate,
+						wire.OService,
+						wire.PermitDeny,
+						wire.UserLookup,
+						wire.Invite,
+						wire.Popup,
+						wire.Stats,
+					},
+				},
+			},
+		},
+		{
+			name:    "Chat service",
+			service: wire.Chat,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceHostOnline,
+					RequestID: wire.ReqIDFromServer,
+				},
+				Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+					FoodGroups: []uint16{
+						wire.OService,
+						wire.Chat,
+					},
+				},
+			},
 		},
-		Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
-			FoodGroups: []uint16{
-				wire.OService,
-				wire.Chat,
+		{
+			name:    "ChatNav service",
+			service: wire.ChatNav,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceHostOnline,
+					RequestID: wire.ReqIDFromServer,
+				},
+				Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+					FoodGroups: []uint16{
+						wire.ChatNav,
+						wire.OService,
+					},
+				},
+			},
+		},
+		{
+			name:    "ODir service",
+			service: wire.ODir,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceHostOnline,
+					RequestID: wire.ReqIDFromServer,
+				},
+				Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
+					FoodGroups: []uint16{
+						wire.ODir,
+						wire.OService,
+					},
+				},
+			},
+		},
+		{
+			name:    "Oops, unsupported service",
+			service: wire.Kerberos,
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceErr,
+				},
 			},
 		},
 	}
 
-	have := svc.HostOnline()
-	assert.Equal(t, want, have)
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			svc := NewOServiceService(config.Config{}, nil, slog.Default(), nil, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), wire.DefaultSNACRateLimits(), nil)
+			have := svc.HostOnline(tc.service)
+			assert.Equal(t, tc.expectOutput, have)
+		})
+	}
 }
 
 func TestOServiceService_ClientVersions(t *testing.T) {
@@ -1265,7 +1506,11 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 	}
 }
 
-func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
+func TestOServiceService_ClientOnline(t *testing.T) {
+	chatRoom := state.NewChatRoom("the-chat-room", state.NewIdentScreenName("creator"), state.PrivateExchange)
+	chatter1 := newTestSession("chatter-1", sessOptChatRoomCookie(chatRoom.Cookie()))
+	chatter2 := newTestSession("chatter-2", sessOptChatRoomCookie(chatRoom.Cookie()))
+
 	tests := []struct {
 		// name is the name of the test
 		name string
@@ -1274,6 +1519,8 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 		// bodyIn is the SNAC body sent from the arriving user's client to the
 		// server
 		bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline
+		// service is the OSCAR service type
+		service uint16
 		// wantErr is the expected error from the handler
 		wantErr error
 		// mockParams is the list of params sent to mocks that satisfy this
@@ -1283,9 +1530,10 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 		wantSess *state.Session
 	}{
 		{
-			name:   "notify that user is online",
-			sess:   newTestSession("me", sessOptCannedSignonTime),
-			bodyIn: wire.SNAC_0x01_0x02_OServiceClientOnline{},
+			name:    "notify that user is online",
+			sess:    newTestSession("me", sessOptCannedSignonTime),
+			bodyIn:  wire.SNAC_0x01_0x02_OServiceClientOnline{},
+			service: wire.BOS,
 			mockParams: mockParams{
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastVisibilityParams: broadcastVisibilityParams{
@@ -1316,52 +1564,11 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 			},
 			wantSess: newTestSession("me", sessOptCannedSignonTime, sessOptSignonComplete),
 		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			buddyUpdateBroadcaster := newMockbuddyBroadcaster(t)
-			for _, params := range tt.mockParams.broadcastVisibilityParams {
-				buddyUpdateBroadcaster.EXPECT().
-					BroadcastVisibility(matchContext(), matchSession(params.from), params.filter, params.doSendDepartures).
-					Return(params.err)
-			}
-			messageRelayer := newMockMessageRelayer(t)
-			for _, params := range tt.mockParams.relayToScreenNameParams {
-				messageRelayer.EXPECT().
-					RelayToScreenName(matchContext(), params.screenName, params.message)
-			}
-
-			svc := NewOServiceServiceForBOS(config.Config{}, messageRelayer, slog.Default(), nil, nil, nil, nil, nil, wire.DefaultRateLimitClasses(), wire.DefaultSNACRateLimits())
-			svc.buddyBroadcaster = buddyUpdateBroadcaster
-			haveErr := svc.ClientOnline(context.Background(), tt.bodyIn, tt.sess)
-			assert.ErrorIs(t, tt.wantErr, haveErr)
-			assert.Equal(t, tt.wantSess.SignonComplete(), tt.sess.SignonComplete())
-		})
-	}
-}
-
-func TestOServiceServiceForChat_ClientOnline(t *testing.T) {
-	chatRoom := state.NewChatRoom("the-chat-room", state.NewIdentScreenName("creator"), state.PrivateExchange)
-	chatter1 := newTestSession("chatter-1", sessOptChatRoomCookie(chatRoom.Cookie()))
-	chatter2 := newTestSession("chatter-2", sessOptChatRoomCookie(chatRoom.Cookie()))
-
-	tests := []struct {
-		// name is the name of the test
-		name string
-		// joiningChatter is the user joining the chat room
-		joiningChatter *state.Session
-		// bodyIn is the SNAC body sent from the arriving user's client to the
-		// server
-		bodyIn  wire.SNAC_0x01_0x02_OServiceClientOnline
-		wantErr error
-		// mockParams is the list of params sent to mocks that satisfy this
-		// method's dependencies
-		mockParams mockParams
-	}{
 		{
-			name:           "upon joining, send chat room metadata and participant list to joining user; alert arrival to existing participants",
-			joiningChatter: chatter1,
-			bodyIn:         wire.SNAC_0x01_0x02_OServiceClientOnline{},
+			name:    "upon joining, send chat room metadata and participant list to joining user; alert arrival to existing participants",
+			sess:    chatter1,
+			bodyIn:  wire.SNAC_0x01_0x02_OServiceClientOnline{},
+			service: wire.Chat,
 			mockParams: mockParams{
 				chatMessageRelayerParams: chatMessageRelayerParams{
 					chatRelayToAllExceptParams: chatRelayToAllExceptParams{
@@ -1437,10 +1644,22 @@ func TestOServiceServiceForChat_ClientOnline(t *testing.T) {
 					},
 				},
 			},
+			wantSess: newTestSession("me", sessOptCannedSignonTime, sessOptSignonComplete),
 		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
+			buddyUpdateBroadcaster := newMockbuddyBroadcaster(t)
+			for _, params := range tt.mockParams.broadcastVisibilityParams {
+				buddyUpdateBroadcaster.EXPECT().
+					BroadcastVisibility(matchContext(), matchSession(params.from), params.filter, params.doSendDepartures).
+					Return(params.err)
+			}
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().
+					RelayToScreenName(matchContext(), params.screenName, params.message)
+			}
 			chatRoomManager := newMockChatRoomRegistry(t)
 			for _, params := range tt.mockParams.chatRoomByCookieParams {
 				chatRoomManager.EXPECT().
@@ -1462,56 +1681,15 @@ func TestOServiceServiceForChat_ClientOnline(t *testing.T) {
 					RelayToScreenName(mock.Anything, params.cookie, params.screenName, params.message)
 			}
 
-			svc := NewOServiceServiceForChat(config.Config{}, slog.Default(), nil, chatRoomManager, chatMessageRelayer, nil, nil, nil, wire.DefaultRateLimitClasses(), wire.DefaultSNACRateLimits())
-
-			haveErr := svc.ClientOnline(context.Background(), wire.SNAC_0x01_0x02_OServiceClientOnline{}, tt.joiningChatter)
+			svc := NewOServiceService(config.Config{}, messageRelayer, slog.Default(), nil, chatRoomManager, nil, nil, nil, wire.DefaultRateLimitClasses(), wire.DefaultSNACRateLimits(), chatMessageRelayer)
+			svc.buddyBroadcaster = buddyUpdateBroadcaster
+			haveErr := svc.ClientOnline(context.Background(), tt.service, tt.bodyIn, tt.sess)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
+			assert.Equal(t, tt.wantSess.SignonComplete(), tt.sess.SignonComplete())
 		})
 	}
 }
 
-func TestOServiceServiceForChatNav_HostOnline(t *testing.T) {
-	svc := NewOServiceServiceForChatNav(config.Config{}, slog.Default(), nil, nil, nil, nil, wire.DefaultRateLimitClasses(), wire.DefaultSNACRateLimits())
-
-	want := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceHostOnline,
-			RequestID: wire.ReqIDFromServer,
-		},
-		Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
-			FoodGroups: []uint16{
-				wire.ChatNav,
-				wire.OService,
-			},
-		},
-	}
-
-	have := svc.HostOnline()
-	assert.Equal(t, want, have)
-}
-
-func TestOServiceServiceForAlert_HostOnline(t *testing.T) {
-	svc := NewOServiceServiceForAlert(config.Config{}, slog.Default(), nil, nil, nil, nil, wire.DefaultRateLimitClasses(), wire.DefaultSNACRateLimits())
-
-	want := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceHostOnline,
-			RequestID: wire.ReqIDFromServer,
-		},
-		Body: wire.SNAC_0x01_0x03_OServiceHostOnline{
-			FoodGroups: []uint16{
-				wire.Alert,
-				wire.OService,
-			},
-		},
-	}
-
-	have := svc.HostOnline()
-	assert.Equal(t, want, have)
-}
-
 func TestOServiceService_SetPrivacyFlags(t *testing.T) {
 	svc := OServiceService{
 		cfg:    config.Config{},

+ 15 - 42
server/http/mgmt_api.go

@@ -8,7 +8,6 @@ import (
 	"errors"
 	"fmt"
 	"log/slog"
-	"net"
 	"net/http"
 	"strconv"
 	"strings"
@@ -21,22 +20,7 @@ import (
 	"github.com/mk6i/retro-aim-server/wire"
 )
 
-func NewManagementAPI(
-	bld config.Build,
-	cfg config.Config,
-	userManager UserManager,
-	sessionRetriever SessionRetriever,
-	chatRoomRetriever ChatRoomRetriever,
-	chatRoomCreator ChatRoomCreator,
-	chatSessionRetriever ChatSessionRetriever,
-	directoryManager DirectoryManager,
-	messageRelayer MessageRelayer,
-	bartRetriever BuddyIconRetriever,
-	feedbagRetriever FeedBagRetriever,
-	accountManager AccountManager,
-	profileRetriever ProfileRetriever,
-	logger *slog.Logger,
-) *Server {
+func NewManagementAPI(bld config.Build, listener string, userManager UserManager, sessionRetriever SessionRetriever, chatRoomRetriever ChatRoomRetriever, chatRoomCreator ChatRoomCreator, chatSessionRetriever ChatSessionRetriever, directoryManager DirectoryManager, messageRelayer MessageRelayer, bartRetriever BuddyIconRetriever, feedbagRetriever FeedBagRetriever, accountManager AccountManager, profileRetriever ProfileRetriever, logger *slog.Logger) *Server {
 	mux := http.NewServeMux()
 
 	// Handlers for '/user' route
@@ -138,45 +122,34 @@ func NewManagementAPI(
 	})
 
 	return &Server{
-		Server: http.Server{
-			Addr:    net.JoinHostPort(cfg.ApiHost, cfg.ApiPort),
+		server: http.Server{
+			Addr:    listener,
 			Handler: mux,
 		},
-		Logger: logger,
+		logger: logger,
 	}
-
 }
 
 type Server struct {
-	http.Server
-	Logger *slog.Logger
+	server http.Server
+	logger *slog.Logger
 }
 
-func (s *Server) Start(ctx context.Context) error {
-	ch := make(chan error)
-
-	go func() {
-		s.Logger.Info("starting management API server", "addr", s.Addr)
-		if err := s.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
-			ch <- fmt.Errorf("unable to start management API server: %w", err)
-		}
-	}()
+func (s *Server) ListenAndServe() error {
+	s.logger.Info("starting server", "addr", s.server.Addr)
 
-	select {
-	case <-ctx.Done():
-	case err := <-ch:
-		return err
+	if err := s.server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
+		return fmt.Errorf("unable to start management API server: %w", err)
 	}
 
-	shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
-	defer cancel()
-
-	if err := s.Shutdown(shutdownCtx); err != nil {
-		s.Logger.Error("unable to shutdown management API server", "err", err.Error())
-	}
 	return nil
 }
 
+func (s *Server) Shutdown(ctx context.Context) error {
+	defer s.logger.Info("shutdown complete")
+	return s.server.Shutdown(ctx)
+}
+
 // deleteUserHandler handles the DELETE /user endpoint.
 func deleteUserHandler(w http.ResponseWriter, r *http.Request, manager UserManager, logger *slog.Logger) {
 	user, err := userFromBody(r)

+ 122 - 0
server/kerberos/kerberos.go

@@ -0,0 +1,122 @@
+package kerberos
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"net"
+	"net/http"
+
+	"golang.org/x/sync/errgroup"
+
+	"github.com/mk6i/retro-aim-server/config"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+type AuthService interface {
+	KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error)
+}
+
+func NewKerberosServer(listeners []config.Listener, logger *slog.Logger, authService AuthService) *Server {
+	servers := make([]*http.Server, 0, len(listeners))
+
+	for _, l := range listeners {
+
+		mux := http.NewServeMux()
+
+		mux.HandleFunc("POST /", func(writer http.ResponseWriter, request *http.Request) {
+			postHandler(writer, request, authService, logger, l.BOSAdvertisedHost)
+		})
+
+		servers = append(servers, &http.Server{
+			Addr:    l.KerberosListenAddress,
+			Handler: mux,
+		})
+	}
+
+	return &Server{
+		servers: servers,
+		logger:  logger,
+	}
+}
+
+// Server hosts an HTTP endpoint capable of handling AIM-style Kerberos
+// authentication. The messages are structured as SNACs transmitted over HTTP.
+type Server struct {
+	servers   []*http.Server
+	listeners []net.Listener
+	logger    *slog.Logger
+}
+
+func (s *Server) ListenAndServe() error {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	g, ctx := errgroup.WithContext(ctx)
+	for _, server := range s.servers {
+		g.Go(func() error {
+			s.logger.Info("starting server", "addr", server.Addr)
+			if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
+				cancel()
+				return fmt.Errorf("unable to start kerberos server: %w", err)
+			}
+			return nil
+		})
+	}
+
+	return g.Wait()
+}
+
+func (s *Server) Shutdown(ctx context.Context) error {
+	defer s.logger.Info("shutdown complete")
+
+	for _, srv := range s.servers {
+		_ = srv.Shutdown(ctx)
+	}
+	return nil
+}
+
+// postHandler handles AIM-style Kerberos authentication for AIM 6.0+.
+func postHandler(w http.ResponseWriter, r *http.Request, authService AuthService, logger *slog.Logger, listenAddress string) {
+	b, err := io.ReadAll(r.Body)
+	if err != nil {
+		http.Error(w, "unable to read HTTP body", http.StatusBadRequest)
+		return
+	}
+	reader := bytes.NewReader(b)
+
+	var header wire.SNACFrame
+	if err := wire.UnmarshalBE(&header, reader); err != nil {
+		http.Error(w, "unable to read kerberos login SNAC header", http.StatusBadRequest)
+		return
+	}
+	if header.FoodGroup != wire.Kerberos || header.SubGroup != wire.KerberosLoginRequest {
+		http.Error(w, "unexpected SNAC type", http.StatusBadRequest)
+		return
+	}
+
+	var body wire.SNAC_0x050C_0x0002_KerberosLoginRequest
+	if err := wire.UnmarshalBE(&body, reader); err != nil {
+		http.Error(w, "unable to read kerberos login SNAC body", http.StatusBadRequest)
+		return
+	}
+
+	response, err := authService.KerberosLogin(r.Context(), body, state.NewStubUser, listenAddress)
+	if err != nil {
+		logger.Error("authService.KerberosLogin", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/x-snac")
+
+	if err := wire.MarshalBE(response, w); err != nil {
+		logger.Error("unable to marshal SNAC response", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+}

+ 31 - 9
server/oscar/kerberos_test.go → server/kerberos/kerberos_test.go

@@ -1,11 +1,14 @@
-package oscar
+package kerberos
 
 import (
 	"bytes"
+	"context"
+	"fmt"
 	"io"
 	"log/slog"
 	"net/http"
 	"net/http/httptest"
+	"sync"
 	"testing"
 
 	"github.com/mk6i/retro-aim-server/config"
@@ -88,26 +91,41 @@ func TestKerberosLoginHandler(t *testing.T) {
 			mockAuth := newMockAuthService(t)
 			if tt.expectLogin {
 				mockAuth.EXPECT().
-					KerberosLogin(mock.Anything, tt.request.Body, mock.Anything).
+					KerberosLogin(mock.Anything, tt.request.Body, mock.Anything, "localhost:5190").
 					Return(tt.response, tt.responseErr)
 			}
 
+			listenCfg := []config.Listener{
+				{
+					KerberosListenAddress: ":1088",
+					BOSAdvertisedHost:     "localhost:5190",
+				},
+			}
+
 			log := slog.New(slog.NewTextHandler(io.Discard, nil))
-			srv := NewKerberosServer(config.Config{KerberosPort: "0"}, log, mockAuth)
+			srv := NewKerberosServer(listenCfg, log, mockAuth)
+
+			wg := sync.WaitGroup{}
+			wg.Add(1)
+			go func() {
+				defer wg.Done()
+				assert.NoError(t, srv.ListenAndServe())
+			}()
 
 			b := &bytes.Buffer{}
 			assert.NoError(t, wire.MarshalBE(tt.request, b))
 
 			req := httptest.NewRequest(http.MethodPost, "/", b)
 			req.Header.Set("Content-Type", "application/x-snac")
-			w := httptest.NewRecorder()
 
-			srv.Handler.ServeHTTP(w, req)
+			resp, err := http.Post(fmt.Sprintf("http://localhost:%s", "1088"), "application/x-snac", b)
+			assert.NoError(t, err)
+			defer resp.Body.Close()
 
-			assert.Equal(t, tt.wantStatus, w.Result().StatusCode)
+			assert.Equal(t, tt.wantStatus, resp.StatusCode)
 
 			if tt.expectSNACResponse {
-				respBytes, _ := io.ReadAll(w.Result().Body)
+				respBytes, _ := io.ReadAll(resp.Body)
 				reader := bytes.NewReader(respBytes)
 				haveFrame := wire.SNACFrame{}
 				assert.NoError(t, wire.UnmarshalBE(&haveFrame, reader))
@@ -115,10 +133,14 @@ func TestKerberosLoginHandler(t *testing.T) {
 				haveBody := wire.SNAC_0x050C_0x0003_KerberosLoginSuccessResponse{}
 				assert.NoError(t, wire.UnmarshalBE(&haveBody, reader))
 				assert.Equal(t, tt.response.Body, haveBody)
-				assert.Equal(t, "application/x-snac", w.Result().Header.Get("Content-Type"))
+				assert.Equal(t, "application/x-snac", resp.Header.Get("Content-Type"))
 			} else {
-				assert.Equal(t, "text/plain; charset=utf-8", w.Result().Header.Get("Content-Type"))
+				assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type"))
 			}
+
+			assert.NoError(t, srv.Shutdown(context.Background()))
+
+			wg.Wait()
 		})
 	}
 }

+ 98 - 0
server/kerberos/mock_auth_test.go

@@ -0,0 +1,98 @@
+// Code generated by mockery v2.53.3. DO NOT EDIT.
+
+package kerberos
+
+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"
+)
+
+// 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}
+}
+
+// KerberosLogin provides a mock function with given fields: ctx, inBody, newUserFn, advertisedHost
+func (_m *mockAuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, inBody, newUserFn, advertisedHost)
+
+	if len(ret) == 0 {
+		panic("no return value specified for KerberosLogin")
+	}
+
+	var r0 wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(state.DisplayScreenName) (state.User, error), string) (wire.SNACMessage, error)); ok {
+		return rf(ctx, inBody, newUserFn, advertisedHost)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(state.DisplayScreenName) (state.User, error), string) wire.SNACMessage); ok {
+		r0 = rf(ctx, inBody, newUserFn, advertisedHost)
+	} else {
+		r0 = ret.Get(0).(wire.SNACMessage)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(state.DisplayScreenName) (state.User, error), string) error); ok {
+		r1 = rf(ctx, inBody, newUserFn, advertisedHost)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAuthService_KerberosLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'KerberosLogin'
+type mockAuthService_KerberosLogin_Call struct {
+	*mock.Call
+}
+
+// KerberosLogin is a helper method to define mock.On call
+//   - ctx context.Context
+//   - inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest
+//   - newUserFn func(state.DisplayScreenName)(state.User , error)
+//   - advertisedHost string
+func (_e *mockAuthService_Expecter) KerberosLogin(ctx interface{}, inBody interface{}, newUserFn interface{}, advertisedHost interface{}) *mockAuthService_KerberosLogin_Call {
+	return &mockAuthService_KerberosLogin_Call{Call: _e.mock.On("KerberosLogin", ctx, inBody, newUserFn, advertisedHost)}
+}
+
+func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error), advertisedHost string)) *mockAuthService_KerberosLogin_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNAC_0x050C_0x0002_KerberosLoginRequest), args[2].(func(state.DisplayScreenName) (state.User, error)), args[3].(string))
+	})
+	return _c
+}
+
+func (_c *mockAuthService_KerberosLogin_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockAuthService_KerberosLogin_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAuthService_KerberosLogin_Call) RunAndReturn(run func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(state.DisplayScreenName) (state.User, error), string) (wire.SNACMessage, error)) *mockAuthService_KerberosLogin_Call {
+	_c.Call.Return(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
+}

+ 0 - 224
server/oscar/admin.go

@@ -1,224 +0,0 @@
-package oscar
-
-import (
-	"bytes"
-	"context"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-	"net"
-	"sync"
-	"time"
-
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-// AdminServer provides client connection lifecycle management for the BOS
-// service.
-type AdminServer struct {
-	AuthService
-	Handler
-	ListenAddr string
-	Logger     *slog.Logger
-	OnlineNotifier
-	config.Config
-	RateLimitUpdater
-	wire.SNACRateLimits
-}
-
-// Start starts a TCP server and listens for connections. The initial
-// authentication handshake sequences are handled by this method. The remaining
-// requests are relayed to BOSRouter.
-func (rt AdminServer) Start(ctx context.Context) error {
-	listener, err := net.Listen("tcp", rt.ListenAddr)
-	if err != nil {
-		return fmt.Errorf("unable to start admin server: %w", err)
-	}
-
-	go func() {
-		<-ctx.Done()
-		listener.Close()
-	}()
-
-	rt.Logger.Info("starting server", "listen_host", rt.ListenAddr, "oscar_host", rt.Config.OSCARHost)
-
-	wg := sync.WaitGroup{}
-	for {
-		conn, err := listener.Accept()
-		if err != nil {
-			if errors.Is(err, net.ErrClosed) {
-				break
-			}
-			rt.Logger.Error("accept failed", "err", err.Error())
-			continue
-		}
-
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			connCtx := context.WithValue(ctx, "ip", conn.RemoteAddr().String())
-			rt.Logger.DebugContext(connCtx, "accepted connection")
-			if err := rt.handleNewConnection(connCtx, conn); err != nil {
-				rt.Logger.Info("user session failed", "err", err.Error())
-			}
-		}()
-	}
-
-	if !waitForShutdown(&wg) {
-		rt.Logger.Error("shutdown complete, but connections didn't close cleanly")
-	} else {
-		rt.Logger.Info("shutdown complete")
-	}
-
-	return nil
-}
-
-func (rt AdminServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser) error {
-	flapc := wire.NewFlapClient(100, rwc, rwc)
-
-	if err := flapc.SendSignonFrame(nil); err != nil {
-		return err
-	}
-	flap, err := flapc.ReceiveSignonFrame()
-	if err != nil {
-		return err
-	}
-
-	authCookie, ok := flap.Bytes(wire.OServiceTLVTagsLoginCookie)
-	if !ok {
-		return errors.New("unable to get session id from payload")
-	}
-
-	sess, err := rt.RetrieveBOSSession(ctx, authCookie)
-	if err != nil {
-		return err
-	}
-	if sess == nil {
-		return errors.New("session not found")
-	}
-
-	defer func() {
-		rwc.Close()
-	}()
-
-	ctx = context.WithValue(ctx, "screenName", sess.IdentScreenName())
-
-	msg := rt.OnlineNotifier.HostOnline()
-	if err := flapc.SendSNAC(msg.Frame, msg.Body); err != nil {
-		return err
-	}
-
-	return dispatchIncomingMessagesSimple(ctx, sess, flapc, rwc, rt.Logger, rt.Handler, rt.RateLimitUpdater, rt.SNACRateLimits)
-}
-
-func dispatchIncomingMessagesSimple(
-	ctx context.Context,
-	sess *state.Session,
-	flapc *wire.FlapClient,
-	r io.Reader,
-	logger *slog.Logger,
-	router Handler,
-	rateLimitUpdater RateLimitUpdater,
-	snacRateLimits wire.SNACRateLimits,
-) error {
-
-	defer func() {
-		logger.InfoContext(ctx, "user disconnected")
-	}()
-
-	// buffered so that the go routine has room to exit
-	msgCh := make(chan wire.FLAPFrame, 1)
-	errCh := make(chan error, 1)
-
-	// consume flap frames
-	go func() {
-		defer close(msgCh)
-		defer close(errCh)
-
-		for {
-			frame := wire.FLAPFrame{}
-			if err := wire.UnmarshalBE(&frame, r); err != nil {
-				errCh <- err
-				return
-			}
-			msgCh <- frame
-		}
-	}()
-
-	for {
-		select {
-		case flap, ok := <-msgCh:
-			if !ok {
-				return nil
-			}
-			switch flap.FrameType {
-			case wire.FLAPFrameData:
-				flapBuf := bytes.NewBuffer(flap.Payload)
-
-				inFrame := wire.SNACFrame{}
-				if err := wire.UnmarshalBE(&inFrame, flapBuf); err != nil {
-					return err
-				}
-
-				rateClassID, ok := snacRateLimits.RateClassLookup(inFrame.FoodGroup, inFrame.SubGroup)
-				if ok {
-					if status := sess.EvaluateRateLimit(time.Now(), rateClassID); status == wire.RateLimitStatusLimited {
-						logger.DebugContext(ctx, "rate limit exceeded, dropping SNAC",
-							"foodgroup", wire.FoodGroupName(inFrame.FoodGroup),
-							"subgroup", wire.SubGroupName(inFrame.FoodGroup, inFrame.SubGroup))
-						break
-					}
-				} else {
-					logger.ErrorContext(ctx, "rate limit not found, allowing request through")
-				}
-
-				// route a client request to the appropriate service handler. the
-				// handler may write a response to the client connection.
-				if err := router.Handle(ctx, sess, inFrame, flapBuf, flapc); err != nil {
-					middleware.LogRequestError(ctx, logger, inFrame, err)
-					if errors.Is(err, ErrRouteNotFound) {
-						if err1 := sendInvalidSNACErr(inFrame, flapc); err1 != nil {
-							return errors.Join(err1, err)
-						}
-						break
-					}
-					return err
-				}
-			case wire.FLAPFrameSignon:
-				return fmt.Errorf("shouldn't get FLAPFrameSignon. flap: %v", flap)
-			case wire.FLAPFrameError:
-				return fmt.Errorf("got FLAPFrameError. flap: %v", flap)
-			case wire.FLAPFrameSignoff:
-				logger.InfoContext(ctx, "got FLAPFrameSignoff", "flap", flap)
-				return nil
-			case wire.FLAPFrameKeepAlive:
-				logger.DebugContext(ctx, "keepalive heartbeat")
-			default:
-				return fmt.Errorf("got unknown FLAP frame type. flap: %v", flap)
-			}
-		case <-time.After(1 * time.Second):
-			msgs := rateLimitUpdater.RateLimitUpdates(ctx, sess, time.Now())
-			for _, rate := range msgs {
-				if err := flapc.SendSNAC(rate.Frame, rate.Body); err != nil {
-					middleware.LogRequestError(ctx, logger, rate.Frame, err)
-					return err
-				}
-			}
-		case <-ctx.Done():
-			// application is shutting down
-			if err := flapc.Disconnect(); err != nil {
-				return fmt.Errorf("unable to gracefully disconnect user. %w", err)
-			}
-			return nil
-		case err := <-errCh:
-			if !errors.Is(io.EOF, err) {
-				logger.ErrorContext(ctx, "client disconnected with error", "err", err)
-			}
-			return nil
-		}
-	}
-}

+ 0 - 284
server/oscar/auth.go

@@ -1,284 +0,0 @@
-package oscar
-
-import (
-	"bytes"
-	"context"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-	"net"
-	"sync"
-	"time"
-
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-
-	"github.com/google/uuid"
-	"github.com/patrickmn/go-cache"
-	"golang.org/x/time/rate"
-)
-
-type AuthService interface {
-	BUCPChallenge(ctx context.Context, bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
-	BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)
-	FLAPLogin(ctx context.Context, frame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error)
-	KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)
-	RegisterBOSSession(ctx context.Context, authCookie []byte) (*state.Session, error)
-	RegisterChatSession(ctx context.Context, authCookie []byte) (*state.Session, error)
-	RetrieveBOSSession(ctx context.Context, authCookie []byte) (*state.Session, error)
-	Signout(ctx context.Context, sess *state.Session)
-	SignoutChat(ctx context.Context, sess *state.Session)
-}
-
-// IPRateLimiter enforces a per-IP rate limit using a token bucket algorithm.
-// It caches individual rate limiters by IP address and supports tagging requests
-// as originating from the BUCP or FLAP auth.
-//
-// The limiter uses an in-memory cache with TTL expiration, so rate limits reset
-// after the TTL if no activity is observed for a given IP.
-type IPRateLimiter struct {
-	cache *cache.Cache // In-memory cache mapping IPs to rate limiters with optional BUCP tag
-	rate  rate.Limit   // Requests allowed per second
-	burst int          // Maximum burst size allowed
-}
-
-type rateLimitEntry struct {
-	isBUCP  bool
-	limiter *rate.Limiter
-}
-
-// NewIPRateLimiter initializes a new IPRateLimiter with the specified rate,
-// burst size, and TTL for each IP's limiter. Entries expire after 2×TTL.
-func NewIPRateLimiter(rate rate.Limit, burst int, ttl time.Duration) *IPRateLimiter {
-	return &IPRateLimiter{
-		cache: cache.New(ttl, 2*ttl),
-		rate:  rate,
-		burst: burst,
-	}
-}
-
-// SetBUCP marks the rate limiter for the given IP as originating from BUCP auth
-// (default FLAP auth).
-func (l *IPRateLimiter) SetBUCP(ip string) {
-	limiter, found := l.cache.Get(ip)
-	if !found {
-		limiter = &rateLimitEntry{
-			isBUCP:  true,
-			limiter: rate.NewLimiter(l.rate, l.burst),
-		}
-		l.cache.Set(ip, limiter, cache.DefaultExpiration)
-	}
-	limiter.(*rateLimitEntry).isBUCP = true
-}
-
-// Allow checks if a request from the given IP is allowed under its rate limit.
-// It returns whether the request is allowed and whether the connection uses
-// BUCP auth.
-func (l *IPRateLimiter) Allow(ip string) (allowed bool, isBUCP bool) {
-	limiter, found := l.cache.Get(ip)
-	if !found {
-		limiter = &rateLimitEntry{
-			limiter: rate.NewLimiter(l.rate, l.burst),
-		}
-		l.cache.Set(ip, limiter, cache.DefaultExpiration)
-	}
-	entry := limiter.(*rateLimitEntry)
-	return entry.limiter.Allow(), entry.isBUCP
-}
-
-// AuthServer is an authentication server for both FLAP (AIM v1.0-3.0) and BUCP
-// (AIM v3.5-5.9) authentication flows.
-type AuthServer struct {
-	AuthService
-	config.Config
-	Logger *slog.Logger
-	*IPRateLimiter
-}
-
-// Start starts the authentication server and listens for new connections.
-func (rt AuthServer) Start(ctx context.Context) error {
-	addr := net.JoinHostPort("", rt.Config.AuthPort)
-	listener, err := net.Listen("tcp", addr)
-	if err != nil {
-		return fmt.Errorf("unable to start auth server: %w", err)
-	}
-
-	go func() {
-		<-ctx.Done()
-		listener.Close()
-	}()
-
-	rt.Logger.Info("starting server", "listen_host", addr, "oscar_host", rt.Config.OSCARHost)
-
-	wg := sync.WaitGroup{}
-	for {
-		conn, err := listener.Accept()
-		if err != nil {
-			if errors.Is(err, net.ErrClosed) {
-				break
-			}
-			rt.Logger.Error("accept failed", "err", err.Error())
-			continue
-		}
-
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			connCtx := context.WithValue(ctx, "ip", conn.RemoteAddr().String())
-			rt.Logger.DebugContext(connCtx, "accepted connection")
-			if err := rt.handleNewConnection(connCtx, conn); err != nil {
-				rt.Logger.Info("user session failed", "err", err.Error())
-			}
-		}()
-	}
-
-	if !waitForShutdown(&wg) {
-		rt.Logger.Error("shutdown complete, but connections didn't close cleanly")
-	} else {
-		rt.Logger.Info("shutdown complete")
-	}
-
-	return nil
-}
-
-func (rt AuthServer) handleNewConnection(ctx context.Context, conn net.Conn) error {
-	defer conn.Close()
-
-	flapc := wire.NewFlapClient(100, conn, conn)
-
-	ip, _, err := net.SplitHostPort(conn.RemoteAddr().String())
-	if err != nil {
-		rt.Logger.Error("failed to parse remote address", "err", err.Error())
-		return err
-	}
-
-	if ok, isBUCP := rt.Allow(ip); !ok {
-		rt.Logger.Error("user rate limited at login", "remote", ip)
-		tlv := wire.TLVRestBlock{
-			TLVList: []wire.TLV{
-				wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, wire.LoginErrRateLimitExceeded),
-			},
-		}
-		// gives wrong response if you quickly switch between BUCP/FLAP clients
-		if isBUCP {
-			return flapc.SendSNAC(
-				wire.SNACFrame{
-					FoodGroup: wire.BUCP,
-					SubGroup:  wire.BUCPLoginResponse,
-				},
-				wire.SNAC_0x17_0x03_BUCPLoginResponse{
-					TLVRestBlock: tlv,
-				},
-			)
-		} else {
-			return flapc.SendSignoffFrame(tlv)
-		}
-	}
-
-	// auth must complete within the next 30 seconds
-	if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
-		return fmt.Errorf("failed to set deadline: %w", err)
-	}
-
-	if err := flapc.SendSignonFrame(nil); err != nil {
-		return err
-	}
-
-	signonFrame, err := flapc.ReceiveSignonFrame()
-	if err != nil {
-		return err
-	}
-
-	// decide whether the client is using BUCP or FLAP authentication based on
-	// the presence of the screen name TLV. this block used to check for the
-	// presence of the roasted password TLV, however that proved an unreliable
-	// indicator of FLAP-auth because older ICQ clients appear to omit the
-	// roasted password TLV when the password is not stored client-side.
-	if _, hasScreenName := signonFrame.Uint16BE(wire.LoginTLVTagsScreenName); hasScreenName {
-		return rt.processFLAPAuth(ctx, signonFrame, flapc)
-	}
-
-	rt.SetBUCP(ip)
-
-	return rt.processBUCPAuth(ctx, flapc)
-}
-
-func (rt AuthServer) processFLAPAuth(ctx context.Context, signonFrame wire.FLAPSignonFrame, flapc *wire.FlapClient) error {
-	tlv, err := rt.AuthService.FLAPLogin(ctx, signonFrame, state.NewStubUser)
-	if err != nil {
-		return err
-	}
-	return flapc.SendSignoffFrame(tlv)
-}
-
-func (rt AuthServer) processBUCPAuth(ctx context.Context, flapc *wire.FlapClient) error {
-	frames := 0
-
-	for {
-		frame, err := flapc.ReceiveFLAP()
-		if err != nil {
-			return err
-		}
-
-		if frames > 10 {
-			// a lot of frames received, the client is misbehaving
-			return fmt.Errorf("too many auth flap packets received")
-		}
-		frames++
-
-		switch frame.FrameType {
-		case wire.FLAPFrameSignoff:
-			rt.Logger.Debug("signed off mid-login")
-			return io.EOF // client disconnected
-		case wire.FLAPFrameKeepAlive:
-			rt.Logger.Debug("received flap keepalive frame")
-		case wire.FLAPFrameData:
-			buf := bytes.NewReader(frame.Payload)
-			fr := wire.SNACFrame{}
-			if err := wire.UnmarshalBE(&fr, buf); err != nil {
-				return err
-			}
-			switch {
-			case fr.FoodGroup == wire.BUCP && fr.SubGroup == wire.BUCPChallengeRequest:
-				challengeRequest := wire.SNAC_0x17_0x06_BUCPChallengeRequest{}
-				if err := wire.UnmarshalBE(&challengeRequest, buf); err != nil {
-					return err
-				}
-				outSNAC, err := rt.BUCPChallenge(ctx, challengeRequest, uuid.New)
-				if err != nil {
-					return err
-				}
-				if err := flapc.SendSNAC(outSNAC.Frame, outSNAC.Body); err != nil {
-					return err
-				}
-
-				if outSNAC.Frame.SubGroup == wire.BUCPLoginResponse {
-					screenName, _ := challengeRequest.String(wire.LoginTLVTagsScreenName)
-					rt.Logger.Debug("failed BUCP challenge: user does not exist", "screen_name", screenName)
-					return nil // account does not exist
-				}
-			case fr.FoodGroup == wire.BUCP && fr.SubGroup == wire.BUCPLoginRequest:
-				loginRequest := wire.SNAC_0x17_0x02_BUCPLoginRequest{}
-				if err := wire.UnmarshalBE(&loginRequest, buf); err != nil {
-					return err
-				}
-				outSNAC, err := rt.BUCPLogin(ctx, loginRequest, state.NewStubUser)
-				if err != nil {
-					return err
-				}
-
-				return flapc.SendSNAC(outSNAC.Frame, outSNAC.Body)
-			default:
-				rt.Logger.Debug("unexpected SNAC received during login",
-					"foodgroup", wire.FoodGroupName(fr.FoodGroup),
-					"subgroup", wire.SubGroupName(fr.FoodGroup, fr.SubGroup))
-				return io.EOF
-			}
-		default:
-			rt.Logger.Debug("unexpected frame type received during login", "type", frame.FrameType)
-			return io.EOF
-		}
-	}
-}

+ 0 - 118
server/oscar/auth_test.go

@@ -1,118 +0,0 @@
-package oscar
-
-import (
-	"bytes"
-	"context"
-	"log/slog"
-	"net"
-	"testing"
-	"time"
-
-	"github.com/mk6i/retro-aim-server/wire"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-	"golang.org/x/time/rate"
-)
-
-type fakeConn struct {
-	net.Conn // embed the real connection
-	local    net.Addr
-	remote   net.Addr
-}
-
-func (f fakeConn) RemoteAddr() net.Addr { return f.remote }
-
-func TestBUCPAuthService_handleNewConnection(t *testing.T) {
-	serverConn, clientConn := net.Pipe()
-
-	addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8080")
-	if err != nil {
-		panic(err)
-	}
-
-	clientFake := fakeConn{
-		Conn:   clientConn,
-		local:  addr,
-		remote: addr,
-	}
-
-	go func() {
-		defer serverConn.Close()
-
-		// < receive FLAPSignonFrame
-		flap := wire.FLAPFrame{}
-		assert.NoError(t, wire.UnmarshalBE(&flap, serverConn))
-		flapSignonFrame := wire.FLAPSignonFrame{}
-		assert.NoError(t, wire.UnmarshalBE(&flapSignonFrame, bytes.NewBuffer(flap.Payload)))
-
-		// > send FLAPSignonFrame
-		flapSignonFrame = wire.FLAPSignonFrame{
-			FLAPVersion: 1,
-		}
-		buf := &bytes.Buffer{}
-		assert.NoError(t, wire.MarshalBE(flapSignonFrame, buf))
-		flap = wire.FLAPFrame{
-			StartMarker: 42,
-			FrameType:   wire.FLAPFrameSignon,
-			Payload:     buf.Bytes(),
-		}
-		assert.NoError(t, wire.MarshalBE(flap, serverConn))
-
-		// > send SNAC_0x17_0x06_BUCPChallengeRequest
-		flapc := wire.NewFlapClient(0, serverConn, serverConn)
-		frame := wire.SNACFrame{
-			FoodGroup: wire.BUCP,
-			SubGroup:  wire.BUCPChallengeRequest,
-		}
-		bodyIn := wire.SNAC_0x17_0x06_BUCPChallengeRequest{}
-		assert.NoError(t, flapc.SendSNAC(frame, bodyIn))
-
-		// < receive SNAC_0x17_0x07_BUCPChallengeResponse
-		frame = wire.SNACFrame{}
-		assert.NoError(t, flapc.ReceiveSNAC(&frame, &wire.SNAC_0x17_0x07_BUCPChallengeResponse{}))
-		assert.Equal(t, wire.SNACFrame{FoodGroup: wire.BUCP, SubGroup: wire.BUCPChallengeResponse}, frame)
-
-		// > send keep alive frame (like BSFlite does mid-login)
-		assert.NoError(t, flapc.SendKeepAliveFrame())
-
-		// > send SNAC_0x17_0x02_BUCPLoginRequest
-		frame = wire.SNACFrame{
-			FoodGroup: wire.BUCP,
-			SubGroup:  wire.BUCPLoginRequest,
-		}
-		assert.NoError(t, flapc.SendSNAC(frame, wire.SNAC_0x17_0x02_BUCPLoginRequest{}))
-
-		// < receive SNAC_0x17_0x03_BUCPLoginResponse
-		frame = wire.SNACFrame{}
-		assert.NoError(t, flapc.ReceiveSNAC(&frame, &wire.SNAC_0x17_0x03_BUCPLoginResponse{}))
-		assert.Equal(t, wire.SNACFrame{FoodGroup: wire.BUCP, SubGroup: wire.BUCPLoginResponse}, frame)
-	}()
-
-	authService := newMockAuthService(t)
-	authService.EXPECT().
-		BUCPChallenge(matchContext(), mock.Anything, mock.Anything).
-		Return(wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.BUCP,
-				SubGroup:  wire.BUCPChallengeResponse,
-			},
-			Body: wire.SNAC_0x17_0x07_BUCPChallengeResponse{},
-		}, nil)
-	authService.EXPECT().
-		BUCPLogin(matchContext(), mock.Anything, mock.Anything).
-		Return(wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.BUCP,
-				SubGroup:  wire.BUCPLoginResponse,
-			},
-			Body: wire.SNAC_0x17_0x03_BUCPLoginResponse{},
-		}, nil)
-
-	rt := AuthServer{
-		AuthService:   authService,
-		Logger:        slog.Default(),
-		IPRateLimiter: NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
-	}
-
-	assert.NoError(t, rt.handleNewConnection(context.Background(), clientFake))
-}

+ 0 - 216
server/oscar/bos.go

@@ -1,216 +0,0 @@
-package oscar
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-	"net"
-	"net/netip"
-	"sync"
-	"time"
-
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-// OnlineNotifier returns a OServiceHostOnline SNAC that is sent to the client
-// at the beginning of the protocol sequence which lists all food groups
-// managed by the server.
-type OnlineNotifier interface {
-	HostOnline() 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 {
-	ClearBuddyListRegistry(ctx context.Context) error
-	RegisterBuddyList(ctx context.Context, user state.IdentScreenName) error
-	UnregisterBuddyList(ctx context.Context, user state.IdentScreenName) error
-}
-
-// DepartureNotifier is the interface for sending buddy departure notifications
-// when a client disconnects.
-type DepartureNotifier interface {
-	BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error
-}
-
-// ChatSessionManager is the interface for closing chat sessions
-// when a client disconnects.
-type ChatSessionManager interface {
-	RemoveUserFromAllChats(user state.IdentScreenName)
-}
-
-// RateLimitUpdater provides rate limit updates for subscribed rate limit classes.
-type RateLimitUpdater interface {
-	RateLimitUpdates(ctx context.Context, sess *state.Session, now time.Time) []wire.SNACMessage
-}
-
-// BOSServer provides client connection lifecycle management for the BOS
-// service.
-type BOSServer struct {
-	AuthService
-	BuddyListRegistry
-	DepartureNotifier
-	Handler
-	ListenAddr string
-	Logger     *slog.Logger
-	OnlineNotifier
-	config.Config
-	ChatSessionManager *state.InMemoryChatSessionManager
-	RateLimitUpdater
-	wire.SNACRateLimits
-}
-
-// Start starts a TCP server and listens for connections. The initial
-// authentication handshake sequences are handled by this method. The remaining
-// requests are relayed to BOSRouter.
-func (rt BOSServer) Start(ctx context.Context) error {
-	listener, err := net.Listen("tcp", rt.ListenAddr)
-	if err != nil {
-		return fmt.Errorf("unable to start BOS server: %w", err)
-	}
-
-	go func() {
-		<-ctx.Done()
-		listener.Close()
-	}()
-
-	rt.Logger.Info("starting server", "listen_host", rt.ListenAddr, "oscar_host", rt.Config.OSCARHost)
-
-	if rt.BuddyListRegistry != nil { // nil check is a hack until server refactor
-		if err = rt.BuddyListRegistry.ClearBuddyListRegistry(ctx); err != nil {
-			return fmt.Errorf("unable to clear client-side buddy list: %s", err.Error())
-		}
-	}
-
-	wg := sync.WaitGroup{}
-	for {
-		conn, err := listener.Accept()
-		if err != nil {
-			if errors.Is(err, net.ErrClosed) {
-				break
-			}
-			rt.Logger.Error("accept failed", "err", err.Error())
-			continue
-		}
-
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			connCtx := context.WithValue(ctx, "ip", conn.RemoteAddr().String())
-			rt.Logger.DebugContext(connCtx, "accepted connection")
-			if err := rt.handleNewConnection(connCtx, conn); err != nil {
-				rt.Logger.Info("user session failed", "err", err.Error())
-			}
-		}()
-	}
-
-	if !waitForShutdown(&wg) {
-		rt.Logger.Error("shutdown complete, but connections didn't close cleanly")
-	} else {
-		rt.Logger.Info("shutdown complete")
-	}
-
-	return 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
-	}
-}
-
-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 {
-		return err
-	}
-	flap, err := flapc.ReceiveSignonFrame()
-	if err != nil {
-		return err
-	}
-
-	authCookie, ok := flap.Bytes(wire.OServiceTLVTagsLoginCookie)
-	if !ok {
-		return errors.New("unable to get session id from payload")
-	}
-
-	sess, err := rt.RegisterBOSSession(ctx, authCookie)
-	if err != nil {
-		return err
-	}
-	if sess == nil {
-		return errors.New("session not found")
-	}
-
-	if rt.BuddyListRegistry != nil { // nil check is a hack until server refactor
-		// todo should this check be below defer()?
-		if err := rt.BuddyListRegistry.RegisterBuddyList(ctx, sess.IdentScreenName()); err != nil {
-			return fmt.Errorf("unable to init buddy list: %w", err)
-		}
-	}
-
-	defer func() {
-		sess.Close()
-		ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
-		defer cancel()
-		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())
-			}
-		}
-		if rt.BuddyListRegistry != nil { // nil check is a hack until server refactor
-			// buddy list must be cleared before session is closed, otherwise
-			// there will be a race condition that could cause the buddy list
-			// be prematurely deleted.
-			if err := rt.BuddyListRegistry.UnregisterBuddyList(ctx, sess.IdentScreenName()); err != nil {
-				rt.Logger.ErrorContext(ctx, "error removing buddy list entry", "err", err.Error())
-			}
-		}
-		if rt.ChatSessionManager != nil {
-			rt.ChatSessionManager.RemoveUserFromAllChats(sess.IdentScreenName())
-		}
-		rt.Signout(ctx, sess)
-	}()
-
-	ctx = context.WithValue(ctx, "screenName", sess.IdentScreenName())
-
-	msg := rt.OnlineNotifier.HostOnline()
-	if err := flapc.SendSNAC(msg.Frame, msg.Body); err != nil {
-		return err
-	}
-
-	remoteAddr, ok := ctx.Value("ip").(string)
-	if ok {
-		ip, err := netip.ParseAddrPort(remoteAddr)
-		if err != nil {
-			return errors.New("unable to parse ip addr")
-		}
-		sess.SetRemoteAddr(&ip)
-	}
-
-	return dispatchIncomingMessages(ctx, sess, flapc, rwc, rt.Logger, rt.Handler, rt.RateLimitUpdater, rt.SNACRateLimits)
-}

+ 0 - 113
server/oscar/bos_test.go

@@ -1,113 +0,0 @@
-package oscar
-
-import (
-	"bytes"
-	"context"
-	"io"
-	"log/slog"
-	"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"
-)
-
-// pipeRWC provides a mock for ReadWriteCloser that uses pipes instead of TCP
-// connections
-type pipeRWC struct {
-	*io.PipeReader
-	*io.PipeWriter
-}
-
-func (m pipeRWC) Close() error {
-	if err := m.PipeReader.Close(); err != nil {
-		return err
-	}
-	return m.PipeWriter.Close()
-}
-
-func TestBOSService_handleNewConnection(t *testing.T) {
-	sess := state.NewSession()
-
-	clientReader, serverWriter := io.Pipe()
-	serverReader, clientWriter := io.Pipe()
-
-	go func() {
-		// < receive FLAPSignonFrame
-		flap := wire.FLAPFrame{}
-		assert.NoError(t, wire.UnmarshalBE(&flap, serverReader))
-		flapSignonFrame := wire.FLAPSignonFrame{}
-		assert.NoError(t, wire.UnmarshalBE(&flapSignonFrame, bytes.NewBuffer(flap.Payload)))
-
-		// > send FLAPSignonFrame
-		flapSignonFrame = wire.FLAPSignonFrame{
-			FLAPVersion: 1,
-		}
-		flapSignonFrame.Append(wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("the-cookie")))
-		buf := &bytes.Buffer{}
-		assert.NoError(t, wire.MarshalBE(flapSignonFrame, buf))
-		flap = wire.FLAPFrame{
-			StartMarker: 42,
-			FrameType:   wire.FLAPFrameSignon,
-			Payload:     buf.Bytes(),
-		}
-		assert.NoError(t, wire.MarshalBE(flap, serverWriter))
-
-		flapc := wire.NewFlapClient(0, serverReader, serverWriter)
-
-		// < receive SNAC_0x01_0x03_OServiceHostOnline
-		frame := wire.SNACFrame{}
-		body := wire.SNAC_0x01_0x03_OServiceHostOnline{}
-		assert.NoError(t, flapc.ReceiveSNAC(&frame, &body))
-
-		// send the first request that should get relayed to BOSRouter.Handle
-		frame = wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceClientOnline,
-		}
-		assert.NoError(t, flapc.SendSNAC(frame, struct{}{}))
-		assert.NoError(t, serverWriter.Close())
-	}()
-
-	authService := newMockAuthService(t)
-	authService.EXPECT().
-		RegisterBOSSession(mock.Anything, []byte("the-cookie")).
-		Return(sess, nil)
-	authService.EXPECT().
-		Signout(mock.Anything, sess)
-
-	onlineNotifier := newMockOnlineNotifier(t)
-	onlineNotifier.EXPECT().
-		HostOnline().
-		Return(wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.OService,
-				SubGroup:  wire.OServiceHostOnline,
-			},
-			Body: wire.SNAC_0x01_0x03_OServiceHostOnline{},
-		})
-
-	router := newMockHandler(t)
-	router.EXPECT().
-		Handle(mock.Anything, sess, mock.Anything, mock.Anything, mock.Anything).
-		Run(func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) {
-			assert.Equal(t, wire.SNACFrame{
-				FoodGroup: wire.OService,
-				SubGroup:  wire.OServiceClientOnline,
-			}, inFrame)
-		}).Return(nil)
-
-	rt := BOSServer{
-		AuthService:    authService,
-		Handler:        router,
-		Logger:         slog.Default(),
-		OnlineNotifier: onlineNotifier,
-	}
-	rwc := pipeRWC{
-		PipeReader: clientReader,
-		PipeWriter: clientWriter,
-	}
-	assert.NoError(t, rt.handleNewConnection(context.Background(), rwc))
-}

+ 0 - 117
server/oscar/chat.go

@@ -1,117 +0,0 @@
-package oscar
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-	"net"
-	"sync"
-	"time"
-
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-// ChatServer represents a service that implements a chat room session.
-// Clients connect to this service upon creating a chat room or being invited
-// to a chat room.
-type ChatServer struct {
-	AuthService
-	Handler
-	Logger *slog.Logger
-	OnlineNotifier
-	config.Config
-	RateLimitUpdater
-	wire.SNACRateLimits
-}
-
-// Start creates a TCP server that implements that chat flow.
-func (rt ChatServer) Start(ctx context.Context) error {
-	addr := net.JoinHostPort("", rt.Config.ChatPort)
-	listener, err := net.Listen("tcp", addr)
-	if err != nil {
-		return fmt.Errorf("unable to start chat sever: %w", err)
-	}
-
-	go func() {
-		<-ctx.Done()
-		listener.Close()
-	}()
-
-	rt.Logger.Info("starting server", "listen_host", addr, "oscar_host", rt.Config.OSCARHost)
-
-	wg := sync.WaitGroup{}
-	for {
-		conn, err := listener.Accept()
-		if err != nil {
-			if errors.Is(err, net.ErrClosed) {
-				break
-			}
-			rt.Logger.Error("accept failed", "err", err.Error())
-			continue
-		}
-
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			connCtx := context.WithValue(ctx, "ip", conn.RemoteAddr().String())
-			rt.Logger.DebugContext(connCtx, "accepted connection")
-			if err := rt.handleNewConnection(connCtx, conn); err != nil {
-				rt.Logger.Info("user session failed", "err", err.Error())
-			}
-		}()
-	}
-
-	if !waitForShutdown(&wg) {
-		rt.Logger.Error("shutdown complete, but connections didn't close cleanly")
-	} else {
-		rt.Logger.Info("shutdown complete")
-	}
-
-	return nil
-}
-
-func (rt ChatServer) 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 {
-		return err
-	}
-	flap, err := flapc.ReceiveSignonFrame()
-	if err != nil {
-		return err
-	}
-
-	authCookie, ok := flap.Bytes(wire.OServiceTLVTagsLoginCookie)
-	if !ok {
-		return errors.New("unable to get login cookie from payload")
-	}
-
-	chatSess, err := rt.RegisterChatSession(ctx, authCookie)
-	if err != nil {
-		return err
-	}
-	if chatSess == nil {
-		return errors.New("session not found")
-	}
-
-	defer func() {
-		ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
-		defer cancel()
-		chatSess.Close()
-		rt.SignoutChat(ctx, chatSess)
-	}()
-
-	msg := rt.HostOnline()
-	if err := flapc.SendSNAC(msg.Frame, msg.Body); err != nil {
-		return err
-	}
-
-	ctx = context.WithValue(ctx, "screenName", chatSess.IdentScreenName())
-	return dispatchIncomingMessages(ctx, chatSess, flapc, rwc, rt.Logger, rt.Handler, rt.RateLimitUpdater, rt.SNACRateLimits)
-}

+ 0 - 94
server/oscar/chat_test.go

@@ -1,94 +0,0 @@
-package oscar
-
-import (
-	"bytes"
-	"context"
-	"io"
-	"log/slog"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestChatService_handleNewConnection(t *testing.T) {
-	sess := state.NewSession()
-
-	clientReader, serverWriter := io.Pipe()
-	serverReader, clientWriter := io.Pipe()
-
-	go func() {
-		// < receive FLAPSignonFrame
-		flap := wire.FLAPFrame{}
-		assert.NoError(t, wire.UnmarshalBE(&flap, serverReader))
-		flapSignonFrame := wire.FLAPSignonFrame{}
-		assert.NoError(t, wire.UnmarshalBE(&flapSignonFrame, bytes.NewBuffer(flap.Payload)))
-
-		// > send FLAPSignonFrame
-		flapSignonFrame = wire.FLAPSignonFrame{
-			FLAPVersion: 1,
-		}
-		flapSignonFrame.Append(wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte(`the-chat-login-cookie`)))
-		buf := &bytes.Buffer{}
-		assert.NoError(t, wire.MarshalBE(flapSignonFrame, buf))
-		flap = wire.FLAPFrame{
-			StartMarker: 42,
-			FrameType:   wire.FLAPFrameSignon,
-			Payload:     buf.Bytes(),
-		}
-		assert.NoError(t, wire.MarshalBE(flap, serverWriter))
-
-		flapc := wire.NewFlapClient(0, serverReader, serverWriter)
-
-		// < receive SNAC_0x01_0x03_OServiceHostOnline
-		frame := wire.SNACFrame{}
-		body := wire.SNAC_0x01_0x03_OServiceHostOnline{}
-		assert.NoError(t, flapc.ReceiveSNAC(&frame, &body))
-
-		// send the first request that should get relayed to BOSRouter.Handle
-		frame = wire.SNACFrame{
-			FoodGroup: wire.Chat,
-			SubGroup:  wire.ChatNavNavInfo,
-		}
-		assert.NoError(t, flapc.SendSNAC(frame, struct{}{}))
-		assert.NoError(t, serverWriter.Close())
-	}()
-
-	authService := newMockAuthService(t)
-	authService.EXPECT().
-		RegisterChatSession(mock.Anything, []byte(`the-chat-login-cookie`)).
-		Return(sess, nil)
-	authService.EXPECT().
-		SignoutChat(mock.Anything, sess)
-
-	onlineNotifier := newMockOnlineNotifier(t)
-	onlineNotifier.EXPECT().
-		HostOnline().
-		Return(wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.OService,
-				SubGroup:  wire.OServiceHostOnline,
-			},
-			Body: wire.SNAC_0x01_0x03_OServiceHostOnline{},
-		})
-
-	bosRouter := newMockHandler(t)
-	bosRouter.EXPECT().
-		Handle(mock.Anything, sess, mock.Anything, mock.Anything, mock.Anything).
-		Return(nil)
-
-	rt := ChatServer{
-		AuthService:    authService,
-		Handler:        bosRouter,
-		Logger:         slog.Default(),
-		OnlineNotifier: onlineNotifier,
-	}
-	rwc := pipeRWC{
-		PipeReader: clientReader,
-		PipeWriter: clientWriter,
-	}
-	assert.NoError(t, rt.handleNewConnection(context.Background(), rwc))
-}

+ 0 - 151
server/oscar/connection.go

@@ -1,151 +0,0 @@
-package oscar
-
-import (
-	"bytes"
-	"context"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-	"time"
-
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-func sendInvalidSNACErr(frameIn wire.SNACFrame, rw ResponseWriter) error {
-	frameOut := wire.SNACFrame{
-		FoodGroup: frameIn.FoodGroup,
-		SubGroup:  0x01, // error subgroup for all SNACs
-		RequestID: frameIn.RequestID,
-	}
-	bodyOut := wire.SNACError{
-		Code: wire.ErrorCodeInvalidSnac,
-	}
-	return rw.SendSNAC(frameOut, bodyOut)
-}
-
-// dispatchIncomingMessages receives incoming messages and sends them to the
-// appropriate message handler. Messages from the client are sent to the
-// router. Messages relayed from the user session are forwarded to the client.
-// This function ensures that the same sequence number is incremented for both
-// types of messages. The function terminates upon receiving a connection error
-// or when the session closes.
-//
-// todo: this method has too many params and should be folded into a new type
-func dispatchIncomingMessages(ctx context.Context, sess *state.Session, flapc *wire.FlapClient, r io.Reader, logger *slog.Logger, router Handler, rateLimitUpdater RateLimitUpdater, snacRateLimits wire.SNACRateLimits) error {
-
-	defer func() {
-		logger.InfoContext(ctx, "user disconnected")
-	}()
-
-	// buffered so that the go routine has room to exit
-	msgCh := make(chan wire.FLAPFrame, 1)
-	errCh := make(chan error, 1)
-
-	// consume flap frames
-	go func() {
-		defer close(msgCh)
-		defer close(errCh)
-
-		for {
-			frame := wire.FLAPFrame{}
-			if err := wire.UnmarshalBE(&frame, r); err != nil {
-				errCh <- err
-				return
-			}
-			msgCh <- frame
-		}
-	}()
-
-	for {
-		select {
-		case flap, ok := <-msgCh:
-			if !ok {
-				return nil
-			}
-			switch flap.FrameType {
-			case wire.FLAPFrameData:
-				flapBuf := bytes.NewBuffer(flap.Payload)
-
-				inFrame := wire.SNACFrame{}
-				if err := wire.UnmarshalBE(&inFrame, flapBuf); err != nil {
-					return err
-				}
-
-				rateClassID, ok := snacRateLimits.RateClassLookup(inFrame.FoodGroup, inFrame.SubGroup)
-				if ok {
-					if status := sess.EvaluateRateLimit(time.Now(), rateClassID); status == wire.RateLimitStatusLimited {
-						logger.DebugContext(ctx, "rate limit exceeded, dropping SNAC",
-							"foodgroup", wire.FoodGroupName(inFrame.FoodGroup),
-							"subgroup", wire.SubGroupName(inFrame.FoodGroup, inFrame.SubGroup))
-						break
-					}
-				} else {
-					logger.ErrorContext(ctx, "rate limit not found, allowing request through")
-				}
-
-				// route a client request to the appropriate service handler. the
-				// handler may write a response to the client connection.
-				if err := router.Handle(ctx, sess, inFrame, flapBuf, flapc); err != nil {
-					middleware.LogRequestError(ctx, logger, inFrame, err)
-					if errors.Is(err, ErrRouteNotFound) {
-						if err1 := sendInvalidSNACErr(inFrame, flapc); err1 != nil {
-							return errors.Join(err1, err)
-						}
-						break
-					}
-					return err
-				}
-			case wire.FLAPFrameSignon:
-				return fmt.Errorf("shouldn't get FLAPFrameSignon. flap: %v", flap)
-			case wire.FLAPFrameError:
-				return fmt.Errorf("got FLAPFrameError. flap: %v", flap)
-			case wire.FLAPFrameSignoff:
-				logger.InfoContext(ctx, "got FLAPFrameSignoff", "flap", flap)
-				return nil
-			case wire.FLAPFrameKeepAlive:
-				logger.DebugContext(ctx, "keepalive heartbeat")
-			default:
-				return fmt.Errorf("got unknown FLAP frame type. flap: %v", flap)
-			}
-		case m := <-sess.ReceiveMessage():
-			// forward a notification sent from another client to this client
-			if err := flapc.SendSNAC(m.Frame, m.Body); err != nil {
-				middleware.LogRequestError(ctx, logger, m.Frame, err)
-				return err
-			}
-			middleware.LogRequest(ctx, logger, m.Frame, m.Body)
-		case <-time.After(1 * time.Second):
-			msgs := rateLimitUpdater.RateLimitUpdates(ctx, sess, time.Now())
-			for _, rate := range msgs {
-				if err := flapc.SendSNAC(rate.Frame, rate.Body); err != nil {
-					middleware.LogRequestError(ctx, logger, rate.Frame, err)
-					return err
-				}
-			}
-		case <-sess.Closed():
-			block := wire.TLVRestBlock{}
-			// error code indicating user signed in a different location
-			block.Append(wire.NewTLVBE(0x0009, wire.OServiceDiscErrNewLogin))
-			// "more info" button
-			block.Append(wire.NewTLVBE(0x000b, "https://github.com/mk6i/retro-aim-server"))
-			if err := flapc.SendSignoffFrame(block); err != nil {
-				return fmt.Errorf("unable to gracefully disconnect user. %w", err)
-			}
-			return nil
-		case <-ctx.Done():
-			// application is shutting down
-			if err := flapc.Disconnect(); err != nil {
-				return fmt.Errorf("unable to gracefully disconnect user. %w", err)
-			}
-			return nil
-		case err := <-errCh:
-			if !errors.Is(io.EOF, err) {
-				logger.ErrorContext(ctx, "client disconnected with error", "err", err)
-			}
-			return nil
-		}
-	}
-}

+ 0 - 222
server/oscar/connection_test.go

@@ -1,222 +0,0 @@
-package oscar
-
-import (
-	"bytes"
-	"context"
-	"io"
-	"log/slog"
-	"sync"
-	"testing"
-	"time"
-
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestHandleChatConnection_MessageRelay(t *testing.T) {
-	sessionManager := state.NewInMemorySessionManager(slog.Default())
-	// add a user to session that will receive relayed messages
-	sess, _ := sessionManager.AddSession(nil, "bob")
-
-	// start the server connection handler in the background
-	serverReader, _ := io.Pipe()
-	clientReader, serverWriter := io.Pipe()
-	go func() {
-		flapc := wire.NewFlapClient(0, nil, serverWriter)
-		rateLimitUpdater := newMockRateLimitUpdater(t)
-		err := dispatchIncomingMessages(context.Background(), sess, flapc, serverReader, slog.Default(), nil, rateLimitUpdater, wire.DefaultSNACRateLimits())
-		assert.NoError(t, err)
-	}()
-
-	inboundMsgs := []wire.SNACMessage{
-		{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.Chat,
-				SubGroup:  wire.ChatUsersJoined,
-			},
-			Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{
-				Users: []wire.TLVUserInfo{
-					{
-						ScreenName: "screenname1",
-					},
-				},
-			},
-		},
-		{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.Chat,
-				SubGroup:  wire.ChatUsersLeft,
-			},
-			Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{
-				Users: []wire.TLVUserInfo{
-					{
-						ScreenName: "screenname2",
-					},
-				},
-			},
-		},
-	}
-
-	// relay messages to user session
-	for _, msg := range inboundMsgs {
-		sessionManager.RelayToScreenName(context.Background(), state.NewIdentScreenName("bob"), msg)
-	}
-
-	// consume and verify the relayed messages
-	for i := 0; i < len(inboundMsgs); i++ {
-		flap := wire.FLAPFrame{}
-		assert.NoError(t, wire.UnmarshalBE(&flap, clientReader))
-		frame := wire.SNACFrame{}
-		buf := bytes.NewBuffer(flap.Payload)
-		assert.NoError(t, wire.UnmarshalBE(&frame, buf))
-		assert.Equal(t, inboundMsgs[i].Frame, frame)
-		body := wire.SNAC_0x0E_0x03_ChatUsersJoined{}
-		assert.NoError(t, wire.UnmarshalBE(&body, buf))
-		assert.Equal(t, inboundMsgs[i].Body, body)
-	}
-
-	// stop the session, which terminates the connection handler goroutine
-	sess.Close()
-	<-sess.Closed()
-
-	// verify the connection handler sends client disconnection message before
-	// terminating
-	flap := wire.FLAPFrame{}
-	assert.NoError(t, wire.UnmarshalBE(&flap, clientReader))
-	assert.Equal(t, wire.FLAPFrameSignoff, flap.FrameType)
-}
-
-func TestHandleChatConnection_ClientRequest(t *testing.T) {
-	sessionManager := state.NewInMemorySessionManager(slog.Default())
-	// add session so that the function can terminate upon closure
-	sess, _ := sessionManager.AddSession(nil, "bob")
-	sess.SetRateClasses(time.Now(), defaultRateLimitClasses())
-
-	inboundMsgs := []wire.SNACMessage{
-		{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.Chat,
-				SubGroup:  wire.ChatUsersJoined,
-			},
-			Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{
-				Users: []wire.TLVUserInfo{
-					{
-						ScreenName: "screenname1",
-					},
-				},
-			},
-		},
-		{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.Chat,
-				SubGroup:  wire.ChatUsersLeft,
-			},
-			Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{
-				Users: []wire.TLVUserInfo{
-					{
-						ScreenName: "screenname2",
-					},
-				},
-			},
-		},
-	}
-
-	wg := &sync.WaitGroup{}
-	wg.Add(len(inboundMsgs))
-
-	// set up mock handlers to receive messages and verify their contents
-	router := newMockHandler(t)
-	for _, msg := range inboundMsgs {
-		msg := msg
-		router.EXPECT().
-			Handle(mock.Anything, sess, msg.Frame, mock.Anything, mock.Anything).
-			Run(func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) {
-				defer wg.Done()
-				body := wire.SNAC_0x0E_0x03_ChatUsersJoined{}
-				assert.NoError(t, wire.UnmarshalBE(&body, r))
-				assert.Equal(t, msg.Body, body)
-			}).
-			Return(nil)
-	}
-
-	// start the server connection handler in the background
-	serverReader, clientWriter := io.Pipe()
-	clientReader, serverWriter := io.Pipe()
-	go func() {
-		flapc := wire.NewFlapClient(0, nil, serverWriter)
-		rateLimitUpdater := newMockRateLimitUpdater(t)
-		assert.NoError(t, dispatchIncomingMessages(context.Background(), sess, flapc, serverReader, slog.Default(), router, rateLimitUpdater, wire.DefaultSNACRateLimits()))
-	}()
-
-	// send client messages
-	flapc := wire.NewFlapClient(0, nil, clientWriter)
-	for _, msg := range inboundMsgs {
-		err := flapc.SendSNAC(msg.Frame, msg.Body)
-		assert.NoError(t, err)
-	}
-	wg.Wait()
-
-	// stop the session, which terminates the connection handler goroutine
-	sess.Close()
-	<-sess.Closed()
-
-	// verify the connection handler sends client disconnection message before
-	// terminating
-	flap := wire.FLAPFrame{}
-	assert.NoError(t, wire.UnmarshalBE(&flap, clientReader))
-	assert.Equal(t, wire.FLAPFrameSignoff, flap.FrameType)
-}
-
-func defaultRateLimitClasses() wire.RateLimitClasses {
-	return wire.NewRateLimitClasses(
-		[5]wire.RateClass{
-			{
-				ID:              1,
-				WindowSize:      80,
-				ClearLevel:      2500,
-				AlertLevel:      2000,
-				LimitLevel:      1500,
-				DisconnectLevel: 800,
-				MaxLevel:        6000,
-			},
-			{
-				ID:              2,
-				WindowSize:      80,
-				ClearLevel:      3000,
-				AlertLevel:      2000,
-				LimitLevel:      1500,
-				DisconnectLevel: 1000,
-				MaxLevel:        6000,
-			},
-			{
-				ID:              3,
-				WindowSize:      20,
-				ClearLevel:      5100,
-				AlertLevel:      5000,
-				LimitLevel:      4000,
-				DisconnectLevel: 3000,
-				MaxLevel:        6000,
-			},
-			{
-				ID:              4,
-				WindowSize:      20,
-				ClearLevel:      5500,
-				AlertLevel:      5300,
-				LimitLevel:      4200,
-				DisconnectLevel: 3000,
-				MaxLevel:        8000,
-			},
-			{
-				ID:              5,
-				WindowSize:      10,
-				ClearLevel:      5500,
-				AlertLevel:      5300,
-				LimitLevel:      4200,
-				DisconnectLevel: 3000,
-				MaxLevel:        8000,
-			},
-		},
-	)
-}

+ 1083 - 0
server/oscar/handler.go

@@ -0,0 +1,1083 @@
+package oscar
+
+import (
+	"bytes"
+	"context"
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+var (
+	// ErrRouteNotFound is an error that indicates a failure to find a matching
+	// route for an OSCAR protocol request.
+	ErrRouteNotFound            = errors.New("route not found")
+	errUnknownICQMetaReqType    = errors.New("unknown ICQ request type")
+	errUnknownICQMetaReqSubType = errors.New("unknown ICQ metadata request subtype")
+)
+
+// ResponseWriter is the interface for sending a SNAC response to the client
+// from the server handlers.
+type ResponseWriter interface {
+	SendSNAC(frame wire.SNACFrame, body any) error
+}
+
+// Handler defines a structure for routing OSCAR protocol requests to
+// appropriate handlers based on group:subGroup identifiers.
+type Handler struct {
+	AdminService
+	BARTService
+	BuddyService
+	ChatNavService
+	ChatService
+	FeedbagService
+	ICBMService
+	ICQService
+	LocateService
+	ODirService
+	OServiceService
+	PermitDenyService
+	StatsService
+	UserLookupService
+	middleware.RouteLogger
+}
+
+func (rt Handler) AdminConfirmRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw ResponseWriter) error {
+	outSNAC, err := rt.AdminService.ConfirmRequest(ctx, sess, inFrame)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) AdminInfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x07_0x02_AdminInfoQuery{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.AdminService.InfoQuery(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) AdminInfoChangeRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x07_0x04_AdminInfoChangeRequest{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.AdminService.InfoChangeRequest(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) AlertNotifyCapabilities(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, _ ResponseWriter) error {
+	rt.LogRequest(ctx, inFrame, nil)
+	return nil
+}
+
+func (rt Handler) AlertNotifyDisplayCapabilities(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, _ ResponseWriter) error {
+	rt.LogRequest(ctx, inFrame, nil)
+	return nil
+}
+
+func (rt Handler) BARTUploadQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x10_0x02_BARTUploadQuery{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.BARTService.UpsertItem(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, outSNAC, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) BARTDownloadQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x10_0x04_BARTDownloadQuery{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.BARTService.RetrieveItem(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, outSNAC, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) BuddyRightsQuery(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inSNAC := wire.SNAC_0x03_0x02_BuddyRightsQuery{}
+	if err := wire.UnmarshalBE(&inSNAC, r); err != nil {
+		return err
+	}
+	outSNAC := rt.BuddyService.RightsQuery(ctx, inFrame)
+	rt.LogRequestAndResponse(ctx, inFrame, inSNAC, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) BuddyAddBuddies(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inSNAC := wire.SNAC_0x03_0x04_BuddyAddBuddies{}
+	if err := wire.UnmarshalBE(&inSNAC, r); err != nil {
+		return err
+	}
+	rt.LogRequest(ctx, inFrame, inSNAC)
+	return rt.BuddyService.AddBuddies(ctx, sess, inSNAC)
+}
+
+func (rt Handler) BuddyDelBuddies(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inSNAC := wire.SNAC_0x03_0x05_BuddyDelBuddies{}
+	if err := wire.UnmarshalBE(&inSNAC, r); err != nil {
+		return err
+	}
+	rt.LogRequest(ctx, inFrame, inSNAC)
+	return rt.BuddyService.DelBuddies(ctx, sess, inSNAC)
+}
+
+func (rt Handler) ChatChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.ChatService.ChannelMsgToHost(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	if outSNAC == nil {
+		return nil
+	}
+	rt.Logger.InfoContext(ctx, "user sent a chat message")
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) ChatNavRequestChatRights(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw ResponseWriter) error {
+	outSNAC := rt.ChatNavService.RequestChatRights(ctx, inFrame)
+	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) ChatNavRequestExchangeInfo(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.ChatNavService.ExchangeInfo(ctx, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) ChatNavRequestRoomInfo(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.ChatNavService.RequestRoomInfo(ctx, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) ChatNavCreateRoom(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.ChatNavService.CreateRoom(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	roomName, _ := inBody.String(wire.ChatRoomTLVRoomName)
+	rt.Logger.InfoContext(ctx, "user started a chat room", slog.String("roomName", roomName))
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) FeedbagRightsQuery(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x13_0x02_FeedbagRightsQuery{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC := rt.FeedbagService.RightsQuery(ctx, inFrame)
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) FeedbagQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw ResponseWriter) error {
+	outSNAC, err := rt.FeedbagService.Query(ctx, sess, inFrame)
+	if err != nil {
+		return err
+	}
+	rt.LogRequest(ctx, inFrame, outSNAC)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) FeedbagQueryIfModified(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x13_0x05_FeedbagQueryIfModified{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.FeedbagService.QueryIfModified(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) FeedbagUse(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, _ ResponseWriter) error {
+	rt.LogRequest(ctx, inFrame, nil)
+	return rt.FeedbagService.Use(ctx, sess)
+}
+
+func (rt Handler) FeedbagInsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x13_0x08_FeedbagInsertItem{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.FeedbagService.UpsertItem(ctx, sess, inFrame, inBody.Items)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) FeedbagUpdateItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x13_0x09_FeedbagUpdateItem{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.FeedbagService.UpsertItem(ctx, sess, inFrame, inBody.Items)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) FeedbagDeleteItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.FeedbagService.DeleteItem(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) FeedbagStartCluster(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, _ ResponseWriter) error {
+	inBody := wire.SNAC_0x13_0x11_FeedbagStartCluster{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	rt.FeedbagService.StartCluster(ctx, inFrame, inBody)
+	rt.LogRequest(ctx, inFrame, inBody)
+	return nil
+}
+
+func (rt Handler) FeedbagEndCluster(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, _ ResponseWriter) error {
+	rt.LogRequest(ctx, inFrame, nil)
+	return nil
+}
+
+func (rt Handler) FeedbagRespondAuthorizeToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	if err := rt.FeedbagService.RespondAuthorizeToHost(ctx, sess, inFrame, inBody); err != nil {
+		return err
+	}
+	rt.LogRequest(ctx, inFrame, inBody)
+	return nil
+}
+
+func (rt Handler) ICBMAddParameters(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, _ ResponseWriter) error {
+	inBody := wire.SNAC_0x04_0x02_ICBMAddParameters{}
+	rt.LogRequest(ctx, inFrame, inBody)
+	return wire.UnmarshalBE(&inBody, r)
+}
+
+func (rt Handler) ICBMParameterQuery(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw ResponseWriter) error {
+	outSNAC := rt.ICBMService.ParameterQuery(ctx, inFrame)
+	rt.LogRequestAndResponse(ctx, inFrame, outSNAC, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) ICBMChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.ICBMService.ChannelMsgToHost(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.Logger.InfoContext(ctx, "user sent an IM", slog.String("recipient", inBody.ScreenName))
+	if outSNAC == nil {
+		return nil
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) ICBMEvilRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x04_0x08_ICBMEvilRequest{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.ICBMService.EvilRequest(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) ICBMClientErr(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, _ ResponseWriter) error {
+	inBody := wire.SNAC_0x04_0x0B_ICBMClientErr{}
+	rt.LogRequest(ctx, inFrame, inBody)
+	err := wire.UnmarshalBE(&inBody, r)
+	if err != nil {
+		return err
+	}
+	return rt.ICBMService.ClientErr(ctx, sess, inFrame, inBody)
+}
+
+func (rt Handler) ICBMClientEvent(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, _ ResponseWriter) error {
+	inBody := wire.SNAC_0x04_0x14_ICBMClientEvent{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	rt.LogRequest(ctx, inFrame, inBody)
+	return rt.ICBMService.ClientEvent(ctx, sess, inFrame, inBody)
+}
+
+func (rt Handler) ICQDBQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x15_0x02_BQuery{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+
+	md, ok := inBody.Bytes(wire.ICQTLVTagsMetadata)
+	if !ok {
+		return errors.New("invalid ICQ frame")
+	}
+
+	icqChunk := wire.ICQMessageRequestEnvelope{}
+	if err := wire.UnmarshalLE(&icqChunk, bytes.NewBuffer(md)); err != nil {
+		return err
+	}
+	buf := bytes.NewBuffer(icqChunk.Body)
+	icqMD := wire.ICQMetadataWithSubType{}
+	if err := wire.UnmarshalLE(&icqMD, buf); err != nil {
+		return err
+	}
+
+	switch icqMD.ReqType {
+	case wire.ICQDBQueryOfflineMsgReq:
+		return rt.ICQService.OfflineMsgReq(ctx, sess, icqMD.Seq)
+	case wire.ICQDBQueryDeleteMsgReq:
+		return rt.ICQService.DeleteMsgReq(ctx, sess, icqMD.Seq)
+	case wire.ICQDBQueryMetaReq:
+		if icqMD.Optional == nil {
+			return errors.New("got req without subtype")
+		}
+		rt.Logger.Debug("ICQ client request",
+			"query_name", wire.ICQDBQueryName(icqMD.ReqType),
+			"query_type", wire.ICQDBQueryMetaName(icqMD.Optional.ReqSubType),
+			"uin", sess.UIN())
+
+		switch icqMD.Optional.ReqSubType {
+		case wire.ICQDBQueryMetaReqShortInfo:
+			userInfo := wire.ICQ_0x07D0_0x04BA_DBQueryMetaReqShortInfo{}
+			if err := binary.Read(buf, binary.LittleEndian, &userInfo); err != nil {
+				return nil
+			}
+			return rt.ICQService.ShortUserInfo(ctx, sess, userInfo, icqMD.Seq)
+		case wire.ICQDBQueryMetaReqFullInfo, wire.ICQDBQueryMetaReqFullInfo2:
+			userInfo := wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{}
+			if err := binary.Read(buf, binary.LittleEndian, &userInfo); err != nil {
+				return nil
+			}
+			return rt.ICQService.FullUserInfo(ctx, sess, userInfo, icqMD.Seq)
+		case wire.ICQDBQueryMetaReqXMLReq:
+			req := wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.XMLReqData(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSetPermissions:
+			req := wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetPermissions(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSearchByUIN:
+			req := wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.FindByUIN(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSearchByUIN2:
+			rest := buf.Bytes()
+			if bytes.HasPrefix(rest, []byte{0x36, 0x01, 0x06, 0x00}) && len(rest) == 8 {
+				// fix incorrect TLV len set by QIP 2005. it specifies len=6
+				// for a 4-byte value, causing the unmarshaler to return EOF.
+				rest[2] = 4
+			}
+			req := wire.ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2{}
+			if err := wire.UnmarshalLE(&req, bytes.NewReader(rest)); err != nil {
+				return err
+			}
+			if err := rt.ICQService.FindByUIN2(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSearchByEmail:
+			req := wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.FindByICQEmail(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSearchByEmail3:
+			req := wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.FindByEmail3(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSearchByDetails:
+			req := wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.FindByICQName(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSearchWhitePages:
+			req := wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.FindByICQInterests(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSearchWhitePages2:
+			req := wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.FindByWhitePages2(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSetBasicInfo:
+			req := wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetBasicInfo(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSetWorkInfo:
+			req := wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetWorkInfo(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSetMoreInfo:
+			req := wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetMoreInfo(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSetNotes:
+			req := wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetUserNotes(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSetEmails:
+			req := wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetEmails(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSetInterests:
+			req := wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetInterests(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqSetAffiliations:
+			req := wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations{}
+			if err := wire.UnmarshalLE(&req, buf); err != nil {
+				return err
+			}
+			if err := rt.ICQService.SetAffiliations(ctx, sess, req, icqMD.Seq); err != nil {
+				return err
+			}
+		case wire.ICQDBQueryMetaReqStat0a8c,
+			wire.ICQDBQueryMetaReqStat0a96,
+			wire.ICQDBQueryMetaReqStat0aaa,
+			wire.ICQDBQueryMetaReqStat0ab4,
+			wire.ICQDBQueryMetaReqStat0ab9,
+			wire.ICQDBQueryMetaReqStat0abe,
+			wire.ICQDBQueryMetaReqStat0ac8,
+			wire.ICQDBQueryMetaReqStat0acd,
+			wire.ICQDBQueryMetaReqStat0ad2,
+			wire.ICQDBQueryMetaReqStat0ad7,
+			wire.ICQDBQueryMetaReqStat0758:
+			rt.Logger.Debug("got a request for stats, not doing anything right now")
+		default:
+			return fmt.Errorf("%w: %X", errUnknownICQMetaReqSubType, icqMD.Optional.ReqSubType)
+		}
+	default:
+		return fmt.Errorf("%w: %X", errUnknownICQMetaReqType, icqMD.ReqType)
+	}
+
+	return nil
+}
+
+func (rt Handler) LocateRightsQuery(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw ResponseWriter) error {
+	outSNAC := rt.LocateService.RightsQuery(ctx, inFrame)
+	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) LocateSetInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, _ ResponseWriter) error {
+	inBody := wire.SNAC_0x02_0x04_LocateSetInfo{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	rt.LogRequest(ctx, inFrame, inBody)
+	return rt.LocateService.SetInfo(ctx, sess, inBody)
+}
+
+func (rt Handler) LocateSetDirInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x02_0x09_LocateSetDirInfo{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.LocateService.SetDirInfo(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) LocateGetDirInfo(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x02_0x0B_LocateGetDirInfo{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.LocateService.DirInfo(ctx, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) LocateSetKeywordInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x02_0x0F_LocateSetKeywordInfo{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.LocateService.SetKeywordInfo(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) LocateUserInfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x02_0x05_LocateUserInfoQuery{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.LocateService.UserInfoQuery(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) LocateUserInfoQuery2(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x02_0x15_LocateUserInfoQuery2{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	// SNAC functionality for LocateUserInfoQuery and LocateUserInfoQuery2 is
+	// identical except for the Type field data type (uint16 vs uint32).
+	wrappedBody := wire.SNAC_0x02_0x05_LocateUserInfoQuery{
+		Type:       uint16(inBody.Type2),
+		ScreenName: inBody.ScreenName,
+	}
+	outSNAC, err := rt.LocateService.UserInfoQuery(ctx, sess, inFrame, wrappedBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) ODirInfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x0F_0x02_InfoQuery{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.ODirService.InfoQuery(ctx, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, outSNAC, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) ODirKeywordListQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	outSNAC, err := rt.ODirService.KeywordListQuery(ctx, inFrame)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) OServiceRateParamsQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw ResponseWriter) error {
+	outSNAC := rt.OServiceService.RateParamsQuery(ctx, sess, inFrame)
+	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) OServiceRateParamsSubAdd(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	rt.OServiceService.RateParamsSubAdd(ctx, sess, inBody)
+	rt.LogRequest(ctx, inFrame, inBody)
+	return nil
+}
+
+func (rt Handler) OServiceUserInfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw ResponseWriter) error {
+	outSNAC := rt.OServiceService.UserInfoQuery(ctx, sess, inFrame)
+	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) OServiceIdleNotification(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, _ ResponseWriter) error {
+	inBody := wire.SNAC_0x01_0x11_OServiceIdleNotification{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	rt.LogRequest(ctx, inFrame, inBody)
+	return rt.OServiceService.IdleNotification(ctx, sess, inBody)
+}
+
+func (rt Handler) OServiceClientVersions(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x01_0x17_OServiceClientVersions{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC := rt.OServiceService.ClientVersions(ctx, sess, inFrame, inBody)
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) OServiceSetUserInfoFields(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.OServiceService.SetUserInfoFields(ctx, sess, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) OServiceNoop(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw ResponseWriter) error {
+	// no-op keep-alive
+	rt.LogRequest(ctx, inFrame, nil)
+	return nil
+}
+
+func (rt Handler) OServiceSetPrivacyFlags(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, _ ResponseWriter) error {
+	inBody := wire.SNAC_0x01_0x14_OServiceSetPrivacyFlags{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	rt.OServiceService.SetPrivacyFlags(ctx, inBody)
+	rt.LogRequest(ctx, inFrame, inBody)
+	return nil
+}
+
+func (rt Handler) OServiceServiceRequest(ctx context.Context, service uint16, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, advertisedHost string) error {
+	inBody := wire.SNAC_0x01_0x04_OServiceServiceRequest{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.OServiceService.ServiceRequest(ctx, service, sess, inFrame, inBody, advertisedHost)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) OServiceClientOnline(ctx context.Context, service uint16, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, _ ResponseWriter) error {
+	inBody := wire.SNAC_0x01_0x02_OServiceClientOnline{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	rt.Logger.InfoContext(ctx, "user signed on")
+	rt.LogRequest(ctx, inFrame, inBody)
+	return rt.OServiceService.ClientOnline(ctx, service, inBody, sess)
+}
+
+func (rt Handler) PermitDenyRightsQuery(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw ResponseWriter) error {
+	outSNAC := rt.PermitDenyService.RightsQuery(ctx, inFrame)
+	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) PermitDenyAddDenyListEntries(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	rt.LogRequest(ctx, inFrame, inBody)
+	return rt.PermitDenyService.AddDenyListEntries(ctx, sess, inBody)
+}
+
+func (rt Handler) PermitDenyDelDenyListEntries(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x09_0x08_PermitDenyDelDenyListEntries{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	rt.LogRequest(ctx, inFrame, inBody)
+	return rt.PermitDenyService.DelDenyListEntries(ctx, sess, inBody)
+}
+
+func (rt Handler) PermitDenyAddPermListEntries(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	rt.LogRequest(ctx, inFrame, inBody)
+	return rt.PermitDenyService.AddPermListEntries(ctx, sess, inBody)
+}
+
+func (rt Handler) PermitDenyDelPermListEntries(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x09_0x06_PermitDenyDelPermListEntries{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	rt.LogRequest(ctx, inFrame, inBody)
+	return rt.PermitDenyService.DelPermListEntries(ctx, sess, inBody)
+}
+
+// PermitDenySetGroupPermitMask sets the classes of users I can interact with. We don't
+// apply any of these settings to the privacy mechanism, so just log them for
+// now.
+func (rt Handler) PermitDenySetGroupPermitMask(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x09_0x04_PermitDenySetGroupPermitMask{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+
+	var flags []string
+
+	if inBody.IsFlagSet(wire.OServiceUserFlagUnconfirmed) {
+		flags = append(flags, "wire.OServiceUserFlagUnconfirmed")
+	}
+	if inBody.IsFlagSet(wire.OServiceUserFlagAdministrator) {
+		flags = append(flags, "wire.OServiceUserFlagAdministrator")
+	}
+	if inBody.IsFlagSet(wire.OServiceUserFlagAOL) {
+		flags = append(flags, "wire.OServiceUserFlagAOL")
+	}
+	if inBody.IsFlagSet(wire.OServiceUserFlagOSCARPay) {
+		flags = append(flags, "wire.OServiceUserFlagOSCARPay")
+	}
+	if inBody.IsFlagSet(wire.OServiceUserFlagOSCARFree) {
+		flags = append(flags, "wire.OServiceUserFlagOSCARFree")
+	}
+	if inBody.IsFlagSet(wire.OServiceUserFlagUnavailable) {
+		flags = append(flags, "wire.OServiceUserFlagUnavailable")
+	}
+	if inBody.IsFlagSet(wire.OServiceUserFlagICQ) {
+		flags = append(flags, "wire.OServiceUserFlagICQ")
+	}
+	if inBody.IsFlagSet(wire.OServiceUserFlagWireless) {
+		flags = append(flags, "wire.OServiceUserFlagWireless")
+	}
+	if inBody.IsFlagSet(wire.OServiceUserFlagInternal) {
+		flags = append(flags, "wire.OServiceUserFlagInternal")
+	}
+	if inBody.IsFlagSet(wire.OServiceUserFlagFish) {
+		flags = append(flags, "wire.OServiceUserFlagFish")
+	}
+	if inBody.IsFlagSet(wire.OServiceUserFlagBot) {
+		flags = append(flags, "wire.OServiceUserFlagBot")
+	}
+	if inBody.IsFlagSet(wire.OServiceUserFlagBeast) {
+		flags = append(flags, "wire.OServiceUserFlagBeast")
+	}
+	if inBody.IsFlagSet(wire.OServiceUserFlagOneWayWireless) {
+		flags = append(flags, "wire.OServiceUserFlagOneWayWireless")
+	}
+	if inBody.IsFlagSet(wire.OServiceUserFlagOfficial) {
+		flags = append(flags, "wire.OServiceUserFlagOfficial")
+	}
+
+	rt.Logger.Info("set pd group mask", "flags", flags)
+	rt.LogRequest(ctx, inFrame, inBody)
+
+	return nil
+}
+
+func (rt Handler) StatsReportEvents(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x0B_0x03_StatsReportEvents{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+
+	outSNAC := rt.StatsService.ReportEvents(ctx, inFrame, inBody)
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+func (rt Handler) UserLookupFindByEmail(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x0A_0x02_UserLookupFindByEmail{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNAC, err := rt.UserLookupService.FindByEmail(ctx, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
+	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
+}
+
+// Handle directs an incoming OSCAR request to the appropriate handler based on
+// its group and subGroup identifiers found in the SNAC frame. It returns an
+// ErrRouteNotFound error if no matching handler is found for the group:subGroup
+// pair in the request.
+func (rt Handler) Handle(ctx context.Context, server uint16, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, advertisedHost string) error {
+	switch inFrame.FoodGroup {
+	case wire.Admin:
+		switch inFrame.SubGroup {
+		case wire.AdminAcctConfirmRequest:
+			return rt.AdminConfirmRequest(ctx, sess, inFrame, r, rw)
+		case wire.AdminInfoChangeRequest:
+			return rt.AdminInfoChangeRequest(ctx, sess, inFrame, r, rw)
+		case wire.AdminInfoQuery:
+			return rt.AdminInfoQuery(ctx, sess, inFrame, r, rw)
+		}
+	case wire.Alert:
+		switch inFrame.SubGroup {
+		case wire.AlertNotifyCapabilities:
+			return rt.AlertNotifyCapabilities(ctx, sess, inFrame, r, rw)
+		case wire.AlertNotifyDisplayCapabilities:
+			return rt.AlertNotifyDisplayCapabilities(ctx, sess, inFrame, r, rw)
+		}
+	case wire.BART:
+		switch inFrame.SubGroup {
+		case wire.BARTDownloadQuery:
+			return rt.BARTDownloadQuery(ctx, sess, inFrame, r, rw)
+		case wire.BARTUploadQuery:
+			return rt.BARTUploadQuery(ctx, sess, inFrame, r, rw)
+		}
+	case wire.Buddy:
+		switch inFrame.SubGroup {
+		case wire.BuddyAddBuddies:
+			return rt.BuddyAddBuddies(ctx, sess, inFrame, r, rw)
+		case wire.BuddyDelBuddies:
+			return rt.BuddyDelBuddies(ctx, sess, inFrame, r, rw)
+		case wire.BuddyRightsQuery:
+			return rt.BuddyRightsQuery(ctx, sess, inFrame, r, rw)
+		}
+	case wire.Chat:
+		switch inFrame.SubGroup {
+		case wire.ChatChannelMsgToHost:
+			return rt.ChatChannelMsgToHost(ctx, sess, inFrame, r, rw)
+		}
+	case wire.ChatNav:
+		switch inFrame.SubGroup {
+		case wire.ChatNavCreateRoom:
+			return rt.ChatNavCreateRoom(ctx, sess, inFrame, r, rw)
+		case wire.ChatNavRequestChatRights:
+			return rt.ChatNavRequestChatRights(ctx, sess, inFrame, r, rw)
+		case wire.ChatNavRequestExchangeInfo:
+			return rt.ChatNavRequestExchangeInfo(ctx, sess, inFrame, r, rw)
+		case wire.ChatNavRequestRoomInfo:
+			return rt.ChatNavRequestRoomInfo(ctx, sess, inFrame, r, rw)
+		}
+	case wire.Feedbag:
+		switch inFrame.SubGroup {
+		case wire.FeedbagDeleteItem:
+			return rt.FeedbagDeleteItem(ctx, sess, inFrame, r, rw)
+		case wire.FeedbagEndCluster:
+			return rt.FeedbagEndCluster(ctx, sess, inFrame, r, rw)
+		case wire.FeedbagInsertItem:
+			return rt.FeedbagInsertItem(ctx, sess, inFrame, r, rw)
+		case wire.FeedbagQuery:
+			return rt.FeedbagQuery(ctx, sess, inFrame, r, rw)
+		case wire.FeedbagQueryIfModified:
+			return rt.FeedbagQueryIfModified(ctx, sess, inFrame, r, rw)
+		case wire.FeedbagRespondAuthorizeToHost:
+			return rt.FeedbagRespondAuthorizeToHost(ctx, sess, inFrame, r, rw)
+		case wire.FeedbagRightsQuery:
+			return rt.FeedbagRightsQuery(ctx, sess, inFrame, r, rw)
+		case wire.FeedbagStartCluster:
+			return rt.FeedbagStartCluster(ctx, sess, inFrame, r, rw)
+		case wire.FeedbagUpdateItem:
+			return rt.FeedbagUpdateItem(ctx, sess, inFrame, r, rw)
+		case wire.FeedbagUse:
+			return rt.FeedbagUse(ctx, sess, inFrame, r, rw)
+		}
+	case wire.ICQ:
+		switch inFrame.SubGroup {
+		case wire.ICQDBQuery:
+			return rt.ICQDBQuery(ctx, sess, inFrame, r, rw)
+		}
+	case wire.ICBM:
+		switch inFrame.SubGroup {
+		case wire.ICBMAddParameters:
+			return rt.ICBMAddParameters(ctx, sess, inFrame, r, rw)
+		case wire.ICBMChannelMsgToHost:
+			return rt.ICBMChannelMsgToHost(ctx, sess, inFrame, r, rw)
+		case wire.ICBMClientErr:
+			return rt.ICBMClientErr(ctx, sess, inFrame, r, rw)
+		case wire.ICBMClientEvent:
+			return rt.ICBMClientEvent(ctx, sess, inFrame, r, rw)
+		case wire.ICBMEvilRequest:
+			return rt.ICBMEvilRequest(ctx, sess, inFrame, r, rw)
+		case wire.ICBMParameterQuery:
+			return rt.ICBMParameterQuery(ctx, sess, inFrame, r, rw)
+		}
+	case wire.Locate:
+		switch inFrame.SubGroup {
+		case wire.LocateGetDirInfo:
+			return rt.LocateGetDirInfo(ctx, sess, inFrame, r, rw)
+		case wire.LocateRightsQuery:
+			return rt.LocateRightsQuery(ctx, sess, inFrame, r, rw)
+		case wire.LocateSetDirInfo:
+			return rt.LocateSetDirInfo(ctx, sess, inFrame, r, rw)
+		case wire.LocateSetInfo:
+			return rt.LocateSetInfo(ctx, sess, inFrame, r, rw)
+		case wire.LocateSetKeywordInfo:
+			return rt.LocateSetKeywordInfo(ctx, sess, inFrame, r, rw)
+		case wire.LocateUserInfoQuery:
+			return rt.LocateUserInfoQuery(ctx, sess, inFrame, r, rw)
+		case wire.LocateUserInfoQuery2:
+			return rt.LocateUserInfoQuery2(ctx, sess, inFrame, r, rw)
+		}
+	case wire.ODir:
+		switch inFrame.SubGroup {
+		case wire.ODirInfoQuery:
+			return rt.ODirInfoQuery(ctx, sess, inFrame, r, rw)
+		case wire.ODirKeywordListQuery:
+			return rt.ODirKeywordListQuery(ctx, sess, inFrame, r, rw)
+		}
+	case wire.OService:
+		switch inFrame.SubGroup {
+		case wire.OServiceClientOnline:
+			return rt.OServiceClientOnline(ctx, server, sess, inFrame, r, rw)
+		case wire.OServiceClientVersions:
+			return rt.OServiceClientVersions(ctx, sess, inFrame, r, rw)
+		case wire.OServiceIdleNotification:
+			return rt.OServiceIdleNotification(ctx, sess, inFrame, r, rw)
+		case wire.OServiceNoop:
+			return rt.OServiceNoop(ctx, sess, inFrame, r, rw)
+		case wire.OServiceRateParamsQuery:
+			return rt.OServiceRateParamsQuery(ctx, sess, inFrame, r, rw)
+		case wire.OServiceRateParamsSubAdd:
+			return rt.OServiceRateParamsSubAdd(ctx, sess, inFrame, r, rw)
+		case wire.OServiceServiceRequest:
+			return rt.OServiceServiceRequest(ctx, server, sess, inFrame, r, rw, advertisedHost)
+		case wire.OServiceSetPrivacyFlags:
+			return rt.OServiceSetPrivacyFlags(ctx, sess, inFrame, r, rw)
+		case wire.OServiceSetUserInfoFields:
+			return rt.OServiceSetUserInfoFields(ctx, sess, inFrame, r, rw)
+		case wire.OServiceUserInfoQuery:
+			return rt.OServiceUserInfoQuery(ctx, sess, inFrame, r, rw)
+		}
+	case wire.PermitDeny:
+		switch inFrame.SubGroup {
+		case wire.PermitDenyAddDenyListEntries:
+			return rt.PermitDenyAddDenyListEntries(ctx, sess, inFrame, r, rw)
+		case wire.PermitDenyAddPermListEntries:
+			return rt.PermitDenyAddPermListEntries(ctx, sess, inFrame, r, rw)
+		case wire.PermitDenyDelDenyListEntries:
+			return rt.PermitDenyDelDenyListEntries(ctx, sess, inFrame, r, rw)
+		case wire.PermitDenyDelPermListEntries:
+			return rt.PermitDenyDelPermListEntries(ctx, sess, inFrame, r, rw)
+		case wire.PermitDenyRightsQuery:
+			return rt.PermitDenyRightsQuery(ctx, sess, inFrame, r, rw)
+		case wire.PermitDenySetGroupPermitMask:
+			return rt.PermitDenySetGroupPermitMask(ctx, sess, inFrame, r, rw)
+		}
+	case wire.Stats:
+		switch inFrame.SubGroup {
+		case wire.StatsReportEvents:
+			return rt.StatsReportEvents(ctx, sess, inFrame, r, rw)
+		}
+	case wire.UserLookup:
+		switch inFrame.SubGroup {
+		case wire.UserLookupFindByEmail:
+			return rt.UserLookupFindByEmail(ctx, sess, inFrame, r, rw)
+
+		}
+	}
+
+	return ErrRouteNotFound
+}

+ 0 - 68
server/oscar/handler/admin.go

@@ -1,68 +0,0 @@
-package handler
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/server/oscar"
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-func NewAdminHandler(logger *slog.Logger, adminService AdminService) AdminHandler {
-	return AdminHandler{
-		RouteLogger: middleware.RouteLogger{
-			Logger: logger,
-		},
-		AdminService: adminService,
-	}
-}
-
-type AdminHandler struct {
-	AdminService
-	OServiceService
-	middleware.RouteLogger
-}
-
-type AdminService interface {
-	ConfirmRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame) (wire.SNACMessage, error)
-	InfoQuery(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x02_AdminInfoQuery) (wire.SNACMessage, error)
-	InfoChangeRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x04_AdminInfoChangeRequest) (wire.SNACMessage, error)
-}
-
-func (rt AdminHandler) ConfirmRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
-	outSNAC, err := rt.AdminService.ConfirmRequest(ctx, sess, inFrame)
-	if err != nil {
-		return err
-	}
-	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (rt AdminHandler) InfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x07_0x02_AdminInfoQuery{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := rt.AdminService.InfoQuery(ctx, sess, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (rt AdminHandler) InfoChangeRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x07_0x04_AdminInfoChangeRequest{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := rt.AdminService.InfoChangeRequest(ctx, sess, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}

+ 0 - 140
server/oscar/handler/admin_test.go

@@ -1,140 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"log/slog"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/wire"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestAdminHandler_ConfirmRequest(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Admin,
-			SubGroup:  wire.AdminAcctConfirmRequest,
-		},
-		Body: wire.SNAC_0x07_0x06_AdminConfirmRequest{},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Admin,
-			SubGroup:  wire.AdminAcctConfirmReply,
-		},
-		Body: wire.SNAC_0x07_0x07_AdminConfirmReply{
-			Status: wire.AdminAcctConfirmStatusEmailSent,
-		},
-	}
-
-	svc := newMockAdminService(t)
-	svc.EXPECT().
-		ConfirmRequest(nil, mock.Anything, input.Frame).
-		Return(output, nil)
-
-	h := NewAdminHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.ConfirmRequest(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestAdminHandler_InfoQuery_RegistrationStatus(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Admin,
-			SubGroup:  wire.AdminInfoQuery,
-		},
-		Body: wire.SNAC_0x07_0x02_AdminInfoQuery{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(wire.AdminTLVRegistrationStatus, uint16(0x00))},
-			},
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Admin,
-			SubGroup:  wire.AdminInfoReply,
-		},
-		Body: wire.SNAC_0x07_0x03_AdminInfoReply{
-			Permissions: wire.AdminInfoPermissionsReadWrite,
-			TLVBlock: wire.TLVBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(wire.AdminTLVRegistrationStatus, wire.AdminInfoRegStatusFullDisclosure),
-				},
-			},
-		},
-	}
-
-	svc := newMockAdminService(t)
-	svc.EXPECT().
-		ConfirmRequest(mock.Anything, mock.Anything, input.Frame).
-		Return(output, nil)
-
-	h := NewAdminHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.ConfirmRequest(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestAdminHandler_InfoChangeRequest_ScreenNameFormatted(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Admin,
-			SubGroup:  wire.AdminInfoQuery,
-		},
-		Body: wire.SNAC_0x07_0x04_AdminInfoChangeRequest{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(wire.AdminTLVScreenNameFormatted, "Chatting Chuck")},
-			},
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Admin,
-			SubGroup:  wire.AdminInfoChangeReply,
-		},
-		Body: wire.SNAC_0x07_0x05_AdminChangeReply{
-			Permissions: wire.AdminInfoPermissionsReadWrite,
-			TLVBlock: wire.TLVBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(wire.AdminTLVScreenNameFormatted, "Chatting Chuck"),
-				},
-			},
-		},
-	}
-
-	svc := newMockAdminService(t)
-	svc.EXPECT().
-		InfoChangeRequest(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewAdminHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.InfoChangeRequest(nil, nil, input.Frame, buf, responseWriter))
-}

+ 0 - 36
server/oscar/handler/alert.go

@@ -1,36 +0,0 @@
-package handler
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/server/oscar"
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-func NewAlertHandler(logger *slog.Logger) AlertHandler {
-	return AlertHandler{
-		RouteLogger: middleware.RouteLogger{
-			Logger: logger,
-		},
-	}
-}
-
-// AlertHandler just reads the request to placate the client. No need to send a
-// response.
-type AlertHandler struct {
-	middleware.RouteLogger
-}
-
-func (rt AlertHandler) NotifyCapabilities(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, _ oscar.ResponseWriter) error {
-	rt.LogRequest(ctx, inFrame, nil)
-	return nil
-}
-
-func (rt AlertHandler) NotifyDisplayCapabilities(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, _ oscar.ResponseWriter) error {
-	rt.LogRequest(ctx, inFrame, nil)
-	return nil
-}

+ 0 - 45
server/oscar/handler/alert_test.go

@@ -1,45 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"log/slog"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/wire"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func TestAlertHandler_NotifyCapabilities(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Alert,
-			SubGroup:  wire.AlertNotifyCapabilities,
-		},
-		Body: wire.SNACFrame{},
-	}
-
-	h := NewAlertHandler(slog.Default())
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.NotifyCapabilities(nil, nil, input.Frame, buf, nil))
-}
-
-func TestAlertHandler_NotifyDisplayCapabilities(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Alert,
-			SubGroup:  wire.AlertNotifyDisplayCapabilities,
-		},
-		Body: wire.SNACFrame{},
-	}
-
-	h := NewAlertHandler(slog.Default())
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.NotifyDisplayCapabilities(nil, nil, input.Frame, buf, nil))
-}

+ 0 - 57
server/oscar/handler/bart.go

@@ -1,57 +0,0 @@
-package handler
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/server/oscar"
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-type BARTService interface {
-	UpsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x02_BARTUploadQuery) (wire.SNACMessage, error)
-	RetrieveItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error)
-}
-
-func NewBARTHandler(logger *slog.Logger, bartService BARTService) BARTHandler {
-	return BARTHandler{
-		BARTService: bartService,
-		RouteLogger: middleware.RouteLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type BARTHandler struct {
-	BARTService
-	middleware.RouteLogger
-}
-
-func (h BARTHandler) UploadQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x10_0x02_BARTUploadQuery{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := h.UpsertItem(ctx, sess, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, outSNAC, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h BARTHandler) DownloadQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x10_0x04_BARTDownloadQuery{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := h.RetrieveItem(ctx, sess, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, outSNAC, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}

+ 0 - 86
server/oscar/handler/bart_test.go

@@ -1,86 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"log/slog"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-func TestBARTHandler_DownloadQuery(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.BART,
-			SubGroup:  wire.BARTDownloadQuery,
-		},
-		Body: wire.SNAC_0x10_0x04_BARTDownloadQuery{},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.BART,
-			SubGroup:  wire.BARTDownloadReply,
-		},
-		Body: wire.SNAC_0x10_0x05_BARTDownloadReply{
-			ScreenName: "the-screen-name",
-		},
-	}
-
-	svc := newMockBARTService(t)
-	svc.EXPECT().
-		RetrieveItem(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewBARTHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.DownloadQuery(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestBARTHandler_UploadQuery(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.BART,
-			SubGroup:  wire.BARTUploadQuery,
-		},
-		Body: wire.SNAC_0x10_0x02_BARTUploadQuery{
-			Type: 1,
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.BART,
-			SubGroup:  wire.BARTUploadReply,
-		},
-		Body: wire.SNAC_0x10_0x03_BARTUploadReply{
-			Code: wire.BARTReplyCodesSuccess,
-		},
-	}
-
-	svc := newMockBARTService(t)
-	svc.EXPECT().
-		UpsertItem(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewBARTHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.UploadQuery(nil, nil, input.Frame, buf, responseWriter))
-}

+ 0 - 60
server/oscar/handler/buddy.go

@@ -1,60 +0,0 @@
-package handler
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/server/oscar"
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-type BuddyService interface {
-	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
-	AddBuddies(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) error
-	DelBuddies(_ context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x05_BuddyDelBuddies) error
-}
-
-func NewBuddyHandler(logger *slog.Logger, buddyService BuddyService) BuddyHandler {
-	return BuddyHandler{
-		BuddyService: buddyService,
-		RouteLogger: middleware.RouteLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type BuddyHandler struct {
-	BuddyService
-	middleware.RouteLogger
-}
-
-func (rt BuddyHandler) RightsQuery(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inSNAC := wire.SNAC_0x03_0x02_BuddyRightsQuery{}
-	if err := wire.UnmarshalBE(&inSNAC, r); err != nil {
-		return err
-	}
-	outSNAC := rt.BuddyService.RightsQuery(ctx, inFrame)
-	rt.LogRequestAndResponse(ctx, inFrame, inSNAC, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (rt BuddyHandler) AddBuddies(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inSNAC := wire.SNAC_0x03_0x04_BuddyAddBuddies{}
-	if err := wire.UnmarshalBE(&inSNAC, r); err != nil {
-		return err
-	}
-	rt.LogRequest(ctx, inFrame, inSNAC)
-	return rt.BuddyService.AddBuddies(ctx, sess, inSNAC)
-}
-
-func (rt BuddyHandler) DelBuddies(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inSNAC := wire.SNAC_0x03_0x05_BuddyDelBuddies{}
-	if err := wire.UnmarshalBE(&inSNAC, r); err != nil {
-		return err
-	}
-	rt.LogRequest(ctx, inFrame, inSNAC)
-	return rt.BuddyService.DelBuddies(ctx, sess, inSNAC)
-}

+ 0 - 122
server/oscar/handler/buddy_test.go

@@ -1,122 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"log/slog"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/wire"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestBuddyHandler_RightsQuery(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Buddy,
-			SubGroup:  wire.BuddyRightsQuery,
-		},
-		Body: wire.SNAC_0x03_0x02_BuddyRightsQuery{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(0x01, uint16(1000)),
-				},
-			},
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Buddy,
-			SubGroup:  wire.BuddyRightsReply,
-		},
-		Body: wire.SNAC_0x03_0x03_BuddyRightsReply{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(0x01, uint16(1000)),
-				},
-			},
-		},
-	}
-
-	svc := newMockBuddyService(t)
-	svc.EXPECT().
-		RightsQuery(mock.Anything, input.Frame).
-		Return(output)
-
-	h := NewBuddyHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.RightsQuery(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestBuddyHandler_AddBuddies(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Buddy,
-			SubGroup:  wire.BuddyAddBuddies,
-		},
-		Body: wire.SNAC_0x03_0x04_BuddyAddBuddies{
-			Buddies: []struct {
-				ScreenName string `oscar:"len_prefix=uint8"`
-			}{
-				{
-					ScreenName: "user1",
-				},
-			},
-		},
-	}
-
-	svc := newMockBuddyService(t)
-	svc.EXPECT().
-		AddBuddies(mock.Anything, mock.Anything, input.Body).
-		Return(nil)
-
-	h := NewBuddyHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.AddBuddies(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestBuddyHandler_DelBuddies(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Buddy,
-			SubGroup:  wire.BuddyDelBuddies,
-		},
-		Body: wire.SNAC_0x03_0x05_BuddyDelBuddies{
-			Buddies: []struct {
-				ScreenName string `oscar:"len_prefix=uint8"`
-			}{
-				{
-					ScreenName: "user1",
-				},
-			},
-		},
-	}
-
-	svc := newMockBuddyService(t)
-	svc.EXPECT().
-		DelBuddies(mock.Anything, mock.Anything, input.Body).
-		Return(nil)
-
-	h := NewBuddyHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.DelBuddies(nil, nil, input.Frame, buf, responseWriter))
-}

+ 0 - 47
server/oscar/handler/chat.go

@@ -1,47 +0,0 @@
-package handler
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/server/oscar"
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-type ChatService interface {
-	ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error)
-}
-
-func NewChatHandler(logger *slog.Logger, chatService ChatService) ChatHandler {
-	return ChatHandler{
-		ChatService: chatService,
-		RouteLogger: middleware.RouteLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type ChatHandler struct {
-	ChatService
-	middleware.RouteLogger
-}
-
-func (rt ChatHandler) ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := rt.ChatService.ChannelMsgToHost(ctx, sess, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	if outSNAC == nil {
-		return nil
-	}
-	rt.Logger.InfoContext(ctx, "user sent a chat message")
-	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}

+ 0 - 81
server/oscar/handler/chat_nav.go

@@ -1,81 +0,0 @@
-package handler
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/server/oscar"
-
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-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)
-}
-
-func NewChatNavHandler(chatNavService ChatNavService, logger *slog.Logger) ChatNavHandler {
-	return ChatNavHandler{
-		ChatNavService: chatNavService,
-		RouteLogger: middleware.RouteLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type ChatNavHandler struct {
-	ChatNavService
-	middleware.RouteLogger
-}
-
-func (rt ChatNavHandler) RequestChatRights(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
-	outSNAC := rt.ChatNavService.RequestChatRights(ctx, inFrame)
-	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (rt ChatNavHandler) RequestExchangeInfo(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := rt.ChatNavService.ExchangeInfo(ctx, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (rt ChatNavHandler) RequestRoomInfo(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := rt.ChatNavService.RequestRoomInfo(ctx, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (rt ChatNavHandler) CreateRoom(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := rt.ChatNavService.CreateRoom(ctx, sess, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	roomName, _ := inBody.String(wire.ChatRoomTLVRoomName)
-	rt.Logger.InfoContext(ctx, "user started a chat room", slog.String("roomName", roomName))
-	rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}

+ 0 - 195
server/oscar/handler/chat_nav_test.go

@@ -1,195 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"log/slog"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestChatNavHandler_CreateRoom(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ChatNav,
-			SubGroup:  wire.ChatNavCreateRoom,
-		},
-		Body: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
-			Exchange: 1,
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ChatNav,
-			SubGroup:  wire.ChatNavNavInfo,
-		},
-		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{},
-	}
-
-	sess := state.NewSession()
-
-	svc := newMockChatNavService(t)
-	svc.EXPECT().
-		CreateRoom(mock.Anything, sess, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewChatNavHandler(svc, slog.Default())
-
-	ss := newMockResponseWriter(t)
-	ss.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.CreateRoom(nil, sess, input.Frame, buf, ss))
-}
-
-func TestChatNavHandler_CreateRoom_ReadErr(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ChatNav,
-			SubGroup:  wire.ChatNavCreateRoom,
-		},
-		Body: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
-			Exchange: 1,
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ChatNav,
-			SubGroup:  wire.ChatNavNavInfo,
-		},
-		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{},
-	}
-
-	sess := state.NewSession()
-
-	svc := newMockChatNavService(t)
-	svc.EXPECT().
-		CreateRoom(mock.Anything, sess, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewChatNavHandler(svc, slog.Default())
-
-	ss := newMockResponseWriter(t)
-	ss.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.CreateRoom(nil, sess, input.Frame, buf, ss))
-}
-
-func TestChatNavHandler_RequestChatRights(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ChatNav,
-			SubGroup:  wire.ChatNavRequestChatRights,
-		},
-		Body: struct{}{},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ChatNav,
-			SubGroup:  wire.ChatNavNavInfo,
-		},
-		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{},
-	}
-
-	svc := newMockChatNavService(t)
-	svc.EXPECT().
-		RequestChatRights(mock.Anything, input.Frame).
-		Return(output)
-
-	h := NewChatNavHandler(svc, slog.Default())
-
-	ss := newMockResponseWriter(t)
-	ss.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.RequestChatRights(nil, nil, input.Frame, buf, ss))
-}
-
-func TestChatNavHandler_RequestRoomInfo(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ChatNav,
-			SubGroup:  wire.ChatNavRequestRoomInfo,
-		},
-		Body: wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
-			Exchange: 1,
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ChatNav,
-			SubGroup:  wire.ChatNavNavInfo,
-		},
-		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{},
-	}
-
-	svc := newMockChatNavService(t)
-	svc.EXPECT().
-		RequestRoomInfo(mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewChatNavHandler(svc, slog.Default())
-
-	ss := newMockResponseWriter(t)
-	ss.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.RequestRoomInfo(nil, nil, input.Frame, buf, ss))
-}
-
-func TestChatNavHandler_RequestExchangeInfo(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ChatNav,
-			SubGroup:  wire.ChatNavRequestExchangeInfo,
-		},
-		Body: wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo{
-			Exchange: 4,
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ChatNav,
-			SubGroup:  wire.ChatNavNavInfo,
-		},
-		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{},
-	}
-
-	svc := newMockChatNavService(t)
-	svc.EXPECT().
-		ExchangeInfo(mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewChatNavHandler(svc, slog.Default())
-
-	ss := newMockResponseWriter(t)
-	ss.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.RequestExchangeInfo(nil, nil, input.Frame, buf, ss))
-}

+ 0 - 79
server/oscar/handler/chat_test.go

@@ -1,79 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"log/slog"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-func TestChatHandler_ChannelMsgToHost_WithReflectedResponse(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Chat,
-			SubGroup:  wire.ChatChannelMsgToHost,
-		},
-		Body: wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
-			Channel: 4,
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Chat,
-			SubGroup:  wire.ChatChannelMsgToClient,
-		},
-		Body: wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{
-			Channel: 4,
-		},
-	}
-
-	svc := newMockChatService(t)
-	svc.EXPECT().
-		ChannelMsgToHost(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(&output, nil)
-
-	h := NewChatHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.ChannelMsgToHost(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestChatHandler_ChannelMsgToHost_WithoutReflectedResponse(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Chat,
-			SubGroup:  wire.ChatChannelMsgToHost,
-		},
-		Body: wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
-			Channel: 4,
-		},
-	}
-	// nil response from handler means the response is not reflected back to
-	// the caller
-	var output *wire.SNACMessage
-
-	svc := newMockChatService(t)
-	svc.EXPECT().
-		ChannelMsgToHost(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewChatHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t) // omit mock handler call
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.ChannelMsgToHost(nil, nil, input.Frame, buf, responseWriter))
-}

+ 0 - 141
server/oscar/handler/feedbag.go

@@ -1,141 +0,0 @@
-package handler
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/server/oscar"
-
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-type FeedbagService interface {
-	DeleteItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (wire.SNACMessage, error)
-	Query(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame) (wire.SNACMessage, error)
-	QueryIfModified(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error)
-	RespondAuthorizeToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error
-	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
-	StartCluster(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster)
-	UpsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, items []wire.FeedbagItem) (wire.SNACMessage, error)
-	Use(ctx context.Context, sess *state.Session) error
-}
-
-func NewFeedbagHandler(logger *slog.Logger, feedbagService FeedbagService) FeedbagHandler {
-	return FeedbagHandler{
-		FeedbagService: feedbagService,
-		RouteLogger: middleware.RouteLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type FeedbagHandler struct {
-	FeedbagService
-	middleware.RouteLogger
-}
-
-func (h FeedbagHandler) RightsQuery(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x13_0x02_FeedbagRightsQuery{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC := h.FeedbagService.RightsQuery(ctx, inFrame)
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h FeedbagHandler) Query(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
-	outSNAC, err := h.FeedbagService.Query(ctx, sess, inFrame)
-	if err != nil {
-		return err
-	}
-	h.LogRequest(ctx, inFrame, outSNAC)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h FeedbagHandler) QueryIfModified(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x13_0x05_FeedbagQueryIfModified{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := h.FeedbagService.QueryIfModified(ctx, sess, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h FeedbagHandler) Use(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, _ oscar.ResponseWriter) error {
-	h.LogRequest(ctx, inFrame, nil)
-	return h.FeedbagService.Use(ctx, sess)
-}
-
-func (h FeedbagHandler) InsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x13_0x08_FeedbagInsertItem{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := h.FeedbagService.UpsertItem(ctx, sess, inFrame, inBody.Items)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h FeedbagHandler) UpdateItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x13_0x09_FeedbagUpdateItem{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := h.FeedbagService.UpsertItem(ctx, sess, inFrame, inBody.Items)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h FeedbagHandler) DeleteItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := h.FeedbagService.DeleteItem(ctx, sess, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h FeedbagHandler) StartCluster(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, _ oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x13_0x11_FeedbagStartCluster{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	h.FeedbagService.StartCluster(ctx, inFrame, inBody)
-	h.LogRequest(ctx, inFrame, inBody)
-	return nil
-}
-
-func (h FeedbagHandler) EndCluster(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, _ oscar.ResponseWriter) error {
-	h.LogRequest(ctx, inFrame, nil)
-	return nil
-}
-
-func (h FeedbagHandler) RespondAuthorizeToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	if err := h.FeedbagService.RespondAuthorizeToHost(ctx, sess, inFrame, inBody); err != nil {
-		return err
-	}
-	h.LogRequest(ctx, inFrame, inBody)
-	return nil
-}

+ 0 - 372
server/oscar/handler/feedbag_test.go

@@ -1,372 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"log/slog"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/wire"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestFeedbagHandler_DeleteItem(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagDeleteItem,
-		},
-		Body: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
-			Items: []wire.FeedbagItem{
-				{
-					Name: "my-item",
-				},
-			},
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagStatus,
-		},
-		Body: wire.SNAC_0x13_0x0E_FeedbagStatus{
-			Results: []uint16{1234},
-		},
-	}
-
-	svc := newMockFeedbagService(t)
-	svc.EXPECT().
-		DeleteItem(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewFeedbagHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.DeleteItem(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestFeedbagHandler_EndCluster(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagEndCluster,
-		},
-		Body: struct{}{},
-	}
-
-	svc := newMockFeedbagService(t)
-	h := NewFeedbagHandler(slog.Default(), svc)
-	responseWriter := newMockResponseWriter(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.EndCluster(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestFeedbagHandler_InsertItem(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagInsertItem,
-		},
-		Body: wire.SNAC_0x13_0x08_FeedbagInsertItem{
-			Items: []wire.FeedbagItem{
-				{
-					Name: "my-item",
-				},
-			},
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagStatus,
-		},
-		Body: wire.SNAC_0x13_0x0E_FeedbagStatus{
-			Results: []uint16{1234},
-		},
-	}
-
-	svc := newMockFeedbagService(t)
-	svc.EXPECT().
-		UpsertItem(mock.Anything, mock.Anything, input.Frame, input.Body.(wire.SNAC_0x13_0x08_FeedbagInsertItem).Items).
-		Return(output, nil)
-
-	h := NewFeedbagHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.InsertItem(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestFeedbagHandler_Query(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagQuery,
-		},
-		Body: wire.SNAC_0x13_0x02_FeedbagRightsQuery{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					{
-						Tag:   0x01,
-						Value: []byte{1, 2, 3, 4},
-					},
-				},
-			},
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagReply,
-		},
-		Body: wire.SNAC_0x13_0x06_FeedbagReply{
-			Version: 4,
-		},
-	}
-
-	svc := newMockFeedbagService(t)
-	svc.EXPECT().
-		Query(mock.Anything, mock.Anything, input.Frame).
-		Return(output, nil)
-
-	h := NewFeedbagHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.Query(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestFeedbagHandler_QueryIfModified(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagQueryIfModified,
-		},
-		Body: wire.SNAC_0x13_0x05_FeedbagQueryIfModified{
-			LastUpdate: 1234,
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagReply,
-		},
-		Body: wire.SNAC_0x13_0x06_FeedbagReply{
-			LastUpdate: 1234,
-		},
-	}
-
-	svc := newMockFeedbagService(t)
-	svc.EXPECT().
-		QueryIfModified(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewFeedbagHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.QueryIfModified(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestFeedbagHandler_RightsQuery(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagRightsQuery,
-		},
-		Body: wire.SNAC_0x13_0x02_FeedbagRightsQuery{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					{
-						Tag:   0x01,
-						Value: []byte{1, 2, 3, 4},
-					},
-				},
-			},
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagRightsReply,
-		},
-		Body: wire.SNAC_0x13_0x03_FeedbagRightsReply{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					{
-						Tag:   0x01,
-						Value: []byte{1, 2, 3, 4},
-					},
-				},
-			},
-		},
-	}
-
-	svc := newMockFeedbagService(t)
-	svc.EXPECT().
-		RightsQuery(mock.Anything, input.Frame).
-		Return(output)
-
-	h := NewFeedbagHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.RightsQuery(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestFeedbagHandler_StartCluster(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagStartCluster,
-		},
-		Body: wire.SNAC_0x13_0x11_FeedbagStartCluster{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					{
-						Tag:   0x01,
-						Value: []byte{1, 2, 3, 4},
-					},
-				},
-			},
-		},
-	}
-
-	svc := newMockFeedbagService(t)
-	svc.EXPECT().
-		StartCluster(mock.Anything, input.Frame, input.Body)
-
-	h := NewFeedbagHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.StartCluster(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestFeedbagHandler_UpdateItem(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagUpdateItem,
-		},
-		Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
-			Items: []wire.FeedbagItem{
-				{
-					Name: "my-item",
-				},
-			},
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagStatus,
-		},
-		Body: wire.SNAC_0x13_0x0E_FeedbagStatus{
-			Results: []uint16{1234},
-		},
-	}
-
-	svc := newMockFeedbagService(t)
-	svc.EXPECT().
-		UpsertItem(mock.Anything, mock.Anything, input.Frame, input.Body.(wire.SNAC_0x13_0x09_FeedbagUpdateItem).Items).
-		Return(output, nil)
-
-	h := NewFeedbagHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.UpdateItem(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestFeedbagHandler_Use(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagUse,
-		},
-		Body: struct{}{},
-	}
-
-	svc := newMockFeedbagService(t)
-	svc.EXPECT().
-		Use(mock.Anything, mock.Anything).
-		Return(nil)
-
-	h := NewFeedbagHandler(slog.Default(), svc)
-	responseWriter := newMockResponseWriter(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.Use(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestFeedbagHandler_RespondAuthorizeToHost(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Feedbag,
-			SubGroup:  wire.FeedbagRequestAuthorizeToHost,
-		},
-		Body: wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost{
-			ScreenName: "theScreenName",
-		},
-	}
-
-	svc := newMockFeedbagService(t)
-	svc.EXPECT().
-		RespondAuthorizeToHost(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(nil)
-
-	h := NewFeedbagHandler(slog.Default(), svc)
-	responseWriter := newMockResponseWriter(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.RespondAuthorizeToHost(nil, nil, input.Frame, buf, responseWriter))
-}

+ 0 - 96
server/oscar/handler/icbm.go

@@ -1,96 +0,0 @@
-package handler
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/server/oscar"
-
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-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
-}
-
-func NewICBMHandler(logger *slog.Logger, icbmService ICBMService) ICBMHandler {
-	return ICBMHandler{
-		ICBMService: icbmService,
-		RouteLogger: middleware.RouteLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type ICBMHandler struct {
-	ICBMService
-	middleware.RouteLogger
-}
-
-func (h ICBMHandler) AddParameters(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, _ oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x04_0x02_ICBMAddParameters{}
-	h.LogRequest(ctx, inFrame, inBody)
-	return wire.UnmarshalBE(&inBody, r)
-}
-
-func (h ICBMHandler) ParameterQuery(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
-	outSNAC := h.ICBMService.ParameterQuery(ctx, inFrame)
-	h.LogRequestAndResponse(ctx, inFrame, outSNAC, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h ICBMHandler) ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := h.ICBMService.ChannelMsgToHost(ctx, sess, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	h.Logger.InfoContext(ctx, "user sent an IM", slog.String("recipient", inBody.ScreenName))
-	if outSNAC == nil {
-		return nil
-	}
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h ICBMHandler) EvilRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x04_0x08_ICBMEvilRequest{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := h.ICBMService.EvilRequest(ctx, sess, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h ICBMHandler) ClientErr(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, _ oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x04_0x0B_ICBMClientErr{}
-	h.LogRequest(ctx, inFrame, inBody)
-	err := wire.UnmarshalBE(&inBody, r)
-	if err != nil {
-		return err
-	}
-	return h.ICBMService.ClientErr(ctx, sess, inFrame, inBody)
-}
-
-func (h ICBMHandler) ClientEvent(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, _ oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x04_0x14_ICBMClientEvent{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	h.LogRequest(ctx, inFrame, inBody)
-	return h.ICBMService.ClientEvent(ctx, sess, inFrame, inBody)
-}

+ 0 - 197
server/oscar/handler/icbm_test.go

@@ -1,197 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"log/slog"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/wire"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestICBMHandler_AddParameters(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ICBM,
-			SubGroup:  wire.ICBMAddParameters,
-		},
-		Body: wire.SNAC_0x04_0x02_ICBMAddParameters{
-			Channel: 1,
-		},
-	}
-
-	svc := newMockICBMService(t)
-	h := NewICBMHandler(slog.Default(), svc)
-	responseWriter := newMockResponseWriter(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.AddParameters(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestICBMHandler_ChannelMsgToHost(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ICBM,
-			SubGroup:  wire.ICBMChannelMsgToHost,
-		},
-		Body: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
-			ScreenName: "recipient-screen-name",
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ICBM,
-			SubGroup:  wire.ICBMHostAck,
-		},
-		Body: wire.SNAC_0x04_0x0C_ICBMHostAck{
-			ChannelID: 4,
-		},
-	}
-
-	svc := newMockICBMService(t)
-	svc.EXPECT().
-		ChannelMsgToHost(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(&output, nil)
-
-	h := NewICBMHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.ChannelMsgToHost(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestICBMHandler_ClientErr(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ICBM,
-			SubGroup:  wire.ICBMClientErr,
-		},
-		Body: wire.SNAC_0x04_0x0B_ICBMClientErr{
-			Code: 4,
-		},
-	}
-
-	svc := newMockICBMService(t)
-	svc.EXPECT().
-		ClientErr(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(nil)
-
-	h := NewICBMHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.ClientErr(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestICBMHandler_ClientEvent(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ICBM,
-			SubGroup:  wire.ICBMClientEvent,
-		},
-		Body: wire.SNAC_0x04_0x14_ICBMClientEvent{
-			ScreenName: "recipient-screen-name",
-		},
-	}
-
-	svc := newMockICBMService(t)
-	svc.EXPECT().
-		ClientEvent(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(nil)
-
-	h := NewICBMHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.ClientEvent(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestICBMHandler_EvilRequest(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ICBM,
-			SubGroup:  wire.ICBMEvilRequest,
-		},
-		Body: wire.SNAC_0x04_0x08_ICBMEvilRequest{
-			ScreenName: "recipient-screen-name",
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ICBM,
-			SubGroup:  wire.ICBMEvilReply,
-		},
-		Body: wire.SNAC_0x04_0x09_ICBMEvilReply{
-			EvilDeltaApplied: 100,
-		},
-	}
-
-	svc := newMockICBMService(t)
-	svc.EXPECT().
-		EvilRequest(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewICBMHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.EvilRequest(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestICBMHandler_ParameterQuery(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ICBM,
-			SubGroup:  wire.ICBMParameterQuery,
-		},
-		Body: struct{}{}, // empty SNAC
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ICBM,
-			SubGroup:  wire.ICBMParameterReply,
-		},
-		Body: wire.SNAC_0x04_0x05_ICBMParameterReply{
-			MaxSlots: 100,
-		},
-	}
-
-	svc := newMockICBMService(t)
-	svc.EXPECT().
-		ParameterQuery(mock.Anything, input.Frame).
-		Return(output)
-
-	h := NewICBMHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.ParameterQuery(nil, nil, input.Frame, buf, responseWriter))
-}

+ 0 - 262
server/oscar/handler/icq.go

@@ -1,262 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"context"
-	"encoding/binary"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/server/oscar"
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-type ICQService interface {
-	DeleteMsgReq(ctx context.Context, sess *state.Session, seq uint16) error
-	FindByICQName(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error
-	FindByICQEmail(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16) error
-	FindByEmail3(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3, seq uint16) error
-	FindByICQInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16) error
-	FindByUIN(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error
-	FindByUIN2(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2, seq uint16) error
-	FindByWhitePages2(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2, seq uint16) error
-	FullUserInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error
-	OfflineMsgReq(ctx context.Context, sess *state.Session, seq uint16) error
-	SetAffiliations(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations, seq uint16) error
-	SetBasicInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo, seq uint16) error
-	SetEmails(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails, seq uint16) error
-	SetInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests, seq uint16) error
-	SetMoreInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo, seq uint16) error
-	SetPermissions(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions, seq uint16) error
-	SetUserNotes(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes, seq uint16) error
-	SetWorkInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo, seq uint16) error
-	ShortUserInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x04BA_DBQueryMetaReqShortInfo, seq uint16) error
-	XMLReqData(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq, seq uint16) error
-}
-
-var (
-	errUnknownICQMetaReqType    = errors.New("unknown ICQ request type")
-	errUnknownICQMetaReqSubType = errors.New("unknown ICQ metadata request subtype")
-)
-
-func NewICQHandler(logger *slog.Logger, ICQService ICQService) ICQHandler {
-	return ICQHandler{
-		RouteLogger: middleware.RouteLogger{
-			Logger: logger,
-		},
-		ICQService: ICQService,
-	}
-}
-
-type ICQHandler struct {
-	ICQService
-	middleware.RouteLogger
-}
-
-func (rt ICQHandler) DBQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x15_0x02_BQuery{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-
-	md, ok := inBody.Bytes(wire.ICQTLVTagsMetadata)
-	if !ok {
-		return errors.New("invalid ICQ frame")
-	}
-
-	icqChunk := wire.ICQMessageRequestEnvelope{}
-	if err := wire.UnmarshalLE(&icqChunk, bytes.NewBuffer(md)); err != nil {
-		return err
-	}
-	buf := bytes.NewBuffer(icqChunk.Body)
-	icqMD := wire.ICQMetadataWithSubType{}
-	if err := wire.UnmarshalLE(&icqMD, buf); err != nil {
-		return err
-	}
-
-	switch icqMD.ReqType {
-	case wire.ICQDBQueryOfflineMsgReq:
-		return rt.ICQService.OfflineMsgReq(ctx, sess, icqMD.Seq)
-	case wire.ICQDBQueryDeleteMsgReq:
-		return rt.ICQService.DeleteMsgReq(ctx, sess, icqMD.Seq)
-	case wire.ICQDBQueryMetaReq:
-		if icqMD.Optional == nil {
-			return errors.New("got req without subtype")
-		}
-		rt.Logger.Debug("ICQ client request",
-			"query_name", wire.ICQDBQueryName(icqMD.ReqType),
-			"query_type", wire.ICQDBQueryMetaName(icqMD.Optional.ReqSubType),
-			"uin", sess.UIN())
-
-		switch icqMD.Optional.ReqSubType {
-		case wire.ICQDBQueryMetaReqShortInfo:
-			userInfo := wire.ICQ_0x07D0_0x04BA_DBQueryMetaReqShortInfo{}
-			if err := binary.Read(buf, binary.LittleEndian, &userInfo); err != nil {
-				return nil
-			}
-			return rt.ICQService.ShortUserInfo(ctx, sess, userInfo, icqMD.Seq)
-		case wire.ICQDBQueryMetaReqFullInfo, wire.ICQDBQueryMetaReqFullInfo2:
-			userInfo := wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{}
-			if err := binary.Read(buf, binary.LittleEndian, &userInfo); err != nil {
-				return nil
-			}
-			return rt.ICQService.FullUserInfo(ctx, sess, userInfo, icqMD.Seq)
-		case wire.ICQDBQueryMetaReqXMLReq:
-			req := wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq{}
-			if err := wire.UnmarshalLE(&req, buf); err != nil {
-				return err
-			}
-			if err := rt.ICQService.XMLReqData(ctx, sess, req, icqMD.Seq); err != nil {
-				return err
-			}
-		case wire.ICQDBQueryMetaReqSetPermissions:
-			req := wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions{}
-			if err := wire.UnmarshalLE(&req, buf); err != nil {
-				return err
-			}
-			if err := rt.ICQService.SetPermissions(ctx, sess, req, icqMD.Seq); err != nil {
-				return err
-			}
-		case wire.ICQDBQueryMetaReqSearchByUIN:
-			req := wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{}
-			if err := wire.UnmarshalLE(&req, buf); err != nil {
-				return err
-			}
-			if err := rt.ICQService.FindByUIN(ctx, sess, req, icqMD.Seq); err != nil {
-				return err
-			}
-		case wire.ICQDBQueryMetaReqSearchByUIN2:
-			rest := buf.Bytes()
-			if bytes.HasPrefix(rest, []byte{0x36, 0x01, 0x06, 0x00}) && len(rest) == 8 {
-				// fix incorrect TLV len set by QIP 2005. it specifies len=6
-				// for a 4-byte value, causing the unmarshaler to return EOF.
-				rest[2] = 4
-			}
-			req := wire.ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2{}
-			if err := wire.UnmarshalLE(&req, bytes.NewReader(rest)); err != nil {
-				return err
-			}
-			if err := rt.ICQService.FindByUIN2(ctx, sess, req, icqMD.Seq); err != nil {
-				return err
-			}
-		case wire.ICQDBQueryMetaReqSearchByEmail:
-			req := wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail{}
-			if err := wire.UnmarshalLE(&req, buf); err != nil {
-				return err
-			}
-			if err := rt.ICQService.FindByICQEmail(ctx, sess, req, icqMD.Seq); err != nil {
-				return err
-			}
-		case wire.ICQDBQueryMetaReqSearchByEmail3:
-			req := wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3{}
-			if err := wire.UnmarshalLE(&req, buf); err != nil {
-				return err
-			}
-			if err := rt.ICQService.FindByEmail3(ctx, sess, req, icqMD.Seq); err != nil {
-				return err
-			}
-		case wire.ICQDBQueryMetaReqSearchByDetails:
-			req := wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails{}
-			if err := wire.UnmarshalLE(&req, buf); err != nil {
-				return err
-			}
-			if err := rt.ICQService.FindByICQName(ctx, sess, req, icqMD.Seq); err != nil {
-				return err
-			}
-		case wire.ICQDBQueryMetaReqSearchWhitePages:
-			req := wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages{}
-			if err := wire.UnmarshalLE(&req, buf); err != nil {
-				return err
-			}
-			if err := rt.ICQService.FindByICQInterests(ctx, sess, req, icqMD.Seq); err != nil {
-				return err
-			}
-		case wire.ICQDBQueryMetaReqSearchWhitePages2:
-			req := wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2{}
-			if err := wire.UnmarshalLE(&req, buf); err != nil {
-				return err
-			}
-			if err := rt.ICQService.FindByWhitePages2(ctx, sess, req, icqMD.Seq); err != nil {
-				return err
-			}
-		case wire.ICQDBQueryMetaReqSetBasicInfo:
-			req := wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo{}
-			if err := wire.UnmarshalLE(&req, buf); err != nil {
-				return err
-			}
-			if err := rt.ICQService.SetBasicInfo(ctx, sess, req, icqMD.Seq); err != nil {
-				return err
-			}
-		case wire.ICQDBQueryMetaReqSetWorkInfo:
-			req := wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo{}
-			if err := wire.UnmarshalLE(&req, buf); err != nil {
-				return err
-			}
-			if err := rt.ICQService.SetWorkInfo(ctx, sess, req, icqMD.Seq); err != nil {
-				return err
-			}
-		case wire.ICQDBQueryMetaReqSetMoreInfo:
-			req := wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{}
-			if err := wire.UnmarshalLE(&req, buf); err != nil {
-				return err
-			}
-			if err := rt.ICQService.SetMoreInfo(ctx, sess, req, icqMD.Seq); err != nil {
-				return err
-			}
-		case wire.ICQDBQueryMetaReqSetNotes:
-			req := wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes{}
-			if err := wire.UnmarshalLE(&req, buf); err != nil {
-				return err
-			}
-			if err := rt.ICQService.SetUserNotes(ctx, sess, req, icqMD.Seq); err != nil {
-				return err
-			}
-		case wire.ICQDBQueryMetaReqSetEmails:
-			req := wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails{}
-			if err := wire.UnmarshalLE(&req, buf); err != nil {
-				return err
-			}
-			if err := rt.ICQService.SetEmails(ctx, sess, req, icqMD.Seq); err != nil {
-				return err
-			}
-		case wire.ICQDBQueryMetaReqSetInterests:
-			req := wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests{}
-			if err := wire.UnmarshalLE(&req, buf); err != nil {
-				return err
-			}
-			if err := rt.ICQService.SetInterests(ctx, sess, req, icqMD.Seq); err != nil {
-				return err
-			}
-		case wire.ICQDBQueryMetaReqSetAffiliations:
-			req := wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations{}
-			if err := wire.UnmarshalLE(&req, buf); err != nil {
-				return err
-			}
-			if err := rt.ICQService.SetAffiliations(ctx, sess, req, icqMD.Seq); err != nil {
-				return err
-			}
-		case wire.ICQDBQueryMetaReqStat0a8c,
-			wire.ICQDBQueryMetaReqStat0a96,
-			wire.ICQDBQueryMetaReqStat0aaa,
-			wire.ICQDBQueryMetaReqStat0ab4,
-			wire.ICQDBQueryMetaReqStat0ab9,
-			wire.ICQDBQueryMetaReqStat0abe,
-			wire.ICQDBQueryMetaReqStat0ac8,
-			wire.ICQDBQueryMetaReqStat0acd,
-			wire.ICQDBQueryMetaReqStat0ad2,
-			wire.ICQDBQueryMetaReqStat0ad7,
-			wire.ICQDBQueryMetaReqStat0758:
-			rt.Logger.Debug("got a request for stats, not doing anything right now")
-		default:
-			return fmt.Errorf("%w: %X", errUnknownICQMetaReqSubType, icqMD.Optional.ReqSubType)
-		}
-	default:
-		return fmt.Errorf("%w: %X", errUnknownICQMetaReqType, icqMD.ReqType)
-	}
-
-	return nil
-}

+ 0 - 1026
server/oscar/handler/icq_test.go

@@ -1,1026 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"context"
-	"log/slog"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-
-	"github.com/mk6i/retro-aim-server/server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-func TestICQHandler_DBQuery(t *testing.T) {
-	type ICQMetaRequest struct {
-		wire.ICQMetadata
-		ReqSubType  uint16
-		MetaRequest any
-	}
-	type reqParams struct {
-		ctx     context.Context
-		sess    *state.Session
-		inFrame wire.SNACFrame
-		inBody  wire.SNAC_0x15_0x02_BQuery
-		rw      oscar.ResponseWriter
-		seq     uint16
-		wantErr error
-	}
-	type mockParam struct {
-		req     any
-		wantErr error
-	}
-	type allMockParams struct {
-		deleteMsgReq      *mockParam
-		findByDetails     *mockParam
-		findByEmail       *mockParam
-		findByEmail3      *mockParam
-		findByInterests   *mockParam
-		findByUIN         *mockParam
-		findByUIN2        *mockParam
-		findByWhitePages2 *mockParam
-		fullUserInfo      *mockParam
-		offlineMsgReq     *mockParam
-		setAffiliations   *mockParam
-		setBasicInfo      *mockParam
-		setEmails         *mockParam
-		setInterests      *mockParam
-		setMoreInfo       *mockParam
-		setPermissions    *mockParam
-		setUserNotes      *mockParam
-		setWorkInfo       *mockParam
-		shortUserInfo     *mockParam
-		xmlReqData        *mockParam
-	}
-	tests := []struct {
-		name          string
-		reqParams     reqParams
-		allMockParams allMockParams
-	}{
-		{
-			name: "MetaReqFullInfo - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqFullInfo,
-									MetaRequest: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
-										UIN: 123456789,
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				fullUserInfo: &mockParam{
-					req: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
-						UIN: 123456789,
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqShortInfo - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqShortInfo,
-									MetaRequest: wire.ICQ_0x07D0_0x04BA_DBQueryMetaReqShortInfo{
-										UIN: 123456789,
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				shortUserInfo: &mockParam{
-					req: wire.ICQ_0x07D0_0x04BA_DBQueryMetaReqShortInfo{
-						UIN: 123456789,
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqFullInfo2 - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqFullInfo2,
-									MetaRequest: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
-										UIN: 123456789,
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				fullUserInfo: &mockParam{
-					req: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
-						UIN: 123456789,
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqXMLReq - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqXMLReq,
-									MetaRequest: wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq{
-										XMLRequest: "<xml></xml>",
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				xmlReqData: &mockParam{
-					req: wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq{
-						XMLRequest: "<xml></xml>",
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqSetPermissions - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqSetPermissions,
-									MetaRequest: wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions{
-										Authorization: 1,
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				setPermissions: &mockParam{
-					req: wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions{
-						Authorization: 1,
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqSearchByUIN - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqSearchByUIN,
-									MetaRequest: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
-										UIN: 123456789,
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				findByUIN: &mockParam{
-					req: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
-						UIN: 123456789,
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqSearchByUIN2 - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqSearchByUIN2,
-									MetaRequest: wire.ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2{
-										TLVRestBlock: wire.TLVRestBlock{
-											TLVList: wire.TLVList{
-												wire.NewTLVBE(1, uint16(1)),
-											},
-										},
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				findByUIN2: &mockParam{
-					req: wire.ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2{
-						TLVRestBlock: wire.TLVRestBlock{
-							TLVList: wire.TLVList{
-								wire.NewTLVBE(1, uint16(1)),
-							},
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqSearchByEmail - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqSearchByEmail,
-									MetaRequest: wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail{
-										Email: "test@aol.com",
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				findByEmail: &mockParam{
-					req: wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail{
-						Email: "test@aol.com",
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqSearchByEmail3 - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqSearchByEmail3,
-									MetaRequest: wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3{
-										TLVRestBlock: wire.TLVRestBlock{
-											TLVList: wire.TLVList{
-												wire.NewTLVBE(1, uint16(1)),
-											},
-										},
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				findByEmail3: &mockParam{
-					req: wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3{
-						TLVRestBlock: wire.TLVRestBlock{
-							TLVList: wire.TLVList{
-								wire.NewTLVBE(1, uint16(1)),
-							},
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqSearchByDetails - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqSearchByDetails,
-									MetaRequest: wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails{
-										FirstName: "john",
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				findByDetails: &mockParam{
-					req: wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails{
-						FirstName: "john",
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqSearchWhitePages - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqSearchWhitePages,
-									MetaRequest: wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages{
-										InterestsCode: 1,
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				findByInterests: &mockParam{
-					req: wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages{
-						InterestsCode: 1,
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqSearchWhitePages2 - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqSearchWhitePages2,
-									MetaRequest: wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2{
-										TLVRestBlock: wire.TLVRestBlock{
-											TLVList: wire.TLVList{
-												wire.NewTLVBE(1, uint16(1)),
-											},
-										},
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				findByWhitePages2: &mockParam{
-					req: wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2{
-						TLVRestBlock: wire.TLVRestBlock{
-							TLVList: wire.TLVList{
-								wire.NewTLVBE(1, uint16(1)),
-							},
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqSetBasicInfo - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqSetBasicInfo,
-									MetaRequest: wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo{
-										FirstName: "john",
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				setBasicInfo: &mockParam{
-					req: wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo{
-						FirstName: "john",
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqSetWorkInfo - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqSetWorkInfo,
-									MetaRequest: wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo{
-										ZIP: "11111",
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				setWorkInfo: &mockParam{
-					req: wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo{
-						ZIP: "11111",
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqSetMoreInfo - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqSetMoreInfo,
-									MetaRequest: wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{
-										Age: 100,
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				setMoreInfo: &mockParam{
-					req: wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{
-						Age: 100,
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqSetNotes - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqSetNotes,
-									MetaRequest: wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes{
-										Notes: "my note",
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				setUserNotes: &mockParam{
-					req: wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes{
-						Notes: "my note",
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqSetEmails - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqSetEmails,
-									MetaRequest: wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails{
-										Emails: []struct {
-											Publish uint8
-											Email   string `oscar:"len_prefix=uint16,nullterm"`
-										}{
-											{
-												Email: "test@aol.com",
-											},
-										},
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				setEmails: &mockParam{
-					req: wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails{
-						Emails: []struct {
-							Publish uint8
-							Email   string `oscar:"len_prefix=uint16,nullterm"`
-						}{
-							{
-								Email: "test@aol.com",
-							},
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqSetInterests - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqSetInterests,
-									MetaRequest: wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests{
-										Interests: []struct {
-											Code    uint16
-											Keyword string `oscar:"len_prefix=uint16,nullterm"`
-										}{
-											{
-												Keyword: "an_interest",
-											},
-										},
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				setInterests: &mockParam{
-					req: wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests{
-						Interests: []struct {
-							Code    uint16
-							Keyword string `oscar:"len_prefix=uint16,nullterm"`
-						}{
-							{
-								Keyword: "an_interest",
-							},
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqSetAffiliations - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType: wire.ICQDBQueryMetaReqSetAffiliations,
-									MetaRequest: wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations{
-										PastAffiliations: []struct {
-											Code    uint16
-											Keyword string `oscar:"len_prefix=uint16,nullterm"`
-										}{
-											{
-												Keyword: "a_past_affiliation",
-											},
-										},
-										Affiliations: []struct {
-											Code    uint16
-											Keyword string `oscar:"len_prefix=uint16,nullterm"`
-										}{
-											{
-												Keyword: "an_affiliation",
-											},
-										},
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				setAffiliations: &mockParam{
-					req: wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations{
-						PastAffiliations: []struct {
-							Code    uint16
-							Keyword string `oscar:"len_prefix=uint16,nullterm"`
-						}{
-							{
-								Keyword: "a_past_affiliation",
-							},
-						},
-						Affiliations: []struct {
-							Code    uint16
-							Keyword string `oscar:"len_prefix=uint16,nullterm"`
-						}{
-							{
-								Keyword: "an_affiliation",
-							},
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "MetaReqStat - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType:  wire.ICQDBQueryMetaReqStat0a8c,
-									MetaRequest: struct{}{},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-		},
-		{
-			name: "unknown metadata request subtype",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryMetaReq,
-										Seq:     1,
-									},
-									ReqSubType:  0xCA8E,
-									MetaRequest: struct{}{},
-								},
-							}),
-						},
-					},
-				},
-				seq:     1,
-				wantErr: errUnknownICQMetaReqSubType,
-			},
-		},
-		{
-			name: "OfflineMsgReq - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: wire.ICQMetadataWithSubType{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryOfflineMsgReq,
-										Seq:     1,
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				offlineMsgReq: &mockParam{},
-			},
-		},
-		{
-			name: "DeleteMsgReq - happy path",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: wire.ICQMetadataWithSubType{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: wire.ICQDBQueryDeleteMsgReq,
-										Seq:     1,
-									},
-								},
-							}),
-						},
-					},
-				},
-				seq: 1,
-			},
-			allMockParams: allMockParams{
-				deleteMsgReq: &mockParam{},
-			},
-		},
-		{
-			name: "unknown request type",
-			reqParams: reqParams{
-				sess: &state.Session{},
-				inBody: wire.SNAC_0x15_0x02_BQuery{
-					TLVRestBlock: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-								Message: ICQMetaRequest{
-									ICQMetadata: wire.ICQMetadata{
-										ReqType: 0x13B4,
-										Seq:     1,
-									},
-									ReqSubType:  0xCA8E,
-									MetaRequest: struct{}{},
-								},
-							}),
-						},
-					},
-				},
-				seq:     1,
-				wantErr: errUnknownICQMetaReqType,
-			},
-		}, // todo: add to a separate test
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-
-			icqService := newMockICQService(t)
-			switch {
-			case tt.allMockParams.fullUserInfo != nil:
-				icqService.EXPECT().
-					FullUserInfo(mock.Anything, tt.reqParams.sess, tt.allMockParams.fullUserInfo.req, tt.reqParams.seq).
-					Return(tt.allMockParams.fullUserInfo.wantErr)
-			case tt.allMockParams.shortUserInfo != nil:
-				icqService.EXPECT().
-					ShortUserInfo(mock.Anything, tt.reqParams.sess, tt.allMockParams.shortUserInfo.req, tt.reqParams.seq).
-					Return(tt.allMockParams.shortUserInfo.wantErr)
-			case tt.allMockParams.xmlReqData != nil:
-				icqService.EXPECT().
-					XMLReqData(mock.Anything, tt.reqParams.sess, tt.allMockParams.xmlReqData.req, tt.reqParams.seq).
-					Return(tt.allMockParams.xmlReqData.wantErr)
-			case tt.allMockParams.setPermissions != nil:
-				icqService.EXPECT().
-					SetPermissions(mock.Anything, tt.reqParams.sess, tt.allMockParams.setPermissions.req, tt.reqParams.seq).
-					Return(tt.allMockParams.setPermissions.wantErr)
-			case tt.allMockParams.findByUIN != nil:
-				icqService.EXPECT().
-					FindByUIN(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByUIN.req, tt.reqParams.seq).
-					Return(tt.allMockParams.findByUIN.wantErr)
-			case tt.allMockParams.findByUIN2 != nil:
-				icqService.EXPECT().
-					FindByUIN2(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByUIN2.req, tt.reqParams.seq).
-					Return(tt.allMockParams.findByUIN2.wantErr)
-			case tt.allMockParams.findByEmail != nil:
-				icqService.EXPECT().
-					FindByICQEmail(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByEmail.req, tt.reqParams.seq).
-					Return(tt.allMockParams.findByEmail.wantErr)
-			case tt.allMockParams.findByEmail3 != nil:
-				icqService.EXPECT().
-					FindByEmail3(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByEmail3.req, tt.reqParams.seq).
-					Return(tt.allMockParams.findByEmail3.wantErr)
-			case tt.allMockParams.findByDetails != nil:
-				icqService.EXPECT().
-					FindByICQName(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByDetails.req, tt.reqParams.seq).
-					Return(tt.allMockParams.findByDetails.wantErr)
-			case tt.allMockParams.findByInterests != nil:
-				icqService.EXPECT().
-					FindByICQInterests(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByInterests.req, tt.reqParams.seq).
-					Return(tt.allMockParams.findByInterests.wantErr)
-			case tt.allMockParams.findByWhitePages2 != nil:
-				icqService.EXPECT().
-					FindByWhitePages2(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByWhitePages2.req, tt.reqParams.seq).
-					Return(tt.allMockParams.findByWhitePages2.wantErr)
-			case tt.allMockParams.setBasicInfo != nil:
-				icqService.EXPECT().
-					SetBasicInfo(mock.Anything, tt.reqParams.sess, tt.allMockParams.setBasicInfo.req, tt.reqParams.seq).
-					Return(tt.allMockParams.setBasicInfo.wantErr)
-			case tt.allMockParams.setWorkInfo != nil:
-				icqService.EXPECT().
-					SetWorkInfo(mock.Anything, tt.reqParams.sess, tt.allMockParams.setWorkInfo.req, tt.reqParams.seq).
-					Return(tt.allMockParams.setWorkInfo.wantErr)
-			case tt.allMockParams.setMoreInfo != nil:
-				icqService.EXPECT().
-					SetMoreInfo(mock.Anything, tt.reqParams.sess, tt.allMockParams.setMoreInfo.req, tt.reqParams.seq).
-					Return(tt.allMockParams.setMoreInfo.wantErr)
-			case tt.allMockParams.setUserNotes != nil:
-				icqService.EXPECT().
-					SetUserNotes(mock.Anything, tt.reqParams.sess, tt.allMockParams.setUserNotes.req, tt.reqParams.seq).
-					Return(tt.allMockParams.setUserNotes.wantErr)
-			case tt.allMockParams.setEmails != nil:
-				icqService.EXPECT().
-					SetEmails(mock.Anything, tt.reqParams.sess, tt.allMockParams.setEmails.req, tt.reqParams.seq).
-					Return(tt.allMockParams.setEmails.wantErr)
-			case tt.allMockParams.setInterests != nil:
-				icqService.EXPECT().
-					SetInterests(mock.Anything, tt.reqParams.sess, tt.allMockParams.setInterests.req, tt.reqParams.seq).
-					Return(tt.allMockParams.setInterests.wantErr)
-			case tt.allMockParams.setAffiliations != nil:
-				icqService.EXPECT().
-					SetAffiliations(mock.Anything, tt.reqParams.sess, tt.allMockParams.setAffiliations.req, tt.reqParams.seq).
-					Return(tt.allMockParams.setAffiliations.wantErr)
-			case tt.allMockParams.offlineMsgReq != nil:
-				icqService.EXPECT().
-					OfflineMsgReq(mock.Anything, tt.reqParams.sess, tt.reqParams.seq).
-					Return(tt.allMockParams.offlineMsgReq.wantErr)
-			case tt.allMockParams.deleteMsgReq != nil:
-				icqService.EXPECT().
-					DeleteMsgReq(mock.Anything, tt.reqParams.sess, tt.reqParams.seq).
-					Return(tt.allMockParams.deleteMsgReq.wantErr)
-			}
-
-			rt := NewICQHandler(slog.Default(), icqService)
-
-			buf := &bytes.Buffer{}
-			assert.NoError(t, wire.MarshalBE(tt.reqParams.inBody, buf))
-
-			err := rt.DBQuery(nil, tt.reqParams.sess, wire.SNACFrame{}, buf, nil)
-			assert.ErrorIs(t, err, tt.reqParams.wantErr)
-		})
-	}
-}
-
-// Test workaround for QIP 2005 bug where TLV length is incorrect.
-func TestICQHandler_DBQuery_QIP2005UINSearchBug(t *testing.T) {
-	icqService := newMockICQService(t)
-
-	type ICQMetaRequest struct {
-		wire.ICQMetadata
-		ReqSubType  uint16
-		MetaRequest any
-	}
-
-	expect := wire.ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2{
-		TLVRestBlock: wire.TLVRestBlock{
-			TLVList: wire.TLVList{
-				wire.NewTLVBE(wire.ICQTLVTagsUIN, uint32(100009)),
-			},
-		},
-	}
-
-	icqService.EXPECT().
-		FindByUIN2(mock.Anything, &state.Session{}, expect, uint16(1)).
-		Return(nil)
-
-	rt := NewICQHandler(slog.Default(), icqService)
-
-	inBody := wire.SNAC_0x15_0x02_BQuery{
-		TLVRestBlock: wire.TLVRestBlock{
-			TLVList: wire.TLVList{
-				wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
-					Message: ICQMetaRequest{
-						ICQMetadata: wire.ICQMetadata{
-							ReqType: wire.ICQDBQueryMetaReq,
-							Seq:     1,
-						},
-						ReqSubType: wire.ICQDBQueryMetaReqSearchByUIN2,
-						MetaRequest: wire.ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2{
-							TLVRestBlock: wire.TLVRestBlock{
-								TLVList: wire.TLVList{
-									wire.NewTLVBE(wire.ICQTLVTagsUIN, uint32(100009)),
-								},
-							},
-						},
-					},
-				}),
-			},
-		},
-	}
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(inBody, buf))
-
-	b := buf.Bytes()
-	b[18] = 6 // incorrectly set TLV length to 6 (should be 4)
-
-	err := rt.DBQuery(nil, &state.Session{}, wire.SNACFrame{}, buf, nil)
-	assert.NoError(t, err)
-}

+ 0 - 122
server/oscar/handler/locate.go

@@ -1,122 +0,0 @@
-package handler
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/server/oscar"
-
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-type LocateService interface {
-	DirInfo(ctx context.Context, frame wire.SNACFrame, body wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error)
-	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
-	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
-	SetKeywordInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, body wire.SNAC_0x02_0x0F_LocateSetKeywordInfo) (wire.SNACMessage, error)
-	UserInfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error)
-}
-
-func NewLocateHandler(locateService LocateService, logger *slog.Logger) LocateHandler {
-	return LocateHandler{
-		LocateService: locateService,
-		RouteLogger: middleware.RouteLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type LocateHandler struct {
-	LocateService
-	middleware.RouteLogger
-}
-
-func (h LocateHandler) RightsQuery(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
-	outSNAC := h.LocateService.RightsQuery(ctx, inFrame)
-	h.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h LocateHandler) SetInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, _ oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x02_0x04_LocateSetInfo{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	h.LogRequest(ctx, inFrame, inBody)
-	return h.LocateService.SetInfo(ctx, sess, inBody)
-}
-
-func (h LocateHandler) SetDirInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x02_0x09_LocateSetDirInfo{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := h.LocateService.SetDirInfo(ctx, sess, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h LocateHandler) GetDirInfo(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x02_0x0B_LocateGetDirInfo{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := h.LocateService.DirInfo(ctx, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h LocateHandler) SetKeywordInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x02_0x0F_LocateSetKeywordInfo{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := h.LocateService.SetKeywordInfo(ctx, sess, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h LocateHandler) UserInfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x02_0x05_LocateUserInfoQuery{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := h.LocateService.UserInfoQuery(ctx, sess, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h LocateHandler) UserInfoQuery2(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x02_0x15_LocateUserInfoQuery2{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	// SNAC functionality for LocateUserInfoQuery and LocateUserInfoQuery2 is
-	// identical except for the Type field data type (uint16 vs uint32).
-	wrappedBody := wire.SNAC_0x02_0x05_LocateUserInfoQuery{
-		Type:       uint16(inBody.Type2),
-		ScreenName: inBody.ScreenName,
-	}
-	outSNAC, err := h.LocateService.UserInfoQuery(ctx, sess, inFrame, wrappedBody)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}

+ 0 - 308
server/oscar/handler/locate_test.go

@@ -1,308 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"log/slog"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/wire"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestLocateHandler_GetDirInfo(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Locate,
-			SubGroup:  wire.LocateGetDirInfo,
-		},
-		Body: wire.SNAC_0x02_0x0B_LocateGetDirInfo{
-			ScreenName: "screen-name",
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Locate,
-			SubGroup:  wire.LocateGetDirReply,
-		},
-		Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{
-			Status: 1,
-		},
-	}
-
-	svc := newMockLocateService(t)
-	svc.EXPECT().
-		DirInfo(mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewLocateHandler(svc, slog.Default())
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.GetDirInfo(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestLocateHandler_RightsQuery(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Locate,
-			SubGroup:  wire.LocateRightsQuery,
-		},
-		Body: struct{}{},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Locate,
-			SubGroup:  wire.LocateRightsReply,
-		},
-		Body: wire.SNAC_0x02_0x03_LocateRightsReply{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(0x01, uint16(1000)),
-				},
-			},
-		},
-	}
-
-	svc := newMockLocateService(t)
-	svc.EXPECT().
-		RightsQuery(mock.Anything, input.Frame).
-		Return(output)
-
-	h := NewLocateHandler(svc, slog.Default())
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.RightsQuery(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestLocateHandler_SetDirInfo(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Locate,
-			SubGroup:  wire.LocateSetDirInfo,
-		},
-		Body: wire.SNAC_0x02_0x09_LocateSetDirInfo{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					{
-						Tag:   0x01,
-						Value: []byte{1, 2, 3, 4},
-					},
-				},
-			},
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Locate,
-			SubGroup:  wire.LocateSetDirReply,
-		},
-		Body: wire.SNAC_0x02_0x0A_LocateSetDirReply{
-			Result: 1,
-		},
-	}
-
-	svc := newMockLocateService(t)
-	svc.EXPECT().
-		SetDirInfo(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewLocateHandler(svc, slog.Default())
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.SetDirInfo(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestLocateHandler_SetInfo(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Locate,
-			SubGroup:  wire.LocateSetInfo,
-		},
-		Body: wire.SNAC_0x02_0x04_LocateSetInfo{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					{
-						Tag:   0x01,
-						Value: []byte{1, 2, 3, 4},
-					},
-				},
-			},
-		},
-	}
-
-	svc := newMockLocateService(t)
-	svc.EXPECT().
-		SetInfo(mock.Anything, mock.Anything, input.Body).
-		Return(nil)
-
-	h := NewLocateHandler(svc, slog.Default())
-	responseWriter := newMockResponseWriter(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.SetInfo(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestLocateHandler_SetKeywordInfo(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Locate,
-			SubGroup:  wire.LocateSetKeywordInfo,
-		},
-		Body: wire.SNAC_0x02_0x0F_LocateSetKeywordInfo{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					{
-						Tag:   0x01,
-						Value: []byte{1, 2, 3, 4},
-					},
-				},
-			},
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Locate,
-			SubGroup:  wire.LocateSetKeywordReply,
-		},
-		Body: wire.SNAC_0x02_0x10_LocateSetKeywordReply{
-			Unknown: 1,
-		},
-	}
-
-	svc := newMockLocateService(t)
-	svc.EXPECT().
-		SetKeywordInfo(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewLocateHandler(svc, slog.Default())
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.SetKeywordInfo(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestLocateHandler_UserInfoQuery(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Locate,
-			SubGroup:  wire.LocateUserInfoQuery,
-		},
-		Body: wire.SNAC_0x02_0x05_LocateUserInfoQuery{
-			Type: 1,
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Locate,
-			SubGroup:  wire.LocateUserInfoReply,
-		},
-		Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
-			TLVUserInfo: wire.TLVUserInfo{
-				ScreenName: "screen-name",
-			},
-			LocateInfo: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					{
-						Tag:   0x01,
-						Value: []byte{1, 2, 3, 4},
-					},
-				},
-			},
-		},
-	}
-
-	svc := newMockLocateService(t)
-	svc.EXPECT().
-		UserInfoQuery(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewLocateHandler(svc, slog.Default())
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.UserInfoQuery(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestLocateHandler_UserInfoQuery2(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Locate,
-			SubGroup:  wire.LocateUserInfoQuery2,
-		},
-		Body: wire.SNAC_0x02_0x15_LocateUserInfoQuery2{
-			Type2: 1,
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Locate,
-			SubGroup:  wire.LocateUserInfoReply,
-		},
-		Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
-			TLVUserInfo: wire.TLVUserInfo{
-				ScreenName: "screen-name",
-			},
-			LocateInfo: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					{
-						Tag:   0x01,
-						Value: []byte{1, 2, 3, 4},
-					},
-				},
-			},
-		},
-	}
-
-	svc := newMockLocateService(t)
-	svc.EXPECT().
-		UserInfoQuery(mock.Anything, mock.Anything, input.Frame, wire.SNAC_0x02_0x05_LocateUserInfoQuery{Type: 1}).
-		Return(output, nil)
-
-	h := NewLocateHandler(svc, slog.Default())
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.UserInfoQuery2(nil, nil, input.Frame, buf, responseWriter))
-}

+ 0 - 53
server/oscar/handler/odir.go

@@ -1,53 +0,0 @@
-package handler
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/server/oscar"
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-type ODirService interface {
-	InfoQuery(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)
-	KeywordListQuery(context.Context, wire.SNACFrame) (wire.SNACMessage, error)
-}
-
-func NewODirHandler(logger *slog.Logger, oDirService ODirService) ODirHandler {
-	return ODirHandler{
-		ODirService: oDirService,
-		RouteLogger: middleware.RouteLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type ODirHandler struct {
-	ODirService
-	middleware.RouteLogger
-}
-
-func (h ODirHandler) InfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x0F_0x02_InfoQuery{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := h.ODirService.InfoQuery(ctx, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, outSNAC, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h ODirHandler) KeywordListQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	outSNAC, err := h.ODirService.KeywordListQuery(ctx, inFrame)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}

+ 0 - 96
server/oscar/handler/odir_test.go

@@ -1,96 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"log/slog"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-func TestODirHandler_InfoQuery(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ODir,
-			SubGroup:  wire.ODirInfoQuery,
-		},
-		Body: wire.SNAC_0x0F_0x02_InfoQuery{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(1, uint16(2)),
-				},
-			},
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ODir,
-			SubGroup:  wire.ODirInfoReply,
-		},
-		Body: wire.SNAC_0x0F_0x03_InfoReply{
-			Status: 5, // OK has results/not found
-		},
-	}
-
-	svc := newMockODirService(t)
-	svc.EXPECT().
-		InfoQuery(mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewODirHandler(slog.Default(), svc)
-
-	ss := newMockResponseWriter(t)
-	ss.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.InfoQuery(nil, nil, input.Frame, buf, ss))
-}
-
-func TestODirHandler_KeywordListQuery(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ODir,
-			SubGroup:  wire.ODirKeywordListQuery,
-		},
-		Body: wire.SNAC_0x0F_0x02_InfoQuery{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(1, uint16(2)),
-				},
-			},
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.ODir,
-			SubGroup:  wire.ODirKeywordListReply,
-		},
-		Body: wire.SNAC_0x0F_0x04_KeywordListReply{
-			Status: 0x01,
-		},
-	}
-
-	svc := newMockODirService(t)
-	svc.EXPECT().
-		KeywordListQuery(mock.Anything, input.Frame).
-		Return(output, nil)
-
-	h := NewODirHandler(slog.Default(), svc)
-
-	ss := newMockResponseWriter(t)
-	ss.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.KeywordListQuery(nil, nil, input.Frame, buf, ss))
-}

+ 0 - 133
server/oscar/handler/oservice.go

@@ -1,133 +0,0 @@
-package handler
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/server/oscar"
-
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-type OServiceService interface {
-	ClientOnline(ctx context.Context, bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error
-	ClientVersions(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x17_OServiceClientVersions) wire.SNACMessage
-	HostOnline() wire.SNACMessage
-	IdleNotification(ctx context.Context, sess *state.Session, bodyIn wire.SNAC_0x01_0x11_OServiceIdleNotification) error
-	RateParamsQuery(ctx context.Context, sess *state.Session, frame wire.SNACFrame) wire.SNACMessage
-	RateParamsSubAdd(context.Context, *state.Session, wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd)
-	ServiceRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest) (wire.SNACMessage, error)
-	SetPrivacyFlags(ctx context.Context, bodyIn wire.SNAC_0x01_0x14_OServiceSetPrivacyFlags)
-	SetUserInfoFields(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (wire.SNACMessage, error)
-	UserInfoQuery(ctx context.Context, sess *state.Session, frame wire.SNACFrame) wire.SNACMessage
-}
-
-func NewOServiceHandler(logger *slog.Logger, oServiceService OServiceService) OServiceHandler {
-	return OServiceHandler{
-		OServiceService: oServiceService,
-		RouteLogger: middleware.RouteLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type OServiceHandler struct {
-	OServiceService
-	middleware.RouteLogger
-}
-
-func (h OServiceHandler) RateParamsQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
-	outSNAC := h.OServiceService.RateParamsQuery(ctx, sess, inFrame)
-	h.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h OServiceHandler) RateParamsSubAdd(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	h.OServiceService.RateParamsSubAdd(ctx, sess, inBody)
-	h.LogRequest(ctx, inFrame, inBody)
-	return nil
-}
-
-func (h OServiceHandler) UserInfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
-	outSNAC := h.OServiceService.UserInfoQuery(ctx, sess, inFrame)
-	h.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h OServiceHandler) IdleNotification(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, _ oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x01_0x11_OServiceIdleNotification{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	h.LogRequest(ctx, inFrame, inBody)
-	return h.OServiceService.IdleNotification(ctx, sess, inBody)
-}
-
-func (h OServiceHandler) ClientVersions(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x01_0x17_OServiceClientVersions{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC := h.OServiceService.ClientVersions(ctx, sess, inFrame, inBody)
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h OServiceHandler) SetUserInfoFields(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := h.OServiceService.SetUserInfoFields(ctx, sess, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h OServiceHandler) Noop(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
-	// no-op keep-alive
-	h.LogRequest(ctx, inFrame, nil)
-	return nil
-}
-
-func (h OServiceHandler) SetPrivacyFlags(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, _ oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x01_0x14_OServiceSetPrivacyFlags{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	h.OServiceService.SetPrivacyFlags(ctx, inBody)
-	h.LogRequest(ctx, inFrame, inBody)
-	return nil
-}
-
-func (h OServiceHandler) ServiceRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x01_0x04_OServiceServiceRequest{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := h.OServiceService.ServiceRequest(ctx, sess, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (h OServiceHandler) ClientOnline(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, _ oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x01_0x02_OServiceClientOnline{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	h.Logger.InfoContext(ctx, "user signed on")
-	h.LogRequest(ctx, inFrame, inBody)
-	return h.OServiceService.ClientOnline(ctx, inBody, sess)
-}

+ 0 - 501
server/oscar/handler/oservice_test.go

@@ -1,501 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"context"
-	"log/slog"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestOServiceBOSHandler_ClientOnline(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceClientOnline,
-		},
-		Body: wire.SNAC_0x01_0x02_OServiceClientOnline{
-			GroupVersions: []struct {
-				FoodGroup   uint16
-				Version     uint16
-				ToolID      uint16
-				ToolVersion uint16
-			}{
-				{
-					FoodGroup: 10,
-				},
-			},
-		},
-	}
-
-	svc := newMockOServiceService(t)
-	svc.EXPECT().
-		ClientOnline(mock.Anything, input.Body, mock.Anything).
-		Return(nil)
-
-	h := NewOServiceHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.ClientOnline(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestOServiceBOSHandler_ServiceRequest(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceServiceRequest,
-		},
-		Body: wire.SNAC_0x01_0x04_OServiceServiceRequest{
-			FoodGroup: wire.Chat,
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceServiceResponse,
-		},
-		Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(0x01, uint16(1000)),
-				},
-			},
-		},
-	}
-
-	svc := newMockOServiceService(t)
-	svc.EXPECT().
-		ServiceRequest(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewOServiceHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.ServiceRequest(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestOServiceChatHandler_ClientOnline(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceClientOnline,
-		},
-		Body: wire.SNAC_0x01_0x02_OServiceClientOnline{
-			GroupVersions: []struct {
-				FoodGroup   uint16
-				Version     uint16
-				ToolID      uint16
-				ToolVersion uint16
-			}{
-				{
-					FoodGroup: 10,
-				},
-			},
-		},
-	}
-
-	svc := newMockOServiceService(t)
-	svc.EXPECT().
-		ClientOnline(mock.Anything, mock.Anything, mock.Anything).
-		Return(nil)
-
-	h := NewOServiceHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.ClientOnline(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestOServiceHandler_IdleNotification(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceIdleNotification,
-		},
-		Body: wire.SNAC_0x01_0x11_OServiceIdleNotification{
-			IdleTime: 10,
-		},
-	}
-
-	svc := newMockOServiceService(t)
-	svc.EXPECT().
-		IdleNotification(mock.Anything, mock.Anything, input.Body).
-		Return(nil)
-
-	h := OServiceHandler{
-		OServiceService: svc,
-		RouteLogger: middleware.RouteLogger{
-			Logger: slog.Default(),
-		},
-	}
-
-	responseWriter := newMockResponseWriter(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.IdleNotification(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestOServiceHandler_ClientVersions(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceClientVersions,
-		},
-		Body: wire.SNAC_0x01_0x17_OServiceClientVersions{
-			Versions: []uint16{
-				10,
-			},
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceHostVersions,
-		},
-		Body: wire.SNAC_0x01_0x18_OServiceHostVersions{
-			Versions: []uint16{
-				10,
-			},
-		},
-	}
-
-	sess := state.NewSession()
-	svc := newMockOServiceService(t)
-	svc.EXPECT().
-		ClientVersions(mock.Anything, sess, input.Frame, input.Body).
-		Return(output)
-
-	h := OServiceHandler{
-		OServiceService: svc,
-		RouteLogger: middleware.RouteLogger{
-			Logger: slog.Default(),
-		},
-	}
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.ClientVersions(context.Background(), sess, input.Frame, buf, responseWriter))
-}
-
-func TestOServiceHandler_RateParamsQuery(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceRateParamsQuery,
-		},
-		Body: struct{}{},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceRateParamsReply,
-		},
-		Body: wire.SNAC_0x01_0x07_OServiceRateParamsReply{
-			RateGroups: []struct {
-				ID    uint16
-				Pairs []struct {
-					FoodGroup uint16
-					SubGroup  uint16
-				} `oscar:"count_prefix=uint16"`
-			}{
-				{
-					ID: 1,
-				},
-			},
-		},
-	}
-
-	svc := newMockOServiceService(t)
-	svc.EXPECT().
-		RateParamsQuery(mock.Anything, mock.Anything, input.Frame).
-		Return(output)
-
-	h := OServiceHandler{
-		OServiceService: svc,
-		RouteLogger: middleware.RouteLogger{
-			Logger: slog.Default(),
-		},
-	}
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.RateParamsQuery(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestOServiceHandler_RateParamsSubAdd(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceRateParamsSubAdd,
-		},
-		Body: wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd{
-			ClassIDs: []uint16{1, 2, 3, 4},
-		},
-	}
-
-	sess := state.NewSession()
-
-	svc := newMockOServiceService(t)
-	svc.EXPECT().
-		RateParamsSubAdd(mock.Anything, sess, input.Body)
-
-	h := OServiceHandler{
-		OServiceService: svc,
-		RouteLogger: middleware.RouteLogger{
-			Logger: slog.Default(),
-		},
-	}
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.RateParamsSubAdd(context.Background(), sess, input.Frame, buf, nil))
-}
-
-func TestOServiceHandler_SetUserInfoFields(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceSetUserInfoFields,
-		},
-		Body: wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(0x01, []byte{1, 2, 3, 4}),
-				},
-			},
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceUserInfoUpdate,
-		},
-		Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
-			UserInfo: []wire.TLVUserInfo{
-				{ScreenName: "screen-name"},
-				{ScreenName: "screen-name"},
-			},
-		},
-	}
-
-	svc := newMockOServiceService(t)
-	svc.EXPECT().
-		SetUserInfoFields(mock.Anything, mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := OServiceHandler{
-		OServiceService: svc,
-		RouteLogger: middleware.RouteLogger{
-			Logger: slog.Default(),
-		},
-	}
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.SetUserInfoFields(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestOServiceHandler_UserInfoQuery(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceUserInfoQuery,
-		},
-		Body: struct{}{},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceUserInfoUpdate,
-		},
-		Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
-			UserInfo: []wire.TLVUserInfo{
-				{ScreenName: "screen-name"},
-				{ScreenName: "screen-name"},
-			},
-		},
-	}
-
-	svc := newMockOServiceService(t)
-	svc.EXPECT().
-		UserInfoQuery(mock.Anything, mock.Anything, input.Frame).
-		Return(output)
-
-	h := OServiceHandler{
-		OServiceService: svc,
-		RouteLogger: middleware.RouteLogger{
-			Logger: slog.Default(),
-		},
-	}
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.UserInfoQuery(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestOServiceHandler_Noop(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceNoop,
-		},
-		Body: struct{}{},
-	}
-
-	h := OServiceHandler{
-		RouteLogger: middleware.RouteLogger{
-			Logger: slog.Default(),
-		},
-	}
-
-	responseWriter := newMockResponseWriter(t)
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.Noop(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestOServiceHandler_SetPrivacyFlags(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceSetPrivacyFlags,
-		},
-		Body: wire.SNAC_0x01_0x14_OServiceSetPrivacyFlags{
-			PrivacyFlags: wire.OServicePrivacyFlagMember,
-		},
-	}
-
-	svc := newMockOServiceService(t)
-	svc.EXPECT().
-		SetPrivacyFlags(mock.Anything, input.Body)
-
-	h := OServiceHandler{
-		OServiceService: svc,
-		RouteLogger: middleware.RouteLogger{
-			Logger: slog.Default(),
-		},
-	}
-
-	responseWriter := newMockResponseWriter(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.SetPrivacyFlags(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestOServiceChatNavHandler_ClientOnline(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceClientOnline,
-		},
-		Body: wire.SNAC_0x01_0x02_OServiceClientOnline{
-			GroupVersions: []struct {
-				FoodGroup   uint16
-				Version     uint16
-				ToolID      uint16
-				ToolVersion uint16
-			}{
-				{
-					FoodGroup: 10,
-				},
-			},
-		},
-	}
-
-	svc := newMockOServiceService(t)
-	svc.EXPECT().
-		ClientOnline(mock.Anything, input.Body, mock.Anything).
-		Return(nil)
-	h := NewOServiceHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.ClientOnline(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestOServiceAlertHandler_ClientOnline(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.OService,
-			SubGroup:  wire.OServiceClientOnline,
-		},
-		Body: wire.SNAC_0x01_0x02_OServiceClientOnline{
-			GroupVersions: []struct {
-				FoodGroup   uint16
-				Version     uint16
-				ToolID      uint16
-				ToolVersion uint16
-			}{
-				{
-					FoodGroup: 10,
-				},
-			},
-		},
-	}
-
-	svc := newMockOServiceService(t)
-	svc.EXPECT().
-		ClientOnline(mock.Anything, input.Body, mock.Anything).
-		Return(nil)
-	h := NewOServiceHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.ClientOnline(nil, nil, input.Frame, buf, responseWriter))
-}

+ 0 - 136
server/oscar/handler/permit_deny.go

@@ -1,136 +0,0 @@
-package handler
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/server/oscar"
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-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
-}
-
-func NewPermitDenyHandler(logger *slog.Logger, permitDenyService PermitDenyService) PermitDenyHandler {
-	return PermitDenyHandler{
-		RouteLogger: middleware.RouteLogger{
-			Logger: logger,
-		},
-		PermitDenyService: permitDenyService,
-	}
-}
-
-type PermitDenyHandler struct {
-	PermitDenyService
-	middleware.RouteLogger
-}
-
-func (rt PermitDenyHandler) RightsQuery(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
-	outSNAC := rt.PermitDenyService.RightsQuery(ctx, inFrame)
-	rt.LogRequestAndResponse(ctx, inFrame, nil, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}
-
-func (rt PermitDenyHandler) AddDenyListEntries(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	rt.LogRequest(ctx, inFrame, inBody)
-	return rt.PermitDenyService.AddDenyListEntries(ctx, sess, inBody)
-}
-
-func (rt PermitDenyHandler) DelDenyListEntries(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x09_0x08_PermitDenyDelDenyListEntries{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	rt.LogRequest(ctx, inFrame, inBody)
-	return rt.PermitDenyService.DelDenyListEntries(ctx, sess, inBody)
-}
-
-func (rt PermitDenyHandler) AddPermListEntries(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	rt.LogRequest(ctx, inFrame, inBody)
-	return rt.PermitDenyService.AddPermListEntries(ctx, sess, inBody)
-}
-
-func (rt PermitDenyHandler) DelPermListEntries(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x09_0x06_PermitDenyDelPermListEntries{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	rt.LogRequest(ctx, inFrame, inBody)
-	return rt.PermitDenyService.DelPermListEntries(ctx, sess, inBody)
-}
-
-// SetGroupPermitMask sets the classes of users I can interact with. We don't
-// apply any of these settings to the privacy mechanism, so just log them for
-// now.
-func (rt PermitDenyHandler) SetGroupPermitMask(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x09_0x04_PermitDenySetGroupPermitMask{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-
-	var flags []string
-
-	if inBody.IsFlagSet(wire.OServiceUserFlagUnconfirmed) {
-		flags = append(flags, "wire.OServiceUserFlagUnconfirmed")
-	}
-	if inBody.IsFlagSet(wire.OServiceUserFlagAdministrator) {
-		flags = append(flags, "wire.OServiceUserFlagAdministrator")
-	}
-	if inBody.IsFlagSet(wire.OServiceUserFlagAOL) {
-		flags = append(flags, "wire.OServiceUserFlagAOL")
-	}
-	if inBody.IsFlagSet(wire.OServiceUserFlagOSCARPay) {
-		flags = append(flags, "wire.OServiceUserFlagOSCARPay")
-	}
-	if inBody.IsFlagSet(wire.OServiceUserFlagOSCARFree) {
-		flags = append(flags, "wire.OServiceUserFlagOSCARFree")
-	}
-	if inBody.IsFlagSet(wire.OServiceUserFlagUnavailable) {
-		flags = append(flags, "wire.OServiceUserFlagUnavailable")
-	}
-	if inBody.IsFlagSet(wire.OServiceUserFlagICQ) {
-		flags = append(flags, "wire.OServiceUserFlagICQ")
-	}
-	if inBody.IsFlagSet(wire.OServiceUserFlagWireless) {
-		flags = append(flags, "wire.OServiceUserFlagWireless")
-	}
-	if inBody.IsFlagSet(wire.OServiceUserFlagInternal) {
-		flags = append(flags, "wire.OServiceUserFlagInternal")
-	}
-	if inBody.IsFlagSet(wire.OServiceUserFlagFish) {
-		flags = append(flags, "wire.OServiceUserFlagFish")
-	}
-	if inBody.IsFlagSet(wire.OServiceUserFlagBot) {
-		flags = append(flags, "wire.OServiceUserFlagBot")
-	}
-	if inBody.IsFlagSet(wire.OServiceUserFlagBeast) {
-		flags = append(flags, "wire.OServiceUserFlagBeast")
-	}
-	if inBody.IsFlagSet(wire.OServiceUserFlagOneWayWireless) {
-		flags = append(flags, "wire.OServiceUserFlagOneWayWireless")
-	}
-	if inBody.IsFlagSet(wire.OServiceUserFlagOfficial) {
-		flags = append(flags, "wire.OServiceUserFlagOfficial")
-	}
-
-	rt.Logger.Info("set pd group mask", "flags", flags)
-	rt.LogRequest(ctx, inFrame, inBody)
-
-	return nil
-}

+ 0 - 201
server/oscar/handler/permit_deny_test.go

@@ -1,201 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"log/slog"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-func TestPermitDenyHandler_RightsQuery(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.PermitDeny,
-			SubGroup:  wire.PermitDenyRightsQuery,
-		},
-		Body: struct{}{},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.PermitDeny,
-			SubGroup:  wire.PermitDenyRightsReply,
-		},
-		Body: wire.SNAC_0x09_0x03_PermitDenyRightsReply{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(0x01, uint16(1000)),
-				},
-			},
-		},
-	}
-
-	svc := newMockPermitDenyService(t)
-	svc.EXPECT().
-		RightsQuery(mock.Anything, input.Frame).
-		Return(output)
-
-	h := NewPermitDenyHandler(slog.Default(), svc)
-
-	responseWriter := newMockResponseWriter(t)
-	responseWriter.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.RightsQuery(nil, nil, input.Frame, buf, responseWriter))
-}
-
-func TestPermitDenyHandler_AddDenyListEntries(t *testing.T) {
-	sess := state.NewSession()
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.PermitDeny,
-			SubGroup:  wire.PermitDenyAddDenyListEntries,
-		},
-		Body: wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries{
-			Users: []struct {
-				ScreenName string `oscar:"len_prefix=uint8"`
-			}{
-				{
-					ScreenName: "friend1",
-				},
-				{
-					ScreenName: "friend2",
-				},
-			},
-		},
-	}
-	svc := newMockPermitDenyService(t)
-	svc.EXPECT().
-		AddDenyListEntries(mock.Anything, sess, input.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	h := NewPermitDenyHandler(slog.Default(), svc)
-	assert.NoError(t, h.AddDenyListEntries(nil, sess, input.Frame, buf, nil))
-}
-
-func TestPermitDenyHandler_DelDenyListEntries(t *testing.T) {
-	sess := state.NewSession()
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.PermitDeny,
-			SubGroup:  wire.PermitDenyDelDenyListEntries,
-		},
-		Body: wire.SNAC_0x09_0x08_PermitDenyDelDenyListEntries{
-			Users: []struct {
-				ScreenName string `oscar:"len_prefix=uint8"`
-			}{
-				{
-					ScreenName: "friend1",
-				},
-				{
-					ScreenName: "friend2",
-				},
-			},
-		},
-	}
-	svc := newMockPermitDenyService(t)
-	svc.EXPECT().
-		DelDenyListEntries(mock.Anything, sess, input.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	h := NewPermitDenyHandler(slog.Default(), svc)
-	assert.NoError(t, h.DelDenyListEntries(nil, sess, input.Frame, buf, nil))
-}
-
-func TestPermitDenyHandler_AddPermListEntries(t *testing.T) {
-	sess := state.NewSession()
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.PermitDeny,
-			SubGroup:  wire.PermitDenyAddPermListEntries,
-		},
-		Body: wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries{
-			Users: []struct {
-				ScreenName string `oscar:"len_prefix=uint8"`
-			}{
-				{
-					ScreenName: "friend1",
-				},
-				{
-					ScreenName: "friend2",
-				},
-			},
-		},
-	}
-	svc := newMockPermitDenyService(t)
-	svc.EXPECT().
-		AddPermListEntries(mock.Anything, sess, input.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	h := NewPermitDenyHandler(slog.Default(), svc)
-	assert.NoError(t, h.AddPermListEntries(nil, sess, input.Frame, buf, nil))
-}
-
-func TestPermitDenyHandler_DelPermListEntries(t *testing.T) {
-	sess := state.NewSession()
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.PermitDeny,
-			SubGroup:  wire.PermitDenyDelPermListEntries,
-		},
-		Body: wire.SNAC_0x09_0x06_PermitDenyDelPermListEntries{
-			Users: []struct {
-				ScreenName string `oscar:"len_prefix=uint8"`
-			}{
-				{
-					ScreenName: "friend1",
-				},
-				{
-					ScreenName: "friend2",
-				},
-			},
-		},
-	}
-	svc := newMockPermitDenyService(t)
-	svc.EXPECT().
-		DelPermListEntries(mock.Anything, sess, input.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	h := NewPermitDenyHandler(slog.Default(), svc)
-	assert.NoError(t, h.DelPermListEntries(nil, sess, input.Frame, buf, nil))
-}
-
-func TestPermitDenyHandler_SetGroupPermitMask(t *testing.T) {
-	sess := state.NewSession()
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.PermitDeny,
-			SubGroup:  wire.PermitDenyDelPermListEntries,
-		},
-		Body: wire.SNAC_0x09_0x04_PermitDenySetGroupPermitMask{
-			PermMask: 1234,
-		},
-	}
-	svc := newMockPermitDenyService(t)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	h := NewPermitDenyHandler(slog.Default(), svc)
-	assert.NoError(t, h.SetGroupPermitMask(nil, sess, input.Frame, buf, nil))
-}

+ 0 - 205
server/oscar/handler/routes.go

@@ -1,205 +0,0 @@
-package handler
-
-import (
-	"github.com/mk6i/retro-aim-server/server/oscar"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-// Handlers aggregates various specialized handlers required for processing
-// different types of OSCAR protocol requests. Each field corresponds to a
-// specific handler responsible for a distinct aspect of the OSCAR service,
-// such as managing buddy lists, chat sessions, and user alerts.
-type Handlers struct {
-	AdminHandler
-	AlertHandler
-	BARTHandler
-	BuddyHandler
-	ChatHandler
-	ChatNavHandler
-	FeedbagHandler
-	ICBMHandler
-	ICQHandler
-	LocateHandler
-	ODirHandler
-	OServiceHandler
-	PermitDenyHandler
-	StatsHandler
-	UserLookupHandler
-}
-
-// NewBOSRouter initializes and configures a new Router instance for handling
-// OSCAR protocol requests in the context of a Basic Oscar Service (BOS).
-func NewBOSRouter(h Handlers) oscar.Router {
-	router := oscar.NewRouter()
-
-	router.Register(wire.Alert, wire.AlertNotifyCapabilities, h.AlertHandler.NotifyCapabilities)
-	router.Register(wire.Alert, wire.AlertNotifyDisplayCapabilities, h.AlertHandler.NotifyDisplayCapabilities)
-
-	router.Register(wire.BART, wire.BARTDownloadQuery, h.BARTHandler.DownloadQuery)
-	router.Register(wire.BART, wire.BARTUploadQuery, h.BARTHandler.UploadQuery)
-
-	router.Register(wire.Buddy, wire.BuddyAddBuddies, h.BuddyHandler.AddBuddies)
-	router.Register(wire.Buddy, wire.BuddyDelBuddies, h.BuddyHandler.DelBuddies)
-	router.Register(wire.Buddy, wire.BuddyRightsQuery, h.BuddyHandler.RightsQuery)
-
-	router.Register(wire.ChatNav, wire.ChatNavCreateRoom, h.ChatNavHandler.CreateRoom)
-	router.Register(wire.ChatNav, wire.ChatNavRequestChatRights, h.ChatNavHandler.RequestChatRights)
-	router.Register(wire.ChatNav, wire.ChatNavRequestExchangeInfo, h.ChatNavHandler.RequestExchangeInfo)
-	router.Register(wire.ChatNav, wire.ChatNavRequestRoomInfo, h.ChatNavHandler.RequestRoomInfo)
-
-	router.Register(wire.Feedbag, wire.FeedbagDeleteItem, h.FeedbagHandler.DeleteItem)
-	router.Register(wire.Feedbag, wire.FeedbagEndCluster, h.FeedbagHandler.EndCluster)
-	router.Register(wire.Feedbag, wire.FeedbagInsertItem, h.FeedbagHandler.InsertItem)
-	router.Register(wire.Feedbag, wire.FeedbagQuery, h.FeedbagHandler.Query)
-	router.Register(wire.Feedbag, wire.FeedbagQueryIfModified, h.FeedbagHandler.QueryIfModified)
-	router.Register(wire.Feedbag, wire.FeedbagRespondAuthorizeToHost, h.FeedbagHandler.RespondAuthorizeToHost)
-	router.Register(wire.Feedbag, wire.FeedbagRightsQuery, h.FeedbagHandler.RightsQuery)
-	router.Register(wire.Feedbag, wire.FeedbagStartCluster, h.FeedbagHandler.StartCluster)
-	router.Register(wire.Feedbag, wire.FeedbagUpdateItem, h.FeedbagHandler.UpdateItem)
-	router.Register(wire.Feedbag, wire.FeedbagUse, h.FeedbagHandler.Use)
-
-	router.Register(wire.ICQ, wire.ICQDBQuery, h.ICQHandler.DBQuery)
-
-	router.Register(wire.ICBM, wire.ICBMAddParameters, h.ICBMHandler.AddParameters)
-	router.Register(wire.ICBM, wire.ICBMChannelMsgToHost, h.ICBMHandler.ChannelMsgToHost)
-	router.Register(wire.ICBM, wire.ICBMClientErr, h.ICBMHandler.ClientErr)
-	router.Register(wire.ICBM, wire.ICBMClientEvent, h.ICBMHandler.ClientEvent)
-	router.Register(wire.ICBM, wire.ICBMEvilRequest, h.ICBMHandler.EvilRequest)
-	router.Register(wire.ICBM, wire.ICBMParameterQuery, h.ICBMHandler.ParameterQuery)
-
-	router.Register(wire.Locate, wire.LocateGetDirInfo, h.LocateHandler.GetDirInfo)
-	router.Register(wire.Locate, wire.LocateRightsQuery, h.LocateHandler.RightsQuery)
-	router.Register(wire.Locate, wire.LocateSetDirInfo, h.LocateHandler.SetDirInfo)
-	router.Register(wire.Locate, wire.LocateSetInfo, h.LocateHandler.SetInfo)
-	router.Register(wire.Locate, wire.LocateSetKeywordInfo, h.LocateHandler.SetKeywordInfo)
-	router.Register(wire.Locate, wire.LocateUserInfoQuery, h.LocateHandler.UserInfoQuery)
-	router.Register(wire.Locate, wire.LocateUserInfoQuery2, h.LocateHandler.UserInfoQuery2)
-
-	router.Register(wire.OService, wire.OServiceClientOnline, h.OServiceHandler.ClientOnline)
-	router.Register(wire.OService, wire.OServiceClientVersions, h.OServiceHandler.ClientVersions)
-	router.Register(wire.OService, wire.OServiceIdleNotification, h.OServiceHandler.IdleNotification)
-	router.Register(wire.OService, wire.OServiceNoop, h.OServiceHandler.Noop)
-	router.Register(wire.OService, wire.OServiceRateParamsQuery, h.OServiceHandler.RateParamsQuery)
-	router.Register(wire.OService, wire.OServiceRateParamsSubAdd, h.OServiceHandler.RateParamsSubAdd)
-	router.Register(wire.OService, wire.OServiceServiceRequest, h.OServiceHandler.ServiceRequest)
-	router.Register(wire.OService, wire.OServiceSetPrivacyFlags, h.OServiceHandler.SetPrivacyFlags)
-	router.Register(wire.OService, wire.OServiceSetUserInfoFields, h.OServiceHandler.SetUserInfoFields)
-	router.Register(wire.OService, wire.OServiceUserInfoQuery, h.OServiceHandler.UserInfoQuery)
-
-	router.Register(wire.PermitDeny, wire.PermitDenyAddDenyListEntries, h.PermitDenyHandler.AddDenyListEntries)
-	router.Register(wire.PermitDeny, wire.PermitDenyAddPermListEntries, h.PermitDenyHandler.AddPermListEntries)
-	router.Register(wire.PermitDeny, wire.PermitDenyDelDenyListEntries, h.PermitDenyHandler.DelDenyListEntries)
-	router.Register(wire.PermitDeny, wire.PermitDenyDelPermListEntries, h.PermitDenyHandler.DelPermListEntries)
-	router.Register(wire.PermitDeny, wire.PermitDenyRightsQuery, h.PermitDenyHandler.RightsQuery)
-	router.Register(wire.PermitDeny, wire.PermitDenySetGroupPermitMask, h.PermitDenyHandler.SetGroupPermitMask)
-
-	router.Register(wire.Stats, wire.StatsReportEvents, h.StatsHandler.ReportEvents)
-
-	router.Register(wire.UserLookup, wire.UserLookupFindByEmail, h.UserLookupHandler.FindByEmail)
-
-	return router
-}
-
-// NewChatRouter initializes and configures a new Router instance specifically
-// for handling chat-related OSCAR protocol requests.
-func NewChatRouter(h Handlers) oscar.Router {
-	router := oscar.NewRouter()
-
-	router.Register(wire.Chat, wire.ChatChannelMsgToHost, h.ChatHandler.ChannelMsgToHost)
-
-	router.Register(wire.OService, wire.OServiceClientOnline, h.ClientOnline)
-	router.Register(wire.OService, wire.OServiceClientVersions, h.OServiceHandler.ClientVersions)
-	router.Register(wire.OService, wire.OServiceIdleNotification, h.OServiceHandler.IdleNotification)
-	router.Register(wire.OService, wire.OServiceNoop, h.OServiceHandler.Noop)
-	router.Register(wire.OService, wire.OServiceRateParamsQuery, h.OServiceHandler.RateParamsQuery)
-	router.Register(wire.OService, wire.OServiceRateParamsSubAdd, h.OServiceHandler.RateParamsSubAdd)
-	router.Register(wire.OService, wire.OServiceSetPrivacyFlags, h.OServiceHandler.SetPrivacyFlags)
-	router.Register(wire.OService, wire.OServiceSetUserInfoFields, h.OServiceHandler.SetUserInfoFields)
-	router.Register(wire.OService, wire.OServiceUserInfoQuery, h.OServiceHandler.UserInfoQuery)
-
-	return router
-}
-
-// NewChatNavRouter initializes and configures a new Router instance for
-// handling OSCAR protocol requests in the context of the ChatNav service.
-func NewChatNavRouter(h Handlers) oscar.Router {
-	router := oscar.NewRouter()
-
-	router.Register(wire.ChatNav, wire.ChatNavCreateRoom, h.ChatNavHandler.CreateRoom)
-	router.Register(wire.ChatNav, wire.ChatNavRequestChatRights, h.ChatNavHandler.RequestChatRights)
-	router.Register(wire.ChatNav, wire.ChatNavRequestExchangeInfo, h.ChatNavHandler.RequestExchangeInfo)
-	router.Register(wire.ChatNav, wire.ChatNavRequestRoomInfo, h.ChatNavHandler.RequestRoomInfo)
-
-	router.Register(wire.OService, wire.OServiceClientOnline, h.ClientOnline)
-	router.Register(wire.OService, wire.OServiceClientVersions, h.OServiceHandler.ClientVersions)
-	router.Register(wire.OService, wire.OServiceIdleNotification, h.OServiceHandler.IdleNotification)
-	router.Register(wire.OService, wire.OServiceRateParamsQuery, h.OServiceHandler.RateParamsQuery)
-	router.Register(wire.OService, wire.OServiceRateParamsSubAdd, h.OServiceHandler.RateParamsSubAdd)
-	router.Register(wire.OService, wire.OServiceSetPrivacyFlags, h.OServiceHandler.SetPrivacyFlags)
-	router.Register(wire.OService, wire.OServiceSetUserInfoFields, h.OServiceHandler.SetUserInfoFields)
-	router.Register(wire.OService, wire.OServiceUserInfoQuery, h.OServiceHandler.UserInfoQuery)
-
-	return router
-}
-
-func NewAlertRouter(h Handlers) oscar.Router {
-	router := oscar.NewRouter()
-
-	router.Register(wire.Alert, wire.AlertNotifyCapabilities, h.AlertHandler.NotifyCapabilities)
-	router.Register(wire.Alert, wire.AlertNotifyDisplayCapabilities, h.AlertHandler.NotifyDisplayCapabilities)
-	router.Register(wire.Alert, wire.AlertUserOnline, h.AlertHandler.NotifyDisplayCapabilities)
-
-	router.Register(wire.OService, wire.OServiceClientOnline, h.ClientOnline)
-	router.Register(wire.OService, wire.OServiceClientVersions, h.OServiceHandler.ClientVersions)
-	router.Register(wire.OService, wire.OServiceRateParamsQuery, h.OServiceHandler.RateParamsQuery)
-	router.Register(wire.OService, wire.OServiceRateParamsSubAdd, h.OServiceHandler.RateParamsSubAdd)
-
-	return router
-}
-
-func NewBARTRouter(h Handlers) oscar.Router {
-	router := oscar.NewRouter()
-
-	router.Register(wire.BART, wire.BARTDownloadQuery, h.BARTHandler.DownloadQuery)
-	router.Register(wire.BART, wire.BARTUploadQuery, h.BARTHandler.UploadQuery)
-
-	router.Register(wire.OService, wire.OServiceClientOnline, h.ClientOnline)
-	router.Register(wire.OService, wire.OServiceClientVersions, h.OServiceHandler.ClientVersions)
-	router.Register(wire.OService, wire.OServiceRateParamsQuery, h.OServiceHandler.RateParamsQuery)
-	router.Register(wire.OService, wire.OServiceRateParamsSubAdd, h.OServiceHandler.RateParamsSubAdd)
-
-	return router
-}
-
-func NewAdminRouter(h Handlers) oscar.Router {
-	router := oscar.NewRouter()
-
-	router.Register(wire.OService, wire.OServiceClientOnline, h.OServiceHandler.ClientOnline)
-	router.Register(wire.OService, wire.OServiceClientVersions, h.OServiceHandler.ClientVersions)
-	router.Register(wire.OService, wire.OServiceRateParamsQuery, h.OServiceHandler.RateParamsQuery)
-	router.Register(wire.OService, wire.OServiceRateParamsSubAdd, h.OServiceHandler.RateParamsSubAdd)
-	router.Register(wire.OService, wire.OServiceSetPrivacyFlags, h.OServiceHandler.SetPrivacyFlags)
-
-	router.Register(wire.Admin, wire.AdminAcctConfirmRequest, h.AdminHandler.ConfirmRequest)
-	router.Register(wire.Admin, wire.AdminInfoChangeRequest, h.AdminHandler.InfoChangeRequest)
-	router.Register(wire.Admin, wire.AdminInfoQuery, h.AdminHandler.InfoQuery)
-
-	return router
-}
-
-func NewODirRouter(h Handlers) oscar.Router {
-	router := oscar.NewRouter()
-
-	router.Register(wire.ODir, wire.ODirInfoQuery, h.ODirHandler.InfoQuery)
-	router.Register(wire.ODir, wire.ODirKeywordListQuery, h.ODirHandler.KeywordListQuery)
-
-	router.Register(wire.OService, wire.OServiceClientOnline, h.ClientOnline)
-	router.Register(wire.OService, wire.OServiceClientVersions, h.OServiceHandler.ClientVersions)
-	router.Register(wire.OService, wire.OServiceIdleNotification, h.OServiceHandler.IdleNotification)
-	router.Register(wire.OService, wire.OServiceNoop, h.OServiceHandler.Noop)
-	router.Register(wire.OService, wire.OServiceRateParamsQuery, h.OServiceHandler.RateParamsQuery)
-	router.Register(wire.OService, wire.OServiceRateParamsSubAdd, h.OServiceHandler.RateParamsSubAdd)
-	router.Register(wire.OService, wire.OServiceSetPrivacyFlags, h.OServiceHandler.SetPrivacyFlags)
-
-	return router
-}

+ 0 - 42
server/oscar/handler/stats.go

@@ -1,42 +0,0 @@
-package handler
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/server/oscar"
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-type StatsService interface {
-	ReportEvents(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0B_0x03_StatsReportEvents) wire.SNACMessage
-}
-
-func NewStatsHandler(logger *slog.Logger, statsService StatsService) StatsHandler {
-	return StatsHandler{
-		StatsService: statsService,
-		RouteLogger: middleware.RouteLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type StatsHandler struct {
-	StatsService
-	middleware.RouteLogger
-}
-
-func (h StatsHandler) ReportEvents(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x0B_0x03_StatsReportEvents{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-
-	outSNAC := h.StatsService.ReportEvents(ctx, inFrame, inBody)
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}

+ 0 - 46
server/oscar/handler/stats_test.go

@@ -1,46 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"log/slog"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-func TestStatsHandler_ReportEvents(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Stats,
-			SubGroup:  wire.StatsReportEvents,
-		},
-		Body: wire.SNAC_0x0B_0x03_StatsReportEvents{},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Stats,
-			SubGroup:  wire.StatsReportAck,
-		},
-		Body: wire.SNAC_0x0B_0x04_StatsReportAck{},
-	}
-
-	svc := newMockStatsService(t)
-	svc.EXPECT().
-		ReportEvents(mock.Anything, input.Frame, input.Body).
-		Return(output)
-
-	h := NewStatsHandler(slog.Default(), svc)
-
-	ss := newMockResponseWriter(t)
-	ss.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.ReportEvents(nil, nil, input.Frame, buf, ss))
-}

+ 0 - 43
server/oscar/handler/user_lookup.go

@@ -1,43 +0,0 @@
-package handler
-
-import (
-	"context"
-	"io"
-	"log/slog"
-
-	"github.com/mk6i/retro-aim-server/server/oscar"
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-type UserLookupService interface {
-	FindByEmail(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0A_0x02_UserLookupFindByEmail) (wire.SNACMessage, error)
-}
-
-func NewUserLookupHandler(logger *slog.Logger, userLookupService UserLookupService) UserLookupHandler {
-	return UserLookupHandler{
-		UserLookupService: userLookupService,
-		RouteLogger: middleware.RouteLogger{
-			Logger: logger,
-		},
-	}
-}
-
-type UserLookupHandler struct {
-	UserLookupService
-	middleware.RouteLogger
-}
-
-func (h UserLookupHandler) FindByEmail(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw oscar.ResponseWriter) error {
-	inBody := wire.SNAC_0x0A_0x02_UserLookupFindByEmail{}
-	if err := wire.UnmarshalBE(&inBody, r); err != nil {
-		return err
-	}
-	outSNAC, err := h.UserLookupService.FindByEmail(ctx, inFrame, inBody)
-	if err != nil {
-		return err
-	}
-	h.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body)
-	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
-}

+ 0 - 54
server/oscar/handler/user_lookup_test.go

@@ -1,54 +0,0 @@
-package handler
-
-import (
-	"bytes"
-	"log/slog"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/wire"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-)
-
-func TestUserLookupHandler_FindByEmail(t *testing.T) {
-	input := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.UserLookup,
-			SubGroup:  wire.UserLookupFindByEmail,
-		},
-		Body: wire.SNAC_0x0A_0x02_UserLookupFindByEmail{
-			Email: []byte("haha@aol.com"),
-		},
-	}
-	output := wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.UserLookup,
-			SubGroup:  wire.UserLookupFindReply,
-		},
-		Body: wire.SNAC_0x0A_0x03_UserLookupFindReply{
-			TLVRestBlock: wire.TLVRestBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(0x01, uint16(0x02)),
-				},
-			},
-		},
-	}
-
-	svc := newMockUserLookupService(t)
-	svc.EXPECT().
-		FindByEmail(mock.Anything, input.Frame, input.Body).
-		Return(output, nil)
-
-	h := NewUserLookupHandler(slog.Default(), svc)
-
-	ss := newMockResponseWriter(t)
-	ss.EXPECT().
-		SendSNAC(output.Frame, output.Body).
-		Return(nil)
-
-	buf := &bytes.Buffer{}
-	assert.NoError(t, wire.MarshalBE(input.Body, buf))
-
-	assert.NoError(t, h.FindByEmail(nil, nil, input.Frame, buf, ss))
-}

+ 5330 - 0
server/oscar/handler_test.go

@@ -0,0 +1,5330 @@
+package oscar
+
+import (
+	"bytes"
+	"context"
+	"log/slog"
+	"math"
+	"testing"
+
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+func TestHandler_AdminConfirmRequest(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x07_0x06_AdminConfirmRequest
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name:      "success",
+			inputBody: wire.SNAC_0x07_0x06_AdminConfirmRequest{},
+		},
+		{
+			name:          "service error",
+			inputBody:     wire.SNAC_0x07_0x06_AdminConfirmRequest{},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name:          "response writer error",
+			inputBody:     wire.SNAC_0x07_0x06_AdminConfirmRequest{},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminAcctConfirmRequest,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminAcctConfirmReply,
+				},
+				Body: wire.SNAC_0x07_0x07_AdminConfirmReply{
+					Status: wire.AdminAcctConfirmStatusEmailSent,
+				},
+			}
+
+			svc := newMockAdminService(t)
+			svc.EXPECT().
+				ConfirmRequest(mock.Anything, mock.Anything, input.Frame).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				AdminService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_AdminInfoQuery_RegistrationStatus(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x07_0x02_AdminInfoQuery
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x07_0x02_AdminInfoQuery{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.AdminTLVRegistrationStatus, uint16(0x00)),
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x07_0x02_AdminInfoQuery{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.AdminTLVRegistrationStatus, uint16(0x00)),
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x07_0x02_AdminInfoQuery{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.AdminTLVRegistrationStatus, uint16(0x00)),
+					},
+				},
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoQuery,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoReply,
+				},
+				Body: wire.SNAC_0x07_0x03_AdminInfoReply{
+					Permissions: wire.AdminInfoPermissionsReadWrite,
+					TLVBlock: wire.TLVBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.AdminTLVRegistrationStatus, wire.AdminInfoRegStatusFullDisclosure),
+						},
+					},
+				},
+			}
+
+			svc := newMockAdminService(t)
+			svc.EXPECT().
+				InfoQuery(mock.Anything, mock.Anything, input.Frame, input.Body).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				AdminService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_AdminInfoChangeRequest_ScreenNameFormatted(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x07_0x04_AdminInfoChangeRequest
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x07_0x04_AdminInfoChangeRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.AdminTLVScreenNameFormatted, "Chatting Chuck"),
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x07_0x04_AdminInfoChangeRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.AdminTLVScreenNameFormatted, "Chatting Chuck"),
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x07_0x04_AdminInfoChangeRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.AdminTLVScreenNameFormatted, "Chatting Chuck"),
+					},
+				},
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoChangeRequest,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Admin,
+					SubGroup:  wire.AdminInfoChangeReply,
+				},
+				Body: wire.SNAC_0x07_0x05_AdminChangeReply{
+					Permissions: wire.AdminInfoPermissionsReadWrite,
+					TLVBlock: wire.TLVBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.AdminTLVScreenNameFormatted, "Chatting Chuck"),
+						},
+					},
+				},
+			}
+
+			svc := newMockAdminService(t)
+			svc.EXPECT().
+				InfoChangeRequest(mock.Anything, mock.Anything, input.Frame, input.Body).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				AdminService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_AlertNotifyCapabilities(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNACFrame
+		expectedError error
+	}{
+		{
+			name:      "success",
+			inputBody: wire.SNACFrame{},
+		},
+		{
+			name:      "empty body",
+			inputBody: wire.SNACFrame{},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Alert,
+					SubGroup:  wire.AlertNotifyCapabilities,
+				},
+				Body: tt.inputBody,
+			}
+
+			h := Handler{
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, nil, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_AlertNotifyDisplayCapabilities(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNACFrame
+		expectedError error
+	}{
+		{
+			name:      "success",
+			inputBody: wire.SNACFrame{},
+		},
+		{
+			name:      "empty body",
+			inputBody: wire.SNACFrame{},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Alert,
+					SubGroup:  wire.AlertNotifyDisplayCapabilities,
+				},
+				Body: tt.inputBody,
+			}
+
+			h := Handler{
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, nil, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_BARTDownloadQuery(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x10_0x04_BARTDownloadQuery
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name:      "success",
+			inputBody: wire.SNAC_0x10_0x04_BARTDownloadQuery{},
+		},
+		{
+			name:          "service error",
+			inputBody:     wire.SNAC_0x10_0x04_BARTDownloadQuery{},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name:          "response writer error",
+			inputBody:     wire.SNAC_0x10_0x04_BARTDownloadQuery{},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BART,
+					SubGroup:  wire.BARTDownloadQuery,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BART,
+					SubGroup:  wire.BARTDownloadReply,
+				},
+				Body: wire.SNAC_0x10_0x05_BARTDownloadReply{
+					ScreenName: "the-screen-name",
+				},
+			}
+
+			svc := newMockBARTService(t)
+			svc.EXPECT().
+				RetrieveItem(mock.Anything, mock.Anything, input.Frame, input.Body).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				BARTService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_BARTUploadQuery(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x10_0x02_BARTUploadQuery
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x10_0x02_BARTUploadQuery{
+				Type: 1,
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x10_0x02_BARTUploadQuery{
+				Type: 1,
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x10_0x02_BARTUploadQuery{
+				Type: 1,
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BART,
+					SubGroup:  wire.BARTUploadQuery,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BART,
+					SubGroup:  wire.BARTUploadReply,
+				},
+				Body: wire.SNAC_0x10_0x03_BARTUploadReply{
+					Code: wire.BARTReplyCodesSuccess,
+				},
+			}
+
+			svc := newMockBARTService(t)
+			svc.EXPECT().
+				UpsertItem(mock.Anything, mock.Anything, input.Frame, input.Body).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				BARTService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_BuddyRightsQuery(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x03_0x02_BuddyRightsQuery
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x03_0x02_BuddyRightsQuery{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(0x01, uint16(1000)),
+					},
+				},
+			},
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x03_0x02_BuddyRightsQuery{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(0x01, uint16(1000)),
+					},
+				},
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Buddy,
+					SubGroup:  wire.BuddyRightsQuery,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Buddy,
+					SubGroup:  wire.BuddyRightsReply,
+				},
+				Body: wire.SNAC_0x03_0x03_BuddyRightsReply{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(0x01, uint16(1000)),
+						},
+					},
+				},
+			}
+
+			svc := newMockBuddyService(t)
+			svc.EXPECT().
+				RightsQuery(mock.Anything, input.Frame).
+				Return(output)
+
+			h := Handler{
+				BuddyService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			responseWriter.EXPECT().
+				SendSNAC(output.Frame, output.Body).
+				Return(tt.responseError)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_BuddyAddBuddies(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x03_0x04_BuddyAddBuddies
+		serviceError  error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+				Buddies: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{
+						ScreenName: "user1",
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x03_0x04_BuddyAddBuddies{
+				Buddies: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{
+						ScreenName: "user1",
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Buddy,
+					SubGroup:  wire.BuddyAddBuddies,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockBuddyService(t)
+			svc.EXPECT().
+				AddBuddies(mock.Anything, mock.Anything, input.Body).
+				Return(tt.serviceError)
+
+			h := Handler{
+				BuddyService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_BuddyDelBuddies(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x03_0x05_BuddyDelBuddies
+		serviceError  error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x03_0x05_BuddyDelBuddies{
+				Buddies: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{
+						ScreenName: "user1",
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x03_0x05_BuddyDelBuddies{
+				Buddies: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{
+						ScreenName: "user1",
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Buddy,
+					SubGroup:  wire.BuddyDelBuddies,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockBuddyService(t)
+			svc.EXPECT().
+				DelBuddies(mock.Anything, mock.Anything, input.Body).
+				Return(tt.serviceError)
+
+			h := Handler{
+				BuddyService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_ChatNavCreateRoom(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+				Exchange: 1,
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+				Exchange: 1,
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+				Exchange: 1,
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ChatNav,
+					SubGroup:  wire.ChatNavCreateRoom,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ChatNav,
+					SubGroup:  wire.ChatNavNavInfo,
+				},
+				Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{},
+			}
+
+			sess := state.NewSession()
+
+			svc := newMockChatNavService(t)
+			svc.EXPECT().
+				CreateRoom(mock.Anything, sess, input.Frame, input.Body).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				ChatNavService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, sess, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_ChatNavCreateRoom_ReadErr(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ChatNav,
+			SubGroup:  wire.ChatNavCreateRoom,
+		},
+		Body: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+			Exchange: 1,
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ChatNav,
+			SubGroup:  wire.ChatNavNavInfo,
+		},
+		Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{},
+	}
+
+	sess := state.NewSession()
+
+	svc := newMockChatNavService(t)
+	svc.EXPECT().
+		CreateRoom(mock.Anything, sess, input.Frame, input.Body).
+		Return(output, nil)
+
+	h := Handler{
+		ChatNavService: svc,
+		RouteLogger: middleware.RouteLogger{
+			Logger: slog.Default(),
+		},
+	}
+
+	ss := newMockResponseWriter(t)
+	ss.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, sess, input.Frame, buf, ss, ""))
+}
+
+func TestHandler_ChatNavRequestChatRights(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     struct{}
+		responseError error
+		expectedError error
+	}{
+		{
+			name:      "success",
+			inputBody: struct{}{},
+		},
+		{
+			name:          "response writer error",
+			inputBody:     struct{}{},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ChatNav,
+					SubGroup:  wire.ChatNavRequestChatRights,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ChatNav,
+					SubGroup:  wire.ChatNavNavInfo,
+				},
+				Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{},
+			}
+
+			svc := newMockChatNavService(t)
+			svc.EXPECT().
+				RequestChatRights(mock.Anything, input.Frame).
+				Return(output)
+
+			h := Handler{
+				ChatNavService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			responseWriter.EXPECT().
+				SendSNAC(output.Frame, output.Body).
+				Return(tt.responseError)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_ChatNavRequestRoomInfo(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
+				Exchange: 1,
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
+				Exchange: 1,
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
+				Exchange: 1,
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ChatNav,
+					SubGroup:  wire.ChatNavRequestRoomInfo,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ChatNav,
+					SubGroup:  wire.ChatNavNavInfo,
+				},
+				Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{},
+			}
+
+			svc := newMockChatNavService(t)
+			svc.EXPECT().
+				RequestRoomInfo(mock.Anything, input.Frame, input.Body).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				ChatNavService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_ChatNavRequestExchangeInfo(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo{
+				Exchange: 4,
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo{
+				Exchange: 4,
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo{
+				Exchange: 4,
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ChatNav,
+					SubGroup:  wire.ChatNavRequestExchangeInfo,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ChatNav,
+					SubGroup:  wire.ChatNavNavInfo,
+				},
+				Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{},
+			}
+
+			svc := newMockChatNavService(t)
+			svc.EXPECT().
+				ExchangeInfo(mock.Anything, input.Frame, input.Body).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				ChatNavService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_ChatChannelMsgToHost(t *testing.T) {
+	tests := []struct {
+		name            string
+		inputBody       wire.SNAC_0x0E_0x05_ChatChannelMsgToHost
+		serviceResponse *wire.SNACMessage
+		serviceError    error
+		responseError   error
+		expectedError   error
+	}{
+		{
+			name: "success with reflected response",
+			inputBody: wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+				Channel: 4,
+			},
+			serviceResponse: &wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Chat,
+					SubGroup:  wire.ChatChannelMsgToClient,
+				},
+				Body: wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{
+					Channel: 4,
+				},
+			},
+		},
+		{
+			name: "service error with reflected response",
+			inputBody: wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+				Channel: 4,
+			},
+			serviceResponse: &wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Chat,
+					SubGroup:  wire.ChatChannelMsgToClient,
+				},
+				Body: wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{
+					Channel: 4,
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error with reflected response",
+			inputBody: wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+				Channel: 4,
+			},
+			serviceResponse: &wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Chat,
+					SubGroup:  wire.ChatChannelMsgToClient,
+				},
+				Body: wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{
+					Channel: 4,
+				},
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "success without reflected response",
+			inputBody: wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+				Channel: 4,
+			},
+			serviceResponse: nil, // nil response means no reflection back to caller
+		},
+		{
+			name: "service error without reflected response",
+			inputBody: wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+				Channel: 4,
+			},
+			serviceResponse: nil,
+			serviceError:    assert.AnError,
+			expectedError:   assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Chat,
+					SubGroup:  wire.ChatChannelMsgToHost,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockChatService(t)
+			svc.EXPECT().
+				ChannelMsgToHost(mock.Anything, mock.Anything, input.Frame, input.Body).
+				Return(tt.serviceResponse, tt.serviceError)
+
+			h := Handler{
+				ChatService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil && tt.serviceResponse != nil {
+				responseWriter.EXPECT().
+					SendSNAC(tt.serviceResponse.Frame, tt.serviceResponse.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_FeedbagDeleteItem(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x13_0x0A_FeedbagDeleteItem
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+				Items: []wire.FeedbagItem{
+					{
+						Name: "my-item",
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+				Items: []wire.FeedbagItem{
+					{
+						Name: "my-item",
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+				Items: []wire.FeedbagItem{
+					{
+						Name: "my-item",
+					},
+				},
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagDeleteItem,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagStatus,
+				},
+				Body: wire.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{1234},
+				},
+			}
+
+			svc := newMockFeedbagService(t)
+			svc.EXPECT().
+				DeleteItem(mock.Anything, mock.Anything, input.Frame, input.Body).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				FeedbagService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_FeedbagEndCluster(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     struct{}
+		expectedError error
+	}{
+		{
+			name:      "success",
+			inputBody: struct{}{},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagEndCluster,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockFeedbagService(t)
+			h := Handler{
+				FeedbagService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+			responseWriter := newMockResponseWriter(t)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_FeedbagInsertItem(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x13_0x08_FeedbagInsertItem
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x13_0x08_FeedbagInsertItem{
+				Items: []wire.FeedbagItem{
+					{
+						Name: "my-item",
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x13_0x08_FeedbagInsertItem{
+				Items: []wire.FeedbagItem{
+					{
+						Name: "my-item",
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x13_0x08_FeedbagInsertItem{
+				Items: []wire.FeedbagItem{
+					{
+						Name: "my-item",
+					},
+				},
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagInsertItem,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagStatus,
+				},
+				Body: wire.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{1234},
+				},
+			}
+
+			svc := newMockFeedbagService(t)
+			svc.EXPECT().
+				UpsertItem(mock.Anything, mock.Anything, input.Frame, tt.inputBody.Items).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				FeedbagService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_FeedbagQuery(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x13_0x02_FeedbagRightsQuery
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x13_0x02_FeedbagRightsQuery{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						{
+							Tag:   0x01,
+							Value: []byte{1, 2, 3, 4},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x13_0x02_FeedbagRightsQuery{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						{
+							Tag:   0x01,
+							Value: []byte{1, 2, 3, 4},
+						},
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x13_0x02_FeedbagRightsQuery{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						{
+							Tag:   0x01,
+							Value: []byte{1, 2, 3, 4},
+						},
+					},
+				},
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagQuery,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagReply,
+				},
+				Body: wire.SNAC_0x13_0x06_FeedbagReply{
+					Version: 4,
+				},
+			}
+
+			svc := newMockFeedbagService(t)
+			svc.EXPECT().
+				Query(mock.Anything, mock.Anything, input.Frame).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				FeedbagService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_FeedbagQueryIfModified(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x13_0x05_FeedbagQueryIfModified
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x13_0x05_FeedbagQueryIfModified{
+				LastUpdate: 1234,
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x13_0x05_FeedbagQueryIfModified{
+				LastUpdate: 1234,
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x13_0x05_FeedbagQueryIfModified{
+				LastUpdate: 1234,
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagQueryIfModified,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagReply,
+				},
+				Body: wire.SNAC_0x13_0x06_FeedbagReply{
+					LastUpdate: 1234,
+				},
+			}
+
+			svc := newMockFeedbagService(t)
+			svc.EXPECT().
+				QueryIfModified(mock.Anything, mock.Anything, input.Frame, input.Body).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				FeedbagService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_FeedbagRightsQuery(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x13_0x02_FeedbagRightsQuery
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x13_0x02_FeedbagRightsQuery{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						{
+							Tag:   0x01,
+							Value: []byte{1, 2, 3, 4},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x13_0x02_FeedbagRightsQuery{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						{
+							Tag:   0x01,
+							Value: []byte{1, 2, 3, 4},
+						},
+					},
+				},
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagRightsQuery,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagRightsReply,
+				},
+				Body: wire.SNAC_0x13_0x03_FeedbagRightsReply{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							{
+								Tag:   0x01,
+								Value: []byte{1, 2, 3, 4},
+							},
+						},
+					},
+				},
+			}
+
+			svc := newMockFeedbagService(t)
+			svc.EXPECT().
+				RightsQuery(mock.Anything, input.Frame).
+				Return(output)
+
+			h := Handler{
+				FeedbagService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			responseWriter.EXPECT().
+				SendSNAC(output.Frame, output.Body).
+				Return(tt.responseError)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_FeedbagStartCluster(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x13_0x11_FeedbagStartCluster
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x13_0x11_FeedbagStartCluster{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						{
+							Tag:   0x01,
+							Value: []byte{1, 2, 3, 4},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagStartCluster,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockFeedbagService(t)
+			svc.EXPECT().
+				StartCluster(mock.Anything, input.Frame, input.Body)
+
+			h := Handler{
+				FeedbagService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_FeedbagUpdateItem(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x13_0x09_FeedbagUpdateItem
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Items: []wire.FeedbagItem{
+					{
+						Name: "my-item",
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Items: []wire.FeedbagItem{
+					{
+						Name: "my-item",
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x13_0x09_FeedbagUpdateItem{
+				Items: []wire.FeedbagItem{
+					{
+						Name: "my-item",
+					},
+				},
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagUpdateItem,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagStatus,
+				},
+				Body: wire.SNAC_0x13_0x0E_FeedbagStatus{
+					Results: []uint16{1234},
+				},
+			}
+
+			svc := newMockFeedbagService(t)
+			svc.EXPECT().
+				UpsertItem(mock.Anything, mock.Anything, input.Frame, tt.inputBody.Items).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				FeedbagService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_FeedbagUse(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     struct{}
+		serviceError  error
+		expectedError error
+	}{
+		{
+			name:      "success",
+			inputBody: struct{}{},
+		},
+		{
+			name:          "service error",
+			inputBody:     struct{}{},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Feedbag,
+					SubGroup:  wire.FeedbagUse,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockFeedbagService(t)
+			svc.EXPECT().
+				Use(mock.Anything, mock.Anything).
+				Return(tt.serviceError)
+
+			h := Handler{
+				FeedbagService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+			responseWriter := newMockResponseWriter(t)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_FeedbagRespondAuthorizeToHost(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Feedbag,
+			SubGroup:  wire.FeedbagRespondAuthorizeToHost,
+		},
+		Body: wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost{
+			ScreenName: "theScreenName",
+		},
+	}
+
+	svc := newMockFeedbagService(t)
+	svc.EXPECT().
+		RespondAuthorizeToHost(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(nil)
+
+	h := Handler{
+		FeedbagService: svc,
+		RouteLogger: middleware.RouteLogger{
+			Logger: slog.Default(),
+		},
+	}
+	responseWriter := newMockResponseWriter(t)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, ""))
+}
+
+func TestHandler_ICBMAddParameters(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x04_0x02_ICBMAddParameters
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x04_0x02_ICBMAddParameters{
+				Channel: 1,
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMAddParameters,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockICBMService(t)
+			h := Handler{
+				ICBMService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+			responseWriter := newMockResponseWriter(t)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_ICBMChannelMsgToHost(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				ScreenName: "recipient-screen-name",
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				ScreenName: "recipient-screen-name",
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+				ScreenName: "recipient-screen-name",
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMChannelMsgToHost,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMHostAck,
+				},
+				Body: wire.SNAC_0x04_0x0C_ICBMHostAck{
+					ChannelID: 4,
+				},
+			}
+
+			svc := newMockICBMService(t)
+			svc.EXPECT().
+				ChannelMsgToHost(mock.Anything, mock.Anything, input.Frame, input.Body).
+				Return(&output, tt.serviceError)
+
+			h := Handler{
+				ICBMService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_ICBMClientErr(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x04_0x0B_ICBMClientErr
+		serviceError  error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x04_0x0B_ICBMClientErr{
+				Code: 4,
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x04_0x0B_ICBMClientErr{
+				Code: 4,
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMClientErr,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockICBMService(t)
+			svc.EXPECT().
+				ClientErr(mock.Anything, mock.Anything, input.Frame, input.Body).
+				Return(tt.serviceError)
+
+			h := Handler{
+				ICBMService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_ICBMClientEvent(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x04_0x14_ICBMClientEvent
+		serviceError  error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x04_0x14_ICBMClientEvent{
+				ScreenName: "recipient-screen-name",
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x04_0x14_ICBMClientEvent{
+				ScreenName: "recipient-screen-name",
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMClientEvent,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockICBMService(t)
+			svc.EXPECT().
+				ClientEvent(mock.Anything, mock.Anything, input.Frame, input.Body).
+				Return(tt.serviceError)
+
+			h := Handler{
+				ICBMService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_ICBMEvilRequest(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x04_0x08_ICBMEvilRequest
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x04_0x08_ICBMEvilRequest{
+				ScreenName: "recipient-screen-name",
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x04_0x08_ICBMEvilRequest{
+				ScreenName: "recipient-screen-name",
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x04_0x08_ICBMEvilRequest{
+				ScreenName: "recipient-screen-name",
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMEvilRequest,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMEvilReply,
+				},
+				Body: wire.SNAC_0x04_0x09_ICBMEvilReply{
+					EvilDeltaApplied: 100,
+				},
+			}
+
+			svc := newMockICBMService(t)
+			svc.EXPECT().
+				EvilRequest(mock.Anything, mock.Anything, input.Frame, input.Body).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				ICBMService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_ICBMParameterQuery(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     struct{}
+		responseError error
+		expectedError error
+	}{
+		{
+			name:      "success",
+			inputBody: struct{}{},
+		},
+		{
+			name:          "response writer error",
+			inputBody:     struct{}{},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMParameterQuery,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMParameterReply,
+				},
+				Body: wire.SNAC_0x04_0x05_ICBMParameterReply{
+					MaxSlots: 100,
+				},
+			}
+
+			svc := newMockICBMService(t)
+			svc.EXPECT().
+				ParameterQuery(mock.Anything, input.Frame).
+				Return(output)
+
+			h := Handler{
+				ICBMService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			responseWriter.EXPECT().
+				SendSNAC(output.Frame, output.Body).
+				Return(tt.responseError)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_ICQDBQuery(t *testing.T) {
+	type ICQMetaRequest struct {
+		wire.ICQMetadata
+		ReqSubType  uint16
+		MetaRequest any
+	}
+	type reqParams struct {
+		ctx     context.Context
+		sess    *state.Session
+		inFrame wire.SNACFrame
+		inBody  wire.SNAC_0x15_0x02_BQuery
+		rw      ResponseWriter
+		seq     uint16
+		wantErr error
+	}
+	type mockParam struct {
+		req     any
+		wantErr error
+	}
+	type allMockParams struct {
+		deleteMsgReq      *mockParam
+		findByDetails     *mockParam
+		findByEmail       *mockParam
+		findByEmail3      *mockParam
+		findByInterests   *mockParam
+		findByUIN         *mockParam
+		findByUIN2        *mockParam
+		findByWhitePages2 *mockParam
+		fullUserInfo      *mockParam
+		offlineMsgReq     *mockParam
+		setAffiliations   *mockParam
+		setBasicInfo      *mockParam
+		setEmails         *mockParam
+		setInterests      *mockParam
+		setMoreInfo       *mockParam
+		setPermissions    *mockParam
+		setUserNotes      *mockParam
+		setWorkInfo       *mockParam
+		shortUserInfo     *mockParam
+		xmlReqData        *mockParam
+	}
+	tests := []struct {
+		name          string
+		reqParams     reqParams
+		allMockParams allMockParams
+	}{
+		{
+			name: "MetaReqFullInfo - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqFullInfo,
+									MetaRequest: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
+										UIN: 123456789,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				fullUserInfo: &mockParam{
+					req: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
+						UIN: 123456789,
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqShortInfo - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqShortInfo,
+									MetaRequest: wire.ICQ_0x07D0_0x04BA_DBQueryMetaReqShortInfo{
+										UIN: 123456789,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				shortUserInfo: &mockParam{
+					req: wire.ICQ_0x07D0_0x04BA_DBQueryMetaReqShortInfo{
+						UIN: 123456789,
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqFullInfo2 - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqFullInfo2,
+									MetaRequest: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
+										UIN: 123456789,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				fullUserInfo: &mockParam{
+					req: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
+						UIN: 123456789,
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqXMLReq - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqXMLReq,
+									MetaRequest: wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq{
+										XMLRequest: "<xml></xml>",
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				xmlReqData: &mockParam{
+					req: wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq{
+						XMLRequest: "<xml></xml>",
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSetPermissions - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetPermissions,
+									MetaRequest: wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions{
+										Authorization: 1,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setPermissions: &mockParam{
+					req: wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions{
+						Authorization: 1,
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSearchByUIN - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSearchByUIN,
+									MetaRequest: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
+										UIN: 123456789,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				findByUIN: &mockParam{
+					req: wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN{
+						UIN: 123456789,
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSearchByUIN2 - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSearchByUIN2,
+									MetaRequest: wire.ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2{
+										TLVRestBlock: wire.TLVRestBlock{
+											TLVList: wire.TLVList{
+												wire.NewTLVBE(1, uint16(1)),
+											},
+										},
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				findByUIN2: &mockParam{
+					req: wire.ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2{
+						TLVRestBlock: wire.TLVRestBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(1, uint16(1)),
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSearchByEmail - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSearchByEmail,
+									MetaRequest: wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail{
+										Email: "test@aol.com",
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				findByEmail: &mockParam{
+					req: wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail{
+						Email: "test@aol.com",
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSearchByEmail3 - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSearchByEmail3,
+									MetaRequest: wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3{
+										TLVRestBlock: wire.TLVRestBlock{
+											TLVList: wire.TLVList{
+												wire.NewTLVBE(1, uint16(1)),
+											},
+										},
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				findByEmail3: &mockParam{
+					req: wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3{
+						TLVRestBlock: wire.TLVRestBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(1, uint16(1)),
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSearchByDetails - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSearchByDetails,
+									MetaRequest: wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails{
+										FirstName: "john",
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				findByDetails: &mockParam{
+					req: wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails{
+						FirstName: "john",
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSearchWhitePages - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSearchWhitePages,
+									MetaRequest: wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages{
+										InterestsCode: 1,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				findByInterests: &mockParam{
+					req: wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages{
+						InterestsCode: 1,
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSearchWhitePages2 - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSearchWhitePages2,
+									MetaRequest: wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2{
+										TLVRestBlock: wire.TLVRestBlock{
+											TLVList: wire.TLVList{
+												wire.NewTLVBE(1, uint16(1)),
+											},
+										},
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				findByWhitePages2: &mockParam{
+					req: wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2{
+						TLVRestBlock: wire.TLVRestBlock{
+							TLVList: wire.TLVList{
+								wire.NewTLVBE(1, uint16(1)),
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSetBasicInfo - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetBasicInfo,
+									MetaRequest: wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo{
+										FirstName: "john",
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setBasicInfo: &mockParam{
+					req: wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo{
+						FirstName: "john",
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSetWorkInfo - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetWorkInfo,
+									MetaRequest: wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo{
+										ZIP: "11111",
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setWorkInfo: &mockParam{
+					req: wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo{
+						ZIP: "11111",
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSetMoreInfo - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetMoreInfo,
+									MetaRequest: wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{
+										Age: 100,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setMoreInfo: &mockParam{
+					req: wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo{
+						Age: 100,
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSetNotes - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetNotes,
+									MetaRequest: wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes{
+										Notes: "my note",
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setUserNotes: &mockParam{
+					req: wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes{
+						Notes: "my note",
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSetEmails - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetEmails,
+									MetaRequest: wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails{
+										Emails: []struct {
+											Publish uint8
+											Email   string `oscar:"len_prefix=uint16,nullterm"`
+										}{
+											{
+												Email: "test@aol.com",
+											},
+										},
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setEmails: &mockParam{
+					req: wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails{
+						Emails: []struct {
+							Publish uint8
+							Email   string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							{
+								Email: "test@aol.com",
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSetInterests - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetInterests,
+									MetaRequest: wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests{
+										Interests: []struct {
+											Code    uint16
+											Keyword string `oscar:"len_prefix=uint16,nullterm"`
+										}{
+											{
+												Keyword: "an_interest",
+											},
+										},
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setInterests: &mockParam{
+					req: wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests{
+						Interests: []struct {
+							Code    uint16
+							Keyword string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							{
+								Keyword: "an_interest",
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqSetAffiliations - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType: wire.ICQDBQueryMetaReqSetAffiliations,
+									MetaRequest: wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations{
+										PastAffiliations: []struct {
+											Code    uint16
+											Keyword string `oscar:"len_prefix=uint16,nullterm"`
+										}{
+											{
+												Keyword: "a_past_affiliation",
+											},
+										},
+										Affiliations: []struct {
+											Code    uint16
+											Keyword string `oscar:"len_prefix=uint16,nullterm"`
+										}{
+											{
+												Keyword: "an_affiliation",
+											},
+										},
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				setAffiliations: &mockParam{
+					req: wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations{
+						PastAffiliations: []struct {
+							Code    uint16
+							Keyword string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							{
+								Keyword: "a_past_affiliation",
+							},
+						},
+						Affiliations: []struct {
+							Code    uint16
+							Keyword string `oscar:"len_prefix=uint16,nullterm"`
+						}{
+							{
+								Keyword: "an_affiliation",
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "MetaReqStat - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType:  wire.ICQDBQueryMetaReqStat0a8c,
+									MetaRequest: struct{}{},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+		},
+		{
+			name: "unknown metadata request subtype",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryMetaReq,
+										Seq:     1,
+									},
+									ReqSubType:  0xCA8E,
+									MetaRequest: struct{}{},
+								},
+							}),
+						},
+					},
+				},
+				seq:     1,
+				wantErr: errUnknownICQMetaReqSubType,
+			},
+		},
+		{
+			name: "OfflineMsgReq - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: wire.ICQMetadataWithSubType{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryOfflineMsgReq,
+										Seq:     1,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				offlineMsgReq: &mockParam{},
+			},
+		},
+		{
+			name: "DeleteMsgReq - happy path",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: wire.ICQMetadataWithSubType{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: wire.ICQDBQueryDeleteMsgReq,
+										Seq:     1,
+									},
+								},
+							}),
+						},
+					},
+				},
+				seq: 1,
+			},
+			allMockParams: allMockParams{
+				deleteMsgReq: &mockParam{},
+			},
+		},
+		{
+			name: "unknown request type",
+			reqParams: reqParams{
+				sess: &state.Session{},
+				inBody: wire.SNAC_0x15_0x02_BQuery{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+								Message: ICQMetaRequest{
+									ICQMetadata: wire.ICQMetadata{
+										ReqType: 0x13B4,
+										Seq:     1,
+									},
+									ReqSubType:  0xCA8E,
+									MetaRequest: struct{}{},
+								},
+							}),
+						},
+					},
+				},
+				seq:     1,
+				wantErr: errUnknownICQMetaReqType,
+			},
+		}, // todo: add to a separate test
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+
+			icqService := newMockICQService(t)
+			switch {
+			case tt.allMockParams.fullUserInfo != nil:
+				icqService.EXPECT().
+					FullUserInfo(mock.Anything, tt.reqParams.sess, tt.allMockParams.fullUserInfo.req, tt.reqParams.seq).
+					Return(tt.allMockParams.fullUserInfo.wantErr)
+			case tt.allMockParams.shortUserInfo != nil:
+				icqService.EXPECT().
+					ShortUserInfo(mock.Anything, tt.reqParams.sess, tt.allMockParams.shortUserInfo.req, tt.reqParams.seq).
+					Return(tt.allMockParams.shortUserInfo.wantErr)
+			case tt.allMockParams.xmlReqData != nil:
+				icqService.EXPECT().
+					XMLReqData(mock.Anything, tt.reqParams.sess, tt.allMockParams.xmlReqData.req, tt.reqParams.seq).
+					Return(tt.allMockParams.xmlReqData.wantErr)
+			case tt.allMockParams.setPermissions != nil:
+				icqService.EXPECT().
+					SetPermissions(mock.Anything, tt.reqParams.sess, tt.allMockParams.setPermissions.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setPermissions.wantErr)
+			case tt.allMockParams.findByUIN != nil:
+				icqService.EXPECT().
+					FindByUIN(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByUIN.req, tt.reqParams.seq).
+					Return(tt.allMockParams.findByUIN.wantErr)
+			case tt.allMockParams.findByUIN2 != nil:
+				icqService.EXPECT().
+					FindByUIN2(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByUIN2.req, tt.reqParams.seq).
+					Return(tt.allMockParams.findByUIN2.wantErr)
+			case tt.allMockParams.findByEmail != nil:
+				icqService.EXPECT().
+					FindByICQEmail(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByEmail.req, tt.reqParams.seq).
+					Return(tt.allMockParams.findByEmail.wantErr)
+			case tt.allMockParams.findByEmail3 != nil:
+				icqService.EXPECT().
+					FindByEmail3(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByEmail3.req, tt.reqParams.seq).
+					Return(tt.allMockParams.findByEmail3.wantErr)
+			case tt.allMockParams.findByDetails != nil:
+				icqService.EXPECT().
+					FindByICQName(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByDetails.req, tt.reqParams.seq).
+					Return(tt.allMockParams.findByDetails.wantErr)
+			case tt.allMockParams.findByInterests != nil:
+				icqService.EXPECT().
+					FindByICQInterests(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByInterests.req, tt.reqParams.seq).
+					Return(tt.allMockParams.findByInterests.wantErr)
+			case tt.allMockParams.findByWhitePages2 != nil:
+				icqService.EXPECT().
+					FindByWhitePages2(mock.Anything, tt.reqParams.sess, tt.allMockParams.findByWhitePages2.req, tt.reqParams.seq).
+					Return(tt.allMockParams.findByWhitePages2.wantErr)
+			case tt.allMockParams.setBasicInfo != nil:
+				icqService.EXPECT().
+					SetBasicInfo(mock.Anything, tt.reqParams.sess, tt.allMockParams.setBasicInfo.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setBasicInfo.wantErr)
+			case tt.allMockParams.setWorkInfo != nil:
+				icqService.EXPECT().
+					SetWorkInfo(mock.Anything, tt.reqParams.sess, tt.allMockParams.setWorkInfo.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setWorkInfo.wantErr)
+			case tt.allMockParams.setMoreInfo != nil:
+				icqService.EXPECT().
+					SetMoreInfo(mock.Anything, tt.reqParams.sess, tt.allMockParams.setMoreInfo.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setMoreInfo.wantErr)
+			case tt.allMockParams.setUserNotes != nil:
+				icqService.EXPECT().
+					SetUserNotes(mock.Anything, tt.reqParams.sess, tt.allMockParams.setUserNotes.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setUserNotes.wantErr)
+			case tt.allMockParams.setEmails != nil:
+				icqService.EXPECT().
+					SetEmails(mock.Anything, tt.reqParams.sess, tt.allMockParams.setEmails.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setEmails.wantErr)
+			case tt.allMockParams.setInterests != nil:
+				icqService.EXPECT().
+					SetInterests(mock.Anything, tt.reqParams.sess, tt.allMockParams.setInterests.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setInterests.wantErr)
+			case tt.allMockParams.setAffiliations != nil:
+				icqService.EXPECT().
+					SetAffiliations(mock.Anything, tt.reqParams.sess, tt.allMockParams.setAffiliations.req, tt.reqParams.seq).
+					Return(tt.allMockParams.setAffiliations.wantErr)
+			case tt.allMockParams.offlineMsgReq != nil:
+				icqService.EXPECT().
+					OfflineMsgReq(mock.Anything, tt.reqParams.sess, tt.reqParams.seq).
+					Return(tt.allMockParams.offlineMsgReq.wantErr)
+			case tt.allMockParams.deleteMsgReq != nil:
+				icqService.EXPECT().
+					DeleteMsgReq(mock.Anything, tt.reqParams.sess, tt.reqParams.seq).
+					Return(tt.allMockParams.deleteMsgReq.wantErr)
+			}
+
+			h := Handler{
+				ICQService: icqService,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(tt.reqParams.inBody, buf))
+
+			frame := wire.SNACFrame{
+				FoodGroup: wire.ICQ,
+				SubGroup:  wire.ICQDBQuery,
+			}
+			err := h.Handle(context.TODO(), wire.BOS, tt.reqParams.sess, frame, buf, nil, "")
+			assert.ErrorIs(t, err, tt.reqParams.wantErr)
+		})
+	}
+}
+
+// Test workaround for QIP 2005 bug where TLV length is incorrect.
+func TestHandler_ICQDBQuery_QIP2005UINSearchBug(t *testing.T) {
+	icqService := newMockICQService(t)
+
+	type ICQMetaRequest struct {
+		wire.ICQMetadata
+		ReqSubType  uint16
+		MetaRequest any
+	}
+
+	expect := wire.ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2{
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.ICQTLVTagsUIN, uint32(100009)),
+			},
+		},
+	}
+
+	icqService.EXPECT().
+		FindByUIN2(mock.Anything, &state.Session{}, expect, uint16(1)).
+		Return(nil)
+
+	h := Handler{
+		ICQService: icqService,
+		RouteLogger: middleware.RouteLogger{
+			Logger: slog.Default(),
+		},
+	}
+
+	inBody := wire.SNAC_0x15_0x02_BQuery{
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.ICQTLVTagsMetadata, wire.ICQMessageReplyEnvelope{
+					Message: ICQMetaRequest{
+						ICQMetadata: wire.ICQMetadata{
+							ReqType: wire.ICQDBQueryMetaReq,
+							Seq:     1,
+						},
+						ReqSubType: wire.ICQDBQueryMetaReqSearchByUIN2,
+						MetaRequest: wire.ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2{
+							TLVRestBlock: wire.TLVRestBlock{
+								TLVList: wire.TLVList{
+									wire.NewTLVBE(wire.ICQTLVTagsUIN, uint32(100009)),
+								},
+							},
+						},
+					},
+				}),
+			},
+		},
+	}
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.MarshalBE(inBody, buf))
+
+	b := buf.Bytes()
+	b[18] = 6 // incorrectly set TLV length to 6 (should be 4)
+
+	err := h.ICQDBQuery(nil, &state.Session{}, wire.SNACFrame{}, buf, nil)
+	assert.NoError(t, err)
+}
+
+func TestHandler_ODirInfoQuery(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x0F_0x02_InfoQuery
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x0F_0x02_InfoQuery{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(1, uint16(2)),
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x0F_0x02_InfoQuery{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(1, uint16(2)),
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x0F_0x02_InfoQuery{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(1, uint16(2)),
+					},
+				},
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ODir,
+					SubGroup:  wire.ODirInfoQuery,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ODir,
+					SubGroup:  wire.ODirInfoReply,
+				},
+				Body: wire.SNAC_0x0F_0x03_InfoReply{
+					Status: 5, // OK has results/not found
+				},
+			}
+
+			svc := newMockODirService(t)
+			svc.EXPECT().
+				InfoQuery(mock.Anything, input.Frame, input.Body).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				ODirService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			ss := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				ss.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, ss, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_ODirKeywordListQuery(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x0F_0x02_InfoQuery
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x0F_0x02_InfoQuery{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(1, uint16(2)),
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x0F_0x02_InfoQuery{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(1, uint16(2)),
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x0F_0x02_InfoQuery{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(1, uint16(2)),
+					},
+				},
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ODir,
+					SubGroup:  wire.ODirKeywordListQuery,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ODir,
+					SubGroup:  wire.ODirKeywordListReply,
+				},
+				Body: wire.SNAC_0x0F_0x04_KeywordListReply{
+					Status: 0x01,
+				},
+			}
+
+			svc := newMockODirService(t)
+			svc.EXPECT().
+				KeywordListQuery(mock.Anything, input.Frame).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				ODirService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			ss := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				ss.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, ss, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_OServiceServiceClientOnline(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x01_0x02_OServiceClientOnline
+		serviceError  error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x01_0x02_OServiceClientOnline{
+				GroupVersions: []struct {
+					FoodGroup   uint16
+					Version     uint16
+					ToolID      uint16
+					ToolVersion uint16
+				}{
+					{
+						FoodGroup: 10,
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x01_0x02_OServiceClientOnline{
+				GroupVersions: []struct {
+					FoodGroup   uint16
+					Version     uint16
+					ToolID      uint16
+					ToolVersion uint16
+				}{
+					{
+						FoodGroup: 10,
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceClientOnline,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockOServiceService(t)
+			svc.EXPECT().
+				ClientOnline(mock.Anything, wire.BOS, input.Body, mock.Anything).
+				Return(tt.serviceError)
+
+			h := Handler{
+				OServiceService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, nil, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_OServiceServiceServiceRequest(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x01_0x04_OServiceServiceRequest
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x01_0x04_OServiceServiceRequest{
+				FoodGroup: wire.Chat,
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x01_0x04_OServiceServiceRequest{
+				FoodGroup: wire.Chat,
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x01_0x04_OServiceServiceRequest{
+				FoodGroup: wire.Chat,
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceServiceRequest,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceServiceResponse,
+				},
+				Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(0x01, uint16(1000)),
+						},
+					},
+				},
+			}
+
+			svc := newMockOServiceService(t)
+			svc.EXPECT().
+				ServiceRequest(mock.Anything, wire.BOS, mock.Anything, input.Frame, input.Body, "127.0.0.1:1234").
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				OServiceService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "127.0.0.1:1234")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_OServiceServiceIdleNotification(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x01_0x11_OServiceIdleNotification
+		serviceError  error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x01_0x11_OServiceIdleNotification{
+				IdleTime: 10,
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x01_0x11_OServiceIdleNotification{
+				IdleTime: 10,
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceIdleNotification,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockOServiceService(t)
+			svc.EXPECT().
+				IdleNotification(mock.Anything, mock.Anything, input.Body).
+				Return(tt.serviceError)
+
+			h := Handler{
+				OServiceService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_OServiceServiceClientVersions(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x01_0x17_OServiceClientVersions
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x01_0x17_OServiceClientVersions{
+				Versions: []uint16{
+					10,
+				},
+			},
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x01_0x17_OServiceClientVersions{
+				Versions: []uint16{
+					10,
+				},
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceClientVersions,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceHostVersions,
+				},
+				Body: wire.SNAC_0x01_0x18_OServiceHostVersions{
+					Versions: []uint16{
+						10,
+					},
+				},
+			}
+
+			sess := state.NewSession()
+			svc := newMockOServiceService(t)
+			svc.EXPECT().
+				ClientVersions(mock.Anything, sess, input.Frame, input.Body).
+				Return(output)
+
+			h := Handler{
+				OServiceService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			responseWriter.EXPECT().
+				SendSNAC(output.Frame, output.Body).
+				Return(tt.responseError)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.Background(), wire.BOS, sess, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_OServiceServiceRateParamsQuery(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     struct{}
+		responseError error
+		expectedError error
+	}{
+		{
+			name:      "success",
+			inputBody: struct{}{},
+		},
+		{
+			name:          "response writer error",
+			inputBody:     struct{}{},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceRateParamsQuery,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceRateParamsReply,
+				},
+				Body: wire.SNAC_0x01_0x07_OServiceRateParamsReply{
+					RateGroups: []struct {
+						ID    uint16
+						Pairs []struct {
+							FoodGroup uint16
+							SubGroup  uint16
+						} `oscar:"count_prefix=uint16"`
+					}{
+						{
+							ID: 1,
+						},
+					},
+				},
+			}
+
+			svc := newMockOServiceService(t)
+			svc.EXPECT().
+				RateParamsQuery(mock.Anything, mock.Anything, input.Frame).
+				Return(output)
+
+			h := Handler{
+				OServiceService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			responseWriter.EXPECT().
+				SendSNAC(output.Frame, output.Body).
+				Return(tt.responseError)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_OServiceServiceRateParamsSubAdd(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd{
+				ClassIDs: []uint16{1, 2, 3, 4},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceRateParamsSubAdd,
+				},
+				Body: tt.inputBody,
+			}
+
+			sess := state.NewSession()
+
+			svc := newMockOServiceService(t)
+			svc.EXPECT().
+				RateParamsSubAdd(mock.Anything, sess, input.Body)
+
+			h := Handler{
+				OServiceService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.Background(), wire.BOS, sess, input.Frame, buf, nil, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_OServiceServiceSetUserInfoFields(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(0x01, []byte{1, 2, 3, 4}),
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(0x01, []byte{1, 2, 3, 4}),
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(0x01, []byte{1, 2, 3, 4}),
+					},
+				},
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceSetUserInfoFields,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceUserInfoUpdate,
+				},
+				Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+					UserInfo: []wire.TLVUserInfo{
+						{ScreenName: "screen-name"},
+						{ScreenName: "screen-name"},
+					},
+				},
+			}
+
+			svc := newMockOServiceService(t)
+			svc.EXPECT().
+				SetUserInfoFields(mock.Anything, mock.Anything, input.Frame, input.Body).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				OServiceService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_OServiceServiceUserInfoQuery(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     struct{}
+		responseError error
+		expectedError error
+	}{
+		{
+			name:      "success",
+			inputBody: struct{}{},
+		},
+		{
+			name:          "response writer error",
+			inputBody:     struct{}{},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceUserInfoQuery,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceUserInfoUpdate,
+				},
+				Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+					UserInfo: []wire.TLVUserInfo{
+						{ScreenName: "screen-name"},
+						{ScreenName: "screen-name"},
+					},
+				},
+			}
+
+			svc := newMockOServiceService(t)
+			svc.EXPECT().
+				UserInfoQuery(mock.Anything, mock.Anything, input.Frame).
+				Return(output)
+
+			h := Handler{
+				OServiceService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			responseWriter.EXPECT().
+				SendSNAC(output.Frame, output.Body).
+				Return(tt.responseError)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_OServiceServiceNoop(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     struct{}
+		expectedError error
+	}{
+		{
+			name:      "success",
+			inputBody: struct{}{},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceNoop,
+				},
+				Body: tt.inputBody,
+			}
+
+			h := Handler{
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_OServiceServiceSetPrivacyFlags(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x01_0x14_OServiceSetPrivacyFlags
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x01_0x14_OServiceSetPrivacyFlags{
+				PrivacyFlags: wire.OServicePrivacyFlagMember,
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.OService,
+					SubGroup:  wire.OServiceSetPrivacyFlags,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockOServiceService(t)
+			svc.EXPECT().
+				SetPrivacyFlags(mock.Anything, input.Body)
+
+			h := Handler{
+				OServiceService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_PermitDenyRightsQuery(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     struct{}
+		responseError error
+		expectedError error
+	}{
+		{
+			name:      "success",
+			inputBody: struct{}{},
+		},
+		{
+			name:          "response writer error",
+			inputBody:     struct{}{},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.PermitDeny,
+					SubGroup:  wire.PermitDenyRightsQuery,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.PermitDeny,
+					SubGroup:  wire.PermitDenyRightsReply,
+				},
+				Body: wire.SNAC_0x09_0x03_PermitDenyRightsReply{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(0x01, uint16(1000)),
+						},
+					},
+				},
+			}
+
+			svc := newMockPermitDenyService(t)
+			svc.EXPECT().
+				RightsQuery(mock.Anything, input.Frame).
+				Return(output)
+
+			h := Handler{
+				PermitDenyService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			responseWriter.EXPECT().
+				SendSNAC(output.Frame, output.Body).
+				Return(tt.responseError)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_PermitDenyAddDenyListEntries(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries
+		serviceError  error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries{
+				Users: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{
+						ScreenName: "friend1",
+					},
+					{
+						ScreenName: "friend2",
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x09_0x07_PermitDenyAddDenyListEntries{
+				Users: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{
+						ScreenName: "friend1",
+					},
+					{
+						ScreenName: "friend2",
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sess := state.NewSession()
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.PermitDeny,
+					SubGroup:  wire.PermitDenyAddDenyListEntries,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockPermitDenyService(t)
+			svc.EXPECT().
+				AddDenyListEntries(mock.Anything, sess, input.Body).
+				Return(tt.serviceError)
+
+			h := Handler{
+				PermitDenyService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, sess, input.Frame, buf, nil, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_PermitDenyDelDenyListEntries(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x09_0x08_PermitDenyDelDenyListEntries
+		serviceError  error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x09_0x08_PermitDenyDelDenyListEntries{
+				Users: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{
+						ScreenName: "friend1",
+					},
+					{
+						ScreenName: "friend2",
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x09_0x08_PermitDenyDelDenyListEntries{
+				Users: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{
+						ScreenName: "friend1",
+					},
+					{
+						ScreenName: "friend2",
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sess := state.NewSession()
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.PermitDeny,
+					SubGroup:  wire.PermitDenyDelDenyListEntries,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockPermitDenyService(t)
+			svc.EXPECT().
+				DelDenyListEntries(mock.Anything, sess, input.Body).
+				Return(tt.serviceError)
+
+			h := Handler{
+				PermitDenyService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, sess, input.Frame, buf, nil, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_PermitDenyAddPermListEntries(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries
+		serviceError  error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries{
+				Users: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{
+						ScreenName: "friend1",
+					},
+					{
+						ScreenName: "friend2",
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x09_0x05_PermitDenyAddPermListEntries{
+				Users: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{
+						ScreenName: "friend1",
+					},
+					{
+						ScreenName: "friend2",
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sess := state.NewSession()
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.PermitDeny,
+					SubGroup:  wire.PermitDenyAddPermListEntries,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockPermitDenyService(t)
+			svc.EXPECT().
+				AddPermListEntries(mock.Anything, sess, input.Body).
+				Return(tt.serviceError)
+
+			h := Handler{
+				PermitDenyService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, sess, input.Frame, buf, nil, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_PermitDenyDelPermListEntries(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x09_0x06_PermitDenyDelPermListEntries
+		serviceError  error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x09_0x06_PermitDenyDelPermListEntries{
+				Users: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{
+						ScreenName: "friend1",
+					},
+					{
+						ScreenName: "friend2",
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x09_0x06_PermitDenyDelPermListEntries{
+				Users: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{
+						ScreenName: "friend1",
+					},
+					{
+						ScreenName: "friend2",
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sess := state.NewSession()
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.PermitDeny,
+					SubGroup:  wire.PermitDenyDelPermListEntries,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockPermitDenyService(t)
+			svc.EXPECT().
+				DelPermListEntries(mock.Anything, sess, input.Body).
+				Return(tt.serviceError)
+
+			h := Handler{
+				PermitDenyService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, sess, input.Frame, buf, nil, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_PermitDenySetGroupPermitMask(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x09_0x06_PermitDenyDelPermListEntries
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x09_0x06_PermitDenyDelPermListEntries{
+				Users: []struct {
+					ScreenName string `oscar:"len_prefix=uint8"`
+				}{
+					{
+						ScreenName: "friend1",
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sess := state.NewSession()
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.PermitDeny,
+					SubGroup:  wire.PermitDenySetGroupPermitMask,
+				},
+				Body: tt.inputBody,
+			}
+
+			svc := newMockPermitDenyService(t)
+
+			h := Handler{
+				PermitDenyService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, sess, input.Frame, buf, nil, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestUserLookupHandler_FindByEmail(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x0A_0x02_UserLookupFindByEmail
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x0A_0x02_UserLookupFindByEmail{
+				Email: []byte("haha@aol.com"),
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x0A_0x02_UserLookupFindByEmail{
+				Email: []byte("haha@aol.com"),
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x0A_0x02_UserLookupFindByEmail{
+				Email: []byte("haha@aol.com"),
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.UserLookup,
+					SubGroup:  wire.UserLookupFindByEmail,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.UserLookup,
+					SubGroup:  wire.UserLookupFindReply,
+				},
+				Body: wire.SNAC_0x0A_0x03_UserLookupFindReply{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(0x01, uint16(0x02)),
+						},
+					},
+				},
+			}
+
+			svc := newMockUserLookupService(t)
+			svc.EXPECT().
+				FindByEmail(mock.Anything, input.Frame, input.Body).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				UserLookupService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			ss := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				ss.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, ss, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_LocateGetDirInfo(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x02_0x0B_LocateGetDirInfo
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x02_0x0B_LocateGetDirInfo{
+				ScreenName: "screen-name",
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x02_0x0B_LocateGetDirInfo{
+				ScreenName: "screen-name",
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x02_0x0B_LocateGetDirInfo{
+				ScreenName: "screen-name",
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Locate,
+					SubGroup:  wire.LocateGetDirInfo,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Locate,
+					SubGroup:  wire.LocateGetDirReply,
+				},
+				Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{
+					Status: 1,
+				},
+			}
+
+			svc := newMockLocateService(t)
+			svc.EXPECT().
+				DirInfo(mock.Anything, input.Frame, input.Body).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				LocateService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_LocateRightsQuery(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     struct{}
+		responseError error
+		expectedError error
+	}{
+		{
+			name:      "success",
+			inputBody: struct{}{},
+		},
+		{
+			name:          "response writer error",
+			inputBody:     struct{}{},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Locate,
+					SubGroup:  wire.LocateRightsQuery,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Locate,
+					SubGroup:  wire.LocateRightsReply,
+				},
+				Body: wire.SNAC_0x02_0x03_LocateRightsReply{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(0x01, uint16(1000)),
+						},
+					},
+				},
+			}
+
+			svc := newMockLocateService(t)
+			svc.EXPECT().
+				RightsQuery(mock.Anything, input.Frame).
+				Return(output)
+
+			h := Handler{
+				LocateService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			responseWriter.EXPECT().
+				SendSNAC(output.Frame, output.Body).
+				Return(tt.responseError)
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_LocateSetDirInfo(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x02_0x09_LocateSetDirInfo
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name: "success",
+			inputBody: wire.SNAC_0x02_0x09_LocateSetDirInfo{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						{
+							Tag:   0x01,
+							Value: []byte{1, 2, 3, 4},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "service error",
+			inputBody: wire.SNAC_0x02_0x09_LocateSetDirInfo{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						{
+							Tag:   0x01,
+							Value: []byte{1, 2, 3, 4},
+						},
+					},
+				},
+			},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name: "response writer error",
+			inputBody: wire.SNAC_0x02_0x09_LocateSetDirInfo{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						{
+							Tag:   0x01,
+							Value: []byte{1, 2, 3, 4},
+						},
+					},
+				},
+			},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Locate,
+					SubGroup:  wire.LocateSetDirInfo,
+				},
+				Body: tt.inputBody,
+			}
+			output := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.Locate,
+					SubGroup:  wire.LocateSetDirReply,
+				},
+				Body: wire.SNAC_0x02_0x0A_LocateSetDirReply{
+					Result: 1,
+				},
+			}
+
+			svc := newMockLocateService(t)
+			svc.EXPECT().
+				SetDirInfo(mock.Anything, mock.Anything, input.Frame, input.Body).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				LocateService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				responseWriter.EXPECT().
+					SendSNAC(output.Frame, output.Body).
+					Return(tt.responseError)
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, "")
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestHandler_LocateSetInfo(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateSetInfo,
+		},
+		Body: wire.SNAC_0x02_0x04_LocateSetInfo{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					{
+						Tag:   0x01,
+						Value: []byte{1, 2, 3, 4},
+					},
+				},
+			},
+		},
+	}
+
+	svc := newMockLocateService(t)
+	svc.EXPECT().
+		SetInfo(mock.Anything, mock.Anything, input.Body).
+		Return(nil)
+
+	h := Handler{
+		LocateService: svc,
+		RouteLogger: middleware.RouteLogger{
+			Logger: slog.Default(),
+		},
+	}
+
+	responseWriter := newMockResponseWriter(t)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, ""))
+}
+
+func TestHandler_LocateSetKeywordInfo(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateSetKeywordInfo,
+		},
+		Body: wire.SNAC_0x02_0x0F_LocateSetKeywordInfo{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					{
+						Tag:   0x01,
+						Value: []byte{1, 2, 3, 4},
+					},
+				},
+			},
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateSetKeywordReply,
+		},
+		Body: wire.SNAC_0x02_0x10_LocateSetKeywordReply{
+			Unknown: 1,
+		},
+	}
+
+	svc := newMockLocateService(t)
+	svc.EXPECT().
+		SetKeywordInfo(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
+
+	h := Handler{
+		LocateService: svc,
+		RouteLogger: middleware.RouteLogger{
+			Logger: slog.Default(),
+		},
+	}
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, ""))
+}
+
+func TestHandler_LocateUserInfoQuery(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateUserInfoQuery,
+		},
+		Body: wire.SNAC_0x02_0x05_LocateUserInfoQuery{
+			Type: 1,
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateUserInfoReply,
+		},
+		Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
+			TLVUserInfo: wire.TLVUserInfo{
+				ScreenName: "screen-name",
+			},
+			LocateInfo: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					{
+						Tag:   0x01,
+						Value: []byte{1, 2, 3, 4},
+					},
+				},
+			},
+		},
+	}
+
+	svc := newMockLocateService(t)
+	svc.EXPECT().
+		UserInfoQuery(mock.Anything, mock.Anything, input.Frame, input.Body).
+		Return(output, nil)
+
+	h := Handler{
+		LocateService: svc,
+		RouteLogger: middleware.RouteLogger{
+			Logger: slog.Default(),
+		},
+	}
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, ""))
+}
+
+func TestHandler_LocateUserInfoQuery2(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateUserInfoQuery2,
+		},
+		Body: wire.SNAC_0x02_0x15_LocateUserInfoQuery2{
+			Type2: 1,
+		},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Locate,
+			SubGroup:  wire.LocateUserInfoReply,
+		},
+		Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
+			TLVUserInfo: wire.TLVUserInfo{
+				ScreenName: "screen-name",
+			},
+			LocateInfo: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					{
+						Tag:   0x01,
+						Value: []byte{1, 2, 3, 4},
+					},
+				},
+			},
+		},
+	}
+
+	svc := newMockLocateService(t)
+	svc.EXPECT().
+		UserInfoQuery(mock.Anything, mock.Anything, input.Frame, wire.SNAC_0x02_0x05_LocateUserInfoQuery{Type: 1}).
+		Return(output, nil)
+
+	h := Handler{
+		LocateService: svc,
+		RouteLogger: middleware.RouteLogger{
+			Logger: slog.Default(),
+		},
+	}
+
+	responseWriter := newMockResponseWriter(t)
+	responseWriter.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, ""))
+}
+
+func TestHandler_StatsReportEvents(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Stats,
+			SubGroup:  wire.StatsReportEvents,
+		},
+		Body: wire.SNAC_0x0B_0x03_StatsReportEvents{},
+	}
+	output := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Stats,
+			SubGroup:  wire.StatsReportAck,
+		},
+		Body: wire.SNAC_0x0B_0x04_StatsReportAck{},
+	}
+
+	svc := newMockStatsService(t)
+	svc.EXPECT().
+		ReportEvents(mock.Anything, input.Frame, input.Body).
+		Return(output)
+
+	h := Handler{
+		StatsService: svc,
+		RouteLogger: middleware.RouteLogger{
+			Logger: slog.Default(),
+		},
+	}
+
+	ss := newMockResponseWriter(t)
+	ss.EXPECT().
+		SendSNAC(output.Frame, output.Body).
+		Return(nil)
+
+	buf := &bytes.Buffer{}
+	assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+	assert.NoError(t, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, ss, ""))
+}
+
+func TestHandler_RouteNotFound(t *testing.T) {
+	input := wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Stats,
+			SubGroup:  math.MaxUint16,
+		},
+		Body: wire.SNAC_0x0B_0x03_StatsReportEvents{},
+	}
+
+	h := Handler{
+		RouteLogger: middleware.RouteLogger{
+			Logger: slog.Default(),
+		},
+	}
+
+	assert.ErrorIs(t, ErrRouteNotFound, h.Handle(context.TODO(), wire.BOS, nil, input.Frame, nil, nil, ""))
+}

+ 0 - 110
server/oscar/kerberos.go

@@ -1,110 +0,0 @@
-package oscar
-
-import (
-	"bytes"
-	"context"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-	"net"
-	"net/http"
-	"time"
-
-	"github.com/mk6i/retro-aim-server/config"
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-func NewKerberosServer(
-	cfg config.Config,
-	logger *slog.Logger,
-	authService AuthService,
-) *KerberosServer {
-	mux := http.NewServeMux()
-
-	mux.HandleFunc("POST /", func(writer http.ResponseWriter, request *http.Request) {
-		postKerberosHandler(writer, request, authService, logger)
-	})
-
-	return &KerberosServer{
-		Server: http.Server{
-			Addr:    net.JoinHostPort("", cfg.KerberosPort),
-			Handler: mux,
-		},
-		Logger: logger,
-	}
-}
-
-// KerberosServer hosts an HTTP endpoint capable of handling AIM-style Kerberos
-// authentication. The messages are structured as SNACs transmitted over HTTP.
-type KerberosServer struct {
-	http.Server
-	Logger *slog.Logger
-}
-
-func (s *KerberosServer) Start(ctx context.Context) error {
-	ch := make(chan error)
-
-	go func() {
-		s.Logger.Info("starting kerberos server", "addr", s.Addr)
-		if err := s.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
-			ch <- fmt.Errorf("unable to start kerberos server: %w", err)
-		}
-	}()
-
-	select {
-	case <-ctx.Done():
-	case err := <-ch:
-		return err
-	}
-
-	shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
-	defer cancel()
-
-	if err := s.Shutdown(shutdownCtx); err != nil {
-		s.Logger.Error("unable to shutdown kerberos server", "err", err.Error())
-	}
-	return nil
-}
-
-// postKerberosHandler handles AIM-style Kerberos authentication for AIM 6.0+.
-func postKerberosHandler(w http.ResponseWriter, r *http.Request, authService AuthService, logger *slog.Logger) {
-	b, err := io.ReadAll(r.Body)
-	if err != nil {
-		http.Error(w, "unable to read HTTP body", http.StatusBadRequest)
-		return
-	}
-	reader := bytes.NewReader(b)
-
-	var header wire.SNACFrame
-	if err := wire.UnmarshalBE(&header, reader); err != nil {
-		http.Error(w, "unable to read kerberos login SNAC header", http.StatusBadRequest)
-		return
-	}
-	if header.FoodGroup != wire.Kerberos && header.FoodGroup != wire.KerberosLoginRequest {
-		http.Error(w, "unexpected SNAC type", http.StatusBadRequest)
-		return
-	}
-
-	var body wire.SNAC_0x050C_0x0002_KerberosLoginRequest
-	if err := wire.UnmarshalBE(&body, reader); err != nil {
-		http.Error(w, "unable to read kerberos login SNAC body", http.StatusBadRequest)
-		return
-	}
-
-	response, err := authService.KerberosLogin(r.Context(), body, state.NewStubUser)
-	if err != nil {
-		logger.Error("authService.KerberosLogin", "err", err.Error())
-		http.Error(w, "internal server error", http.StatusInternalServerError)
-		return
-	}
-
-	w.Header().Set("Content-Type", "application/x-snac")
-
-	if err := wire.MarshalBE(response, w); err != nil {
-		logger.Error("unable to marshal SNAC response", "err", err.Error())
-		http.Error(w, "internal server error", http.StatusInternalServerError)
-		return
-	}
-}

+ 1 - 1
server/oscar/handler/mock_admin_service_test.go → server/oscar/mock_admin_service_test.go

@@ -1,6 +1,6 @@
 // Code generated by mockery v2.53.3. DO NOT EDIT.
 
-package handler
+package oscar
 
 import (
 	context "context"

+ 125 - 66
server/oscar/mock_auth_test.go

@@ -84,9 +84,9 @@ func (_c *mockAuthService_BUCPChallenge_Call) RunAndReturn(run func(context.Cont
 	return _c
 }
 
-// BUCPLogin provides a mock function with given fields: ctx, bodyIn, newUserFn
-func (_m *mockAuthService) BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error) {
-	ret := _m.Called(ctx, bodyIn, newUserFn)
+// BUCPLogin provides a mock function with given fields: ctx, bodyIn, newUserFn, here
+func (_m *mockAuthService) BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error), here string) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, bodyIn, newUserFn, here)
 
 	if len(ret) == 0 {
 		panic("no return value specified for BUCPLogin")
@@ -94,17 +94,17 @@ func (_m *mockAuthService) BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_
 
 	var r0 wire.SNACMessage
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)); ok {
-		return rf(ctx, bodyIn, newUserFn)
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error), string) (wire.SNACMessage, error)); ok {
+		return rf(ctx, bodyIn, newUserFn, here)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error)) wire.SNACMessage); ok {
-		r0 = rf(ctx, bodyIn, newUserFn)
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error), string) wire.SNACMessage); ok {
+		r0 = rf(ctx, bodyIn, newUserFn, here)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error)) error); ok {
-		r1 = rf(ctx, bodyIn, newUserFn)
+	if rf, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error), string) error); ok {
+		r1 = rf(ctx, bodyIn, newUserFn, here)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -121,13 +121,14 @@ type mockAuthService_BUCPLogin_Call struct {
 //   - ctx context.Context
 //   - bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest
 //   - newUserFn func(state.DisplayScreenName)(state.User , error)
-func (_e *mockAuthService_Expecter) BUCPLogin(ctx interface{}, bodyIn interface{}, newUserFn interface{}) *mockAuthService_BUCPLogin_Call {
-	return &mockAuthService_BUCPLogin_Call{Call: _e.mock.On("BUCPLogin", ctx, bodyIn, newUserFn)}
+//   - here string
+func (_e *mockAuthService_Expecter) BUCPLogin(ctx interface{}, bodyIn interface{}, newUserFn interface{}, here interface{}) *mockAuthService_BUCPLogin_Call {
+	return &mockAuthService_BUCPLogin_Call{Call: _e.mock.On("BUCPLogin", ctx, bodyIn, newUserFn, here)}
 }
 
-func (_c *mockAuthService_BUCPLogin_Call) Run(run func(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error))) *mockAuthService_BUCPLogin_Call {
+func (_c *mockAuthService_BUCPLogin_Call) Run(run func(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error), here string)) *mockAuthService_BUCPLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(wire.SNAC_0x17_0x02_BUCPLoginRequest), args[2].(func(state.DisplayScreenName) (state.User, error)))
+		run(args[0].(context.Context), args[1].(wire.SNAC_0x17_0x02_BUCPLoginRequest), args[2].(func(state.DisplayScreenName) (state.User, error)), args[3].(string))
 	})
 	return _c
 }
@@ -137,14 +138,70 @@ func (_c *mockAuthService_BUCPLogin_Call) Return(_a0 wire.SNACMessage, _a1 error
 	return _c
 }
 
-func (_c *mockAuthService_BUCPLogin_Call) RunAndReturn(run func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)) *mockAuthService_BUCPLogin_Call {
+func (_c *mockAuthService_BUCPLogin_Call) RunAndReturn(run func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error), string) (wire.SNACMessage, error)) *mockAuthService_BUCPLogin_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
-// FLAPLogin provides a mock function with given fields: ctx, frame, newUserFn
-func (_m *mockAuthService) FLAPLogin(ctx context.Context, frame wire.FLAPSignonFrame, newUserFn func(state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error) {
-	ret := _m.Called(ctx, frame, newUserFn)
+// CrackCookie provides a mock function with given fields: authCookie
+func (_m *mockAuthService) CrackCookie(authCookie []byte) (state.ServerCookie, error) {
+	ret := _m.Called(authCookie)
+
+	if len(ret) == 0 {
+		panic("no return value specified for CrackCookie")
+	}
+
+	var r0 state.ServerCookie
+	var r1 error
+	if rf, ok := ret.Get(0).(func([]byte) (state.ServerCookie, error)); ok {
+		return rf(authCookie)
+	}
+	if rf, ok := ret.Get(0).(func([]byte) state.ServerCookie); ok {
+		r0 = rf(authCookie)
+	} else {
+		r0 = ret.Get(0).(state.ServerCookie)
+	}
+
+	if rf, ok := ret.Get(1).(func([]byte) error); ok {
+		r1 = rf(authCookie)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAuthService_CrackCookie_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CrackCookie'
+type mockAuthService_CrackCookie_Call struct {
+	*mock.Call
+}
+
+// CrackCookie is a helper method to define mock.On call
+//   - authCookie []byte
+func (_e *mockAuthService_Expecter) CrackCookie(authCookie interface{}) *mockAuthService_CrackCookie_Call {
+	return &mockAuthService_CrackCookie_Call{Call: _e.mock.On("CrackCookie", authCookie)}
+}
+
+func (_c *mockAuthService_CrackCookie_Call) Run(run func(authCookie []byte)) *mockAuthService_CrackCookie_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].([]byte))
+	})
+	return _c
+}
+
+func (_c *mockAuthService_CrackCookie_Call) Return(_a0 state.ServerCookie, _a1 error) *mockAuthService_CrackCookie_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAuthService_CrackCookie_Call) RunAndReturn(run func([]byte) (state.ServerCookie, error)) *mockAuthService_CrackCookie_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// FLAPLogin provides a mock function with given fields: ctx, frame, newUserFn, here
+func (_m *mockAuthService) FLAPLogin(ctx context.Context, frame wire.FLAPSignonFrame, newUserFn func(state.DisplayScreenName) (state.User, error), here string) (wire.TLVRestBlock, error) {
+	ret := _m.Called(ctx, frame, newUserFn, here)
 
 	if len(ret) == 0 {
 		panic("no return value specified for FLAPLogin")
@@ -152,17 +209,17 @@ func (_m *mockAuthService) FLAPLogin(ctx context.Context, frame wire.FLAPSignonF
 
 	var r0 wire.TLVRestBlock
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error)); ok {
-		return rf(ctx, frame, newUserFn)
+	if rf, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error), string) (wire.TLVRestBlock, error)); ok {
+		return rf(ctx, frame, newUserFn, here)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error)) wire.TLVRestBlock); ok {
-		r0 = rf(ctx, frame, newUserFn)
+	if rf, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error), string) wire.TLVRestBlock); ok {
+		r0 = rf(ctx, frame, newUserFn, here)
 	} else {
 		r0 = ret.Get(0).(wire.TLVRestBlock)
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error)) error); ok {
-		r1 = rf(ctx, frame, newUserFn)
+	if rf, ok := ret.Get(1).(func(context.Context, wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error), string) error); ok {
+		r1 = rf(ctx, frame, newUserFn, here)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -179,13 +236,14 @@ type mockAuthService_FLAPLogin_Call struct {
 //   - ctx context.Context
 //   - frame wire.FLAPSignonFrame
 //   - newUserFn func(state.DisplayScreenName)(state.User , error)
-func (_e *mockAuthService_Expecter) FLAPLogin(ctx interface{}, frame interface{}, newUserFn interface{}) *mockAuthService_FLAPLogin_Call {
-	return &mockAuthService_FLAPLogin_Call{Call: _e.mock.On("FLAPLogin", ctx, frame, newUserFn)}
+//   - here string
+func (_e *mockAuthService_Expecter) FLAPLogin(ctx interface{}, frame interface{}, newUserFn interface{}, here interface{}) *mockAuthService_FLAPLogin_Call {
+	return &mockAuthService_FLAPLogin_Call{Call: _e.mock.On("FLAPLogin", ctx, frame, newUserFn, here)}
 }
 
-func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, frame wire.FLAPSignonFrame, newUserFn func(state.DisplayScreenName) (state.User, error))) *mockAuthService_FLAPLogin_Call {
+func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, frame wire.FLAPSignonFrame, newUserFn func(state.DisplayScreenName) (state.User, error), here string)) *mockAuthService_FLAPLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(wire.FLAPSignonFrame), args[2].(func(state.DisplayScreenName) (state.User, error)))
+		run(args[0].(context.Context), args[1].(wire.FLAPSignonFrame), args[2].(func(state.DisplayScreenName) (state.User, error)), args[3].(string))
 	})
 	return _c
 }
@@ -195,14 +253,14 @@ func (_c *mockAuthService_FLAPLogin_Call) Return(_a0 wire.TLVRestBlock, _a1 erro
 	return _c
 }
 
-func (_c *mockAuthService_FLAPLogin_Call) RunAndReturn(run func(context.Context, wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error)) *mockAuthService_FLAPLogin_Call {
+func (_c *mockAuthService_FLAPLogin_Call) RunAndReturn(run func(context.Context, wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error), string) (wire.TLVRestBlock, error)) *mockAuthService_FLAPLogin_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
-// KerberosLogin provides a mock function with given fields: ctx, inBody, newUserFn
-func (_m *mockAuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error) {
-	ret := _m.Called(ctx, inBody, newUserFn)
+// KerberosLogin provides a mock function with given fields: ctx, inBody, newUserFn, advertisedHost
+func (_m *mockAuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, inBody, newUserFn, advertisedHost)
 
 	if len(ret) == 0 {
 		panic("no return value specified for KerberosLogin")
@@ -210,17 +268,17 @@ func (_m *mockAuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0
 
 	var r0 wire.SNACMessage
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)); ok {
-		return rf(ctx, inBody, newUserFn)
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(state.DisplayScreenName) (state.User, error), string) (wire.SNACMessage, error)); ok {
+		return rf(ctx, inBody, newUserFn, advertisedHost)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(state.DisplayScreenName) (state.User, error)) wire.SNACMessage); ok {
-		r0 = rf(ctx, inBody, newUserFn)
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(state.DisplayScreenName) (state.User, error), string) wire.SNACMessage); ok {
+		r0 = rf(ctx, inBody, newUserFn, advertisedHost)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(state.DisplayScreenName) (state.User, error)) error); ok {
-		r1 = rf(ctx, inBody, newUserFn)
+	if rf, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(state.DisplayScreenName) (state.User, error), string) error); ok {
+		r1 = rf(ctx, inBody, newUserFn, advertisedHost)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -237,13 +295,14 @@ type mockAuthService_KerberosLogin_Call struct {
 //   - ctx context.Context
 //   - inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest
 //   - newUserFn func(state.DisplayScreenName)(state.User , error)
-func (_e *mockAuthService_Expecter) KerberosLogin(ctx interface{}, inBody interface{}, newUserFn interface{}) *mockAuthService_KerberosLogin_Call {
-	return &mockAuthService_KerberosLogin_Call{Call: _e.mock.On("KerberosLogin", ctx, inBody, newUserFn)}
+//   - advertisedHost string
+func (_e *mockAuthService_Expecter) KerberosLogin(ctx interface{}, inBody interface{}, newUserFn interface{}, advertisedHost interface{}) *mockAuthService_KerberosLogin_Call {
+	return &mockAuthService_KerberosLogin_Call{Call: _e.mock.On("KerberosLogin", ctx, inBody, newUserFn, advertisedHost)}
 }
 
-func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error))) *mockAuthService_KerberosLogin_Call {
+func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error), advertisedHost string)) *mockAuthService_KerberosLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(wire.SNAC_0x050C_0x0002_KerberosLoginRequest), args[2].(func(state.DisplayScreenName) (state.User, error)))
+		run(args[0].(context.Context), args[1].(wire.SNAC_0x050C_0x0002_KerberosLoginRequest), args[2].(func(state.DisplayScreenName) (state.User, error)), args[3].(string))
 	})
 	return _c
 }
@@ -253,13 +312,13 @@ func (_c *mockAuthService_KerberosLogin_Call) Return(_a0 wire.SNACMessage, _a1 e
 	return _c
 }
 
-func (_c *mockAuthService_KerberosLogin_Call) RunAndReturn(run func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)) *mockAuthService_KerberosLogin_Call {
+func (_c *mockAuthService_KerberosLogin_Call) RunAndReturn(run func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(state.DisplayScreenName) (state.User, error), string) (wire.SNACMessage, error)) *mockAuthService_KerberosLogin_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) {
+func (_m *mockAuthService) RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.Session, error) {
 	ret := _m.Called(ctx, authCookie)
 
 	if len(ret) == 0 {
@@ -268,10 +327,10 @@ func (_m *mockAuthService) RegisterBOSSession(ctx context.Context, authCookie []
 
 	var r0 *state.Session
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, []byte) (*state.Session, error)); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, state.ServerCookie) (*state.Session, error)); ok {
 		return rf(ctx, authCookie)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, []byte) *state.Session); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, state.ServerCookie) *state.Session); ok {
 		r0 = rf(ctx, authCookie)
 	} else {
 		if ret.Get(0) != nil {
@@ -279,7 +338,7 @@ func (_m *mockAuthService) RegisterBOSSession(ctx context.Context, authCookie []
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
+	if rf, ok := ret.Get(1).(func(context.Context, state.ServerCookie) error); ok {
 		r1 = rf(ctx, authCookie)
 	} else {
 		r1 = ret.Error(1)
@@ -295,14 +354,14 @@ type mockAuthService_RegisterBOSSession_Call struct {
 
 // RegisterBOSSession is a helper method to define mock.On call
 //   - ctx context.Context
-//   - authCookie []byte
+//   - authCookie state.ServerCookie
 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 {
+func (_c *mockAuthService_RegisterBOSSession_Call) Run(run func(ctx context.Context, authCookie state.ServerCookie)) *mockAuthService_RegisterBOSSession_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].([]byte))
+		run(args[0].(context.Context), args[1].(state.ServerCookie))
 	})
 	return _c
 }
@@ -312,13 +371,13 @@ func (_c *mockAuthService_RegisterBOSSession_Call) Return(_a0 *state.Session, _a
 	return _c
 }
 
-func (_c *mockAuthService_RegisterBOSSession_Call) RunAndReturn(run func(context.Context, []byte) (*state.Session, error)) *mockAuthService_RegisterBOSSession_Call {
+func (_c *mockAuthService_RegisterBOSSession_Call) RunAndReturn(run func(context.Context, state.ServerCookie) (*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) {
+func (_m *mockAuthService) RegisterChatSession(ctx context.Context, authCookie state.ServerCookie) (*state.Session, error) {
 	ret := _m.Called(ctx, authCookie)
 
 	if len(ret) == 0 {
@@ -327,10 +386,10 @@ func (_m *mockAuthService) RegisterChatSession(ctx context.Context, authCookie [
 
 	var r0 *state.Session
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, []byte) (*state.Session, error)); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, state.ServerCookie) (*state.Session, error)); ok {
 		return rf(ctx, authCookie)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, []byte) *state.Session); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, state.ServerCookie) *state.Session); ok {
 		r0 = rf(ctx, authCookie)
 	} else {
 		if ret.Get(0) != nil {
@@ -338,7 +397,7 @@ func (_m *mockAuthService) RegisterChatSession(ctx context.Context, authCookie [
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
+	if rf, ok := ret.Get(1).(func(context.Context, state.ServerCookie) error); ok {
 		r1 = rf(ctx, authCookie)
 	} else {
 		r1 = ret.Error(1)
@@ -354,14 +413,14 @@ type mockAuthService_RegisterChatSession_Call struct {
 
 // RegisterChatSession is a helper method to define mock.On call
 //   - ctx context.Context
-//   - authCookie []byte
+//   - authCookie state.ServerCookie
 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 {
+func (_c *mockAuthService_RegisterChatSession_Call) Run(run func(ctx context.Context, authCookie state.ServerCookie)) *mockAuthService_RegisterChatSession_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].([]byte))
+		run(args[0].(context.Context), args[1].(state.ServerCookie))
 	})
 	return _c
 }
@@ -371,13 +430,13 @@ func (_c *mockAuthService_RegisterChatSession_Call) Return(_a0 *state.Session, _
 	return _c
 }
 
-func (_c *mockAuthService_RegisterChatSession_Call) RunAndReturn(run func(context.Context, []byte) (*state.Session, error)) *mockAuthService_RegisterChatSession_Call {
+func (_c *mockAuthService_RegisterChatSession_Call) RunAndReturn(run func(context.Context, state.ServerCookie) (*state.Session, error)) *mockAuthService_RegisterChatSession_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
 // RetrieveBOSSession provides a mock function with given fields: ctx, authCookie
-func (_m *mockAuthService) RetrieveBOSSession(ctx context.Context, authCookie []byte) (*state.Session, error) {
+func (_m *mockAuthService) RetrieveBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.Session, error) {
 	ret := _m.Called(ctx, authCookie)
 
 	if len(ret) == 0 {
@@ -386,10 +445,10 @@ func (_m *mockAuthService) RetrieveBOSSession(ctx context.Context, authCookie []
 
 	var r0 *state.Session
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, []byte) (*state.Session, error)); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, state.ServerCookie) (*state.Session, error)); ok {
 		return rf(ctx, authCookie)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, []byte) *state.Session); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, state.ServerCookie) *state.Session); ok {
 		r0 = rf(ctx, authCookie)
 	} else {
 		if ret.Get(0) != nil {
@@ -397,7 +456,7 @@ func (_m *mockAuthService) RetrieveBOSSession(ctx context.Context, authCookie []
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
+	if rf, ok := ret.Get(1).(func(context.Context, state.ServerCookie) error); ok {
 		r1 = rf(ctx, authCookie)
 	} else {
 		r1 = ret.Error(1)
@@ -413,14 +472,14 @@ type mockAuthService_RetrieveBOSSession_Call struct {
 
 // RetrieveBOSSession is a helper method to define mock.On call
 //   - ctx context.Context
-//   - authCookie []byte
+//   - authCookie state.ServerCookie
 func (_e *mockAuthService_Expecter) RetrieveBOSSession(ctx interface{}, authCookie interface{}) *mockAuthService_RetrieveBOSSession_Call {
 	return &mockAuthService_RetrieveBOSSession_Call{Call: _e.mock.On("RetrieveBOSSession", ctx, authCookie)}
 }
 
-func (_c *mockAuthService_RetrieveBOSSession_Call) Run(run func(ctx context.Context, authCookie []byte)) *mockAuthService_RetrieveBOSSession_Call {
+func (_c *mockAuthService_RetrieveBOSSession_Call) Run(run func(ctx context.Context, authCookie state.ServerCookie)) *mockAuthService_RetrieveBOSSession_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].([]byte))
+		run(args[0].(context.Context), args[1].(state.ServerCookie))
 	})
 	return _c
 }
@@ -430,7 +489,7 @@ func (_c *mockAuthService_RetrieveBOSSession_Call) Return(_a0 *state.Session, _a
 	return _c
 }
 
-func (_c *mockAuthService_RetrieveBOSSession_Call) RunAndReturn(run func(context.Context, []byte) (*state.Session, error)) *mockAuthService_RetrieveBOSSession_Call {
+func (_c *mockAuthService_RetrieveBOSSession_Call) RunAndReturn(run func(context.Context, state.ServerCookie) (*state.Session, error)) *mockAuthService_RetrieveBOSSession_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 1 - 1
server/oscar/handler/mock_bart_service_test.go → server/oscar/mock_bart_service_test.go

@@ -1,6 +1,6 @@
 // Code generated by mockery v2.53.3. DO NOT EDIT.
 
-package handler
+package oscar
 
 import (
 	context "context"

+ 177 - 0
server/oscar/mock_buddy_list_registry_test.go

@@ -0,0 +1,177 @@
+// Code generated by mockery v2.53.3. DO NOT EDIT.
+
+package oscar
+
+import (
+	context "context"
+
+	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}
+}
+
+// ClearBuddyListRegistry provides a mock function with given fields: ctx
+func (_m *mockBuddyListRegistry) ClearBuddyListRegistry(ctx context.Context) error {
+	ret := _m.Called(ctx)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ClearBuddyListRegistry")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context) error); ok {
+		r0 = rf(ctx)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBuddyListRegistry_ClearBuddyListRegistry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearBuddyListRegistry'
+type mockBuddyListRegistry_ClearBuddyListRegistry_Call struct {
+	*mock.Call
+}
+
+// ClearBuddyListRegistry is a helper method to define mock.On call
+//   - ctx context.Context
+func (_e *mockBuddyListRegistry_Expecter) ClearBuddyListRegistry(ctx interface{}) *mockBuddyListRegistry_ClearBuddyListRegistry_Call {
+	return &mockBuddyListRegistry_ClearBuddyListRegistry_Call{Call: _e.mock.On("ClearBuddyListRegistry", ctx)}
+}
+
+func (_c *mockBuddyListRegistry_ClearBuddyListRegistry_Call) Run(run func(ctx context.Context)) *mockBuddyListRegistry_ClearBuddyListRegistry_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context))
+	})
+	return _c
+}
+
+func (_c *mockBuddyListRegistry_ClearBuddyListRegistry_Call) Return(_a0 error) *mockBuddyListRegistry_ClearBuddyListRegistry_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBuddyListRegistry_ClearBuddyListRegistry_Call) RunAndReturn(run func(context.Context) error) *mockBuddyListRegistry_ClearBuddyListRegistry_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RegisterBuddyList provides a mock function with given fields: ctx, user
+func (_m *mockBuddyListRegistry) RegisterBuddyList(ctx context.Context, user state.IdentScreenName) error {
+	ret := _m.Called(ctx, user)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RegisterBuddyList")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) error); ok {
+		r0 = rf(ctx, 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
+//   - ctx context.Context
+//   - user state.IdentScreenName
+func (_e *mockBuddyListRegistry_Expecter) RegisterBuddyList(ctx interface{}, user interface{}) *mockBuddyListRegistry_RegisterBuddyList_Call {
+	return &mockBuddyListRegistry_RegisterBuddyList_Call{Call: _e.mock.On("RegisterBuddyList", ctx, user)}
+}
+
+func (_c *mockBuddyListRegistry_RegisterBuddyList_Call) Run(run func(ctx context.Context, user state.IdentScreenName)) *mockBuddyListRegistry_RegisterBuddyList_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(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(context.Context, state.IdentScreenName) error) *mockBuddyListRegistry_RegisterBuddyList_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UnregisterBuddyList provides a mock function with given fields: ctx, user
+func (_m *mockBuddyListRegistry) UnregisterBuddyList(ctx context.Context, user state.IdentScreenName) error {
+	ret := _m.Called(ctx, user)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UnregisterBuddyList")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) error); ok {
+		r0 = rf(ctx, 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
+//   - ctx context.Context
+//   - user state.IdentScreenName
+func (_e *mockBuddyListRegistry_Expecter) UnregisterBuddyList(ctx interface{}, user interface{}) *mockBuddyListRegistry_UnregisterBuddyList_Call {
+	return &mockBuddyListRegistry_UnregisterBuddyList_Call{Call: _e.mock.On("UnregisterBuddyList", ctx, user)}
+}
+
+func (_c *mockBuddyListRegistry_UnregisterBuddyList_Call) Run(run func(ctx context.Context, user state.IdentScreenName)) *mockBuddyListRegistry_UnregisterBuddyList_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(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(context.Context, 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
+}

+ 1 - 1
server/oscar/handler/mock_buddy_service_test.go → server/oscar/mock_buddy_service_test.go

@@ -1,6 +1,6 @@
 // Code generated by mockery v2.53.3. DO NOT EDIT.
 
-package handler
+package oscar
 
 import (
 	context "context"

+ 1 - 1
server/oscar/handler/mock_chat_nav_service_test.go → server/oscar/mock_chat_nav_service_test.go

@@ -1,6 +1,6 @@
 // Code generated by mockery v2.53.3. DO NOT EDIT.
 
-package handler
+package oscar
 
 import (
 	context "context"

+ 1 - 1
server/oscar/handler/mock_chat_service_test.go → server/oscar/mock_chat_service_test.go

@@ -1,6 +1,6 @@
 // Code generated by mockery v2.53.3. DO NOT EDIT.
 
-package handler
+package oscar
 
 import (
 	context "context"

+ 68 - 0
server/oscar/mock_chat_session_manager_test.go

@@ -0,0 +1,68 @@
+// Code generated by mockery v2.53.3. DO NOT EDIT.
+
+package oscar
+
+import (
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockChatSessionManager is an autogenerated mock type for the ChatSessionManager type
+type mockChatSessionManager struct {
+	mock.Mock
+}
+
+type mockChatSessionManager_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockChatSessionManager) EXPECT() *mockChatSessionManager_Expecter {
+	return &mockChatSessionManager_Expecter{mock: &_m.Mock}
+}
+
+// RemoveUserFromAllChats provides a mock function with given fields: user
+func (_m *mockChatSessionManager) RemoveUserFromAllChats(user state.IdentScreenName) {
+	_m.Called(user)
+}
+
+// mockChatSessionManager_RemoveUserFromAllChats_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveUserFromAllChats'
+type mockChatSessionManager_RemoveUserFromAllChats_Call struct {
+	*mock.Call
+}
+
+// RemoveUserFromAllChats is a helper method to define mock.On call
+//   - user state.IdentScreenName
+func (_e *mockChatSessionManager_Expecter) RemoveUserFromAllChats(user interface{}) *mockChatSessionManager_RemoveUserFromAllChats_Call {
+	return &mockChatSessionManager_RemoveUserFromAllChats_Call{Call: _e.mock.On("RemoveUserFromAllChats", user)}
+}
+
+func (_c *mockChatSessionManager_RemoveUserFromAllChats_Call) Run(run func(user state.IdentScreenName)) *mockChatSessionManager_RemoveUserFromAllChats_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockChatSessionManager_RemoveUserFromAllChats_Call) Return() *mockChatSessionManager_RemoveUserFromAllChats_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockChatSessionManager_RemoveUserFromAllChats_Call) RunAndReturn(run func(state.IdentScreenName)) *mockChatSessionManager_RemoveUserFromAllChats_Call {
+	_c.Run(run)
+	return _c
+}
+
+// newMockChatSessionManager creates a new instance of mockChatSessionManager. 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 newMockChatSessionManager(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockChatSessionManager {
+	mock := &mockChatSessionManager{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 84 - 0
server/oscar/mock_departure_notifier_test.go

@@ -0,0 +1,84 @@
+// Code generated by mockery v2.53.3. DO NOT EDIT.
+
+package oscar
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockDepartureNotifier is an autogenerated mock type for the DepartureNotifier type
+type mockDepartureNotifier struct {
+	mock.Mock
+}
+
+type mockDepartureNotifier_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockDepartureNotifier) EXPECT() *mockDepartureNotifier_Expecter {
+	return &mockDepartureNotifier_Expecter{mock: &_m.Mock}
+}
+
+// BroadcastBuddyDeparted provides a mock function with given fields: ctx, sess
+func (_m *mockDepartureNotifier) 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
+}
+
+// mockDepartureNotifier_BroadcastBuddyDeparted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BroadcastBuddyDeparted'
+type mockDepartureNotifier_BroadcastBuddyDeparted_Call struct {
+	*mock.Call
+}
+
+// BroadcastBuddyDeparted is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+func (_e *mockDepartureNotifier_Expecter) BroadcastBuddyDeparted(ctx interface{}, sess interface{}) *mockDepartureNotifier_BroadcastBuddyDeparted_Call {
+	return &mockDepartureNotifier_BroadcastBuddyDeparted_Call{Call: _e.mock.On("BroadcastBuddyDeparted", ctx, sess)}
+}
+
+func (_c *mockDepartureNotifier_BroadcastBuddyDeparted_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockDepartureNotifier_BroadcastBuddyDeparted_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session))
+	})
+	return _c
+}
+
+func (_c *mockDepartureNotifier_BroadcastBuddyDeparted_Call) Return(_a0 error) *mockDepartureNotifier_BroadcastBuddyDeparted_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockDepartureNotifier_BroadcastBuddyDeparted_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockDepartureNotifier_BroadcastBuddyDeparted_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockDepartureNotifier creates a new instance of mockDepartureNotifier. 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 newMockDepartureNotifier(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockDepartureNotifier {
+	mock := &mockDepartureNotifier{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 1 - 1
server/oscar/handler/mock_feedbag_service_test.go → server/oscar/mock_feedbag_service_test.go

@@ -1,6 +1,6 @@
 // Code generated by mockery v2.53.3. DO NOT EDIT.
 
-package handler
+package oscar
 
 import (
 	context "context"

+ 0 - 91
server/oscar/mock_handler_test.go

@@ -1,91 +0,0 @@
-// Code generated by mockery v2.53.3. DO NOT EDIT.
-
-package oscar
-
-import (
-	context "context"
-	io "io"
-
-	mock "github.com/stretchr/testify/mock"
-
-	state "github.com/mk6i/retro-aim-server/state"
-
-	wire "github.com/mk6i/retro-aim-server/wire"
-)
-
-// mockHandler is an autogenerated mock type for the Handler type
-type mockHandler struct {
-	mock.Mock
-}
-
-type mockHandler_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockHandler) EXPECT() *mockHandler_Expecter {
-	return &mockHandler_Expecter{mock: &_m.Mock}
-}
-
-// Handle provides a mock function with given fields: ctx, sess, inFrame, r, rw
-func (_m *mockHandler) Handle(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
-	ret := _m.Called(ctx, sess, inFrame, r, rw)
-
-	if len(ret) == 0 {
-		panic("no return value specified for Handle")
-	}
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, io.Reader, ResponseWriter) error); ok {
-		r0 = rf(ctx, sess, inFrame, r, rw)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// mockHandler_Handle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Handle'
-type mockHandler_Handle_Call struct {
-	*mock.Call
-}
-
-// Handle is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - inFrame wire.SNACFrame
-//   - r io.Reader
-//   - rw ResponseWriter
-func (_e *mockHandler_Expecter) Handle(ctx interface{}, sess interface{}, inFrame interface{}, r interface{}, rw interface{}) *mockHandler_Handle_Call {
-	return &mockHandler_Handle_Call{Call: _e.mock.On("Handle", ctx, sess, inFrame, r, rw)}
-}
-
-func (_c *mockHandler_Handle_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter)) *mockHandler_Handle_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(io.Reader), args[4].(ResponseWriter))
-	})
-	return _c
-}
-
-func (_c *mockHandler_Handle_Call) Return(_a0 error) *mockHandler_Handle_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockHandler_Handle_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, io.Reader, ResponseWriter) error) *mockHandler_Handle_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// newMockHandler creates a new instance of mockHandler. 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 newMockHandler(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockHandler {
-	mock := &mockHandler{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 1 - 1
server/oscar/handler/mock_icbm_service_test.go → server/oscar/mock_icbm_service_test.go

@@ -1,6 +1,6 @@
 // Code generated by mockery v2.53.3. DO NOT EDIT.
 
-package handler
+package oscar
 
 import (
 	context "context"

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

@@ -1,6 +1,6 @@
 // Code generated by mockery v2.53.3. DO NOT EDIT.
 
-package handler
+package oscar
 
 import (
 	context "context"

+ 1 - 1
server/oscar/handler/mock_locate_service_test.go → server/oscar/mock_locate_service_test.go

@@ -1,6 +1,6 @@
 // Code generated by mockery v2.53.3. DO NOT EDIT.
 
-package handler
+package oscar
 
 import (
 	context "context"

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

@@ -1,6 +1,6 @@
 // Code generated by mockery v2.53.3. DO NOT EDIT.
 
-package handler
+package oscar
 
 import (
 	context "context"

+ 11 - 10
server/oscar/mock_online_notifier_test.go

@@ -20,17 +20,17 @@ func (_m *mockOnlineNotifier) EXPECT() *mockOnlineNotifier_Expecter {
 	return &mockOnlineNotifier_Expecter{mock: &_m.Mock}
 }
 
-// HostOnline provides a mock function with no fields
-func (_m *mockOnlineNotifier) HostOnline() wire.SNACMessage {
-	ret := _m.Called()
+// HostOnline provides a mock function with given fields: service
+func (_m *mockOnlineNotifier) HostOnline(service uint16) wire.SNACMessage {
+	ret := _m.Called(service)
 
 	if len(ret) == 0 {
 		panic("no return value specified for HostOnline")
 	}
 
 	var r0 wire.SNACMessage
-	if rf, ok := ret.Get(0).(func() wire.SNACMessage); ok {
-		r0 = rf()
+	if rf, ok := ret.Get(0).(func(uint16) wire.SNACMessage); ok {
+		r0 = rf(service)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
@@ -44,13 +44,14 @@ type mockOnlineNotifier_HostOnline_Call struct {
 }
 
 // HostOnline is a helper method to define mock.On call
-func (_e *mockOnlineNotifier_Expecter) HostOnline() *mockOnlineNotifier_HostOnline_Call {
-	return &mockOnlineNotifier_HostOnline_Call{Call: _e.mock.On("HostOnline")}
+//   - service uint16
+func (_e *mockOnlineNotifier_Expecter) HostOnline(service interface{}) *mockOnlineNotifier_HostOnline_Call {
+	return &mockOnlineNotifier_HostOnline_Call{Call: _e.mock.On("HostOnline", service)}
 }
 
-func (_c *mockOnlineNotifier_HostOnline_Call) Run(run func()) *mockOnlineNotifier_HostOnline_Call {
+func (_c *mockOnlineNotifier_HostOnline_Call) Run(run func(service uint16)) *mockOnlineNotifier_HostOnline_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run()
+		run(args[0].(uint16))
 	})
 	return _c
 }
@@ -60,7 +61,7 @@ func (_c *mockOnlineNotifier_HostOnline_Call) Return(_a0 wire.SNACMessage) *mock
 	return _c
 }
 
-func (_c *mockOnlineNotifier_HostOnline_Call) RunAndReturn(run func() wire.SNACMessage) *mockOnlineNotifier_HostOnline_Call {
+func (_c *mockOnlineNotifier_HostOnline_Call) RunAndReturn(run func(uint16) wire.SNACMessage) *mockOnlineNotifier_HostOnline_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 39 - 35
server/oscar/handler/mock_oservice_service_test.go → server/oscar/mock_oservice_service_test.go

@@ -1,6 +1,6 @@
 // Code generated by mockery v2.53.3. DO NOT EDIT.
 
-package handler
+package oscar
 
 import (
 	context "context"
@@ -24,17 +24,17 @@ func (_m *mockOServiceService) EXPECT() *mockOServiceService_Expecter {
 	return &mockOServiceService_Expecter{mock: &_m.Mock}
 }
 
-// ClientOnline provides a mock function with given fields: ctx, bodyIn, sess
-func (_m *mockOServiceService) ClientOnline(ctx context.Context, bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error {
-	ret := _m.Called(ctx, bodyIn, sess)
+// ClientOnline provides a mock function with given fields: ctx, service, bodyIn, sess
+func (_m *mockOServiceService) ClientOnline(ctx context.Context, service uint16, bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error {
+	ret := _m.Called(ctx, service, bodyIn, 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, bodyIn, sess)
+	if rf, ok := ret.Get(0).(func(context.Context, uint16, wire.SNAC_0x01_0x02_OServiceClientOnline, *state.Session) error); ok {
+		r0 = rf(ctx, service, bodyIn, sess)
 	} else {
 		r0 = ret.Error(0)
 	}
@@ -49,15 +49,16 @@ type mockOServiceService_ClientOnline_Call struct {
 
 // ClientOnline is a helper method to define mock.On call
 //   - ctx context.Context
+//   - service uint16
 //   - bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline
 //   - sess *state.Session
-func (_e *mockOServiceService_Expecter) ClientOnline(ctx interface{}, bodyIn interface{}, sess interface{}) *mockOServiceService_ClientOnline_Call {
-	return &mockOServiceService_ClientOnline_Call{Call: _e.mock.On("ClientOnline", ctx, bodyIn, sess)}
+func (_e *mockOServiceService_Expecter) ClientOnline(ctx interface{}, service interface{}, bodyIn interface{}, sess interface{}) *mockOServiceService_ClientOnline_Call {
+	return &mockOServiceService_ClientOnline_Call{Call: _e.mock.On("ClientOnline", ctx, service, bodyIn, sess)}
 }
 
-func (_c *mockOServiceService_ClientOnline_Call) Run(run func(ctx context.Context, bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session)) *mockOServiceService_ClientOnline_Call {
+func (_c *mockOServiceService_ClientOnline_Call) Run(run func(ctx context.Context, service uint16, bodyIn 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))
+		run(args[0].(context.Context), args[1].(uint16), args[2].(wire.SNAC_0x01_0x02_OServiceClientOnline), args[3].(*state.Session))
 	})
 	return _c
 }
@@ -67,7 +68,7 @@ func (_c *mockOServiceService_ClientOnline_Call) Return(_a0 error) *mockOService
 	return _c
 }
 
-func (_c *mockOServiceService_ClientOnline_Call) RunAndReturn(run func(context.Context, wire.SNAC_0x01_0x02_OServiceClientOnline, *state.Session) error) *mockOServiceService_ClientOnline_Call {
+func (_c *mockOServiceService_ClientOnline_Call) RunAndReturn(run func(context.Context, uint16, wire.SNAC_0x01_0x02_OServiceClientOnline, *state.Session) error) *mockOServiceService_ClientOnline_Call {
 	_c.Call.Return(run)
 	return _c
 }
@@ -121,17 +122,17 @@ func (_c *mockOServiceService_ClientVersions_Call) RunAndReturn(run func(context
 	return _c
 }
 
-// HostOnline provides a mock function with no fields
-func (_m *mockOServiceService) HostOnline() wire.SNACMessage {
-	ret := _m.Called()
+// HostOnline provides a mock function with given fields: service
+func (_m *mockOServiceService) HostOnline(service uint16) wire.SNACMessage {
+	ret := _m.Called(service)
 
 	if len(ret) == 0 {
 		panic("no return value specified for HostOnline")
 	}
 
 	var r0 wire.SNACMessage
-	if rf, ok := ret.Get(0).(func() wire.SNACMessage); ok {
-		r0 = rf()
+	if rf, ok := ret.Get(0).(func(uint16) wire.SNACMessage); ok {
+		r0 = rf(service)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
@@ -145,13 +146,14 @@ type mockOServiceService_HostOnline_Call struct {
 }
 
 // HostOnline is a helper method to define mock.On call
-func (_e *mockOServiceService_Expecter) HostOnline() *mockOServiceService_HostOnline_Call {
-	return &mockOServiceService_HostOnline_Call{Call: _e.mock.On("HostOnline")}
+//   - service uint16
+func (_e *mockOServiceService_Expecter) HostOnline(service interface{}) *mockOServiceService_HostOnline_Call {
+	return &mockOServiceService_HostOnline_Call{Call: _e.mock.On("HostOnline", service)}
 }
 
-func (_c *mockOServiceService_HostOnline_Call) Run(run func()) *mockOServiceService_HostOnline_Call {
+func (_c *mockOServiceService_HostOnline_Call) Run(run func(service uint16)) *mockOServiceService_HostOnline_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run()
+		run(args[0].(uint16))
 	})
 	return _c
 }
@@ -161,7 +163,7 @@ func (_c *mockOServiceService_HostOnline_Call) Return(_a0 wire.SNACMessage) *moc
 	return _c
 }
 
-func (_c *mockOServiceService_HostOnline_Call) RunAndReturn(run func() wire.SNACMessage) *mockOServiceService_HostOnline_Call {
+func (_c *mockOServiceService_HostOnline_Call) RunAndReturn(run func(uint16) wire.SNACMessage) *mockOServiceService_HostOnline_Call {
 	_c.Call.Return(run)
 	return _c
 }
@@ -297,9 +299,9 @@ func (_c *mockOServiceService_RateParamsSubAdd_Call) RunAndReturn(run func(conte
 	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)
+// ServiceRequest provides a mock function with given fields: ctx, service, sess, frame, bodyIn, advertisedHost
+func (_m *mockOServiceService) ServiceRequest(ctx context.Context, service uint16, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest, advertisedHost string) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, service, sess, frame, bodyIn, advertisedHost)
 
 	if len(ret) == 0 {
 		panic("no return value specified for ServiceRequest")
@@ -307,17 +309,17 @@ func (_m *mockOServiceService) ServiceRequest(ctx context.Context, sess *state.S
 
 	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, uint16, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, string) (wire.SNACMessage, error)); ok {
+		return rf(ctx, service, sess, frame, bodyIn, advertisedHost)
 	}
-	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)
+	if rf, ok := ret.Get(0).(func(context.Context, uint16, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, string) wire.SNACMessage); ok {
+		r0 = rf(ctx, service, sess, frame, bodyIn, advertisedHost)
 	} 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)
+	if rf, ok := ret.Get(1).(func(context.Context, uint16, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, string) error); ok {
+		r1 = rf(ctx, service, sess, frame, bodyIn, advertisedHost)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -332,16 +334,18 @@ type mockOServiceService_ServiceRequest_Call struct {
 
 // ServiceRequest is a helper method to define mock.On call
 //   - ctx context.Context
+//   - service uint16
 //   - 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)}
+//   - advertisedHost string
+func (_e *mockOServiceService_Expecter) ServiceRequest(ctx interface{}, service interface{}, sess interface{}, frame interface{}, bodyIn interface{}, advertisedHost interface{}) *mockOServiceService_ServiceRequest_Call {
+	return &mockOServiceService_ServiceRequest_Call{Call: _e.mock.On("ServiceRequest", ctx, service, sess, frame, bodyIn, advertisedHost)}
 }
 
-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 {
+func (_c *mockOServiceService_ServiceRequest_Call) Run(run func(ctx context.Context, service uint16, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest, advertisedHost string)) *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))
+		run(args[0].(context.Context), args[1].(uint16), args[2].(*state.Session), args[3].(wire.SNACFrame), args[4].(wire.SNAC_0x01_0x04_OServiceServiceRequest), args[5].(string))
 	})
 	return _c
 }
@@ -351,7 +355,7 @@ func (_c *mockOServiceService_ServiceRequest_Call) Return(_a0 wire.SNACMessage,
 	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 {
+func (_c *mockOServiceService_ServiceRequest_Call) RunAndReturn(run func(context.Context, uint16, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, string) (wire.SNACMessage, error)) *mockOServiceService_ServiceRequest_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 1 - 1
server/oscar/handler/mock_permit_deny_service_test.go → server/oscar/mock_permit_deny_service_test.go

@@ -1,6 +1,6 @@
 // Code generated by mockery v2.53.3. DO NOT EDIT.
 
-package handler
+package oscar
 
 import (
 	context "context"

+ 2 - 3
server/oscar/handler/mock_response_writer_test.go → server/oscar/mock_response_writer_test.go

@@ -1,11 +1,10 @@
 // Code generated by mockery v2.53.3. DO NOT EDIT.
 
-package handler
+package oscar
 
 import (
-	mock "github.com/stretchr/testify/mock"
-
 	wire "github.com/mk6i/retro-aim-server/wire"
+	mock "github.com/stretchr/testify/mock"
 )
 
 // mockResponseWriter is an autogenerated mock type for the ResponseWriter type

+ 1 - 1
server/oscar/handler/mock_stats_service_test.go → server/oscar/mock_stats_service_test.go

@@ -1,6 +1,6 @@
 // Code generated by mockery v2.53.3. DO NOT EDIT.
 
-package handler
+package oscar
 
 import (
 	context "context"

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

@@ -1,6 +1,6 @@
 // Code generated by mockery v2.53.3. DO NOT EDIT.
 
-package handler
+package oscar
 
 import (
 	context "context"

+ 0 - 73
server/oscar/router.go

@@ -1,73 +0,0 @@
-package oscar
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"io"
-
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-)
-
-// ErrRouteNotFound is an error that indicates a failure to find a matching
-// route for an OSCAR protocol request.
-var ErrRouteNotFound = errors.New("route not found")
-
-// ResponseWriter is the interface for sending a SNAC response to the client
-// from the server handlers.
-type ResponseWriter interface {
-	SendSNAC(frame wire.SNACFrame, body any) error
-}
-
-// Handler defines an interface for routing and processing OSCAR protocol
-// requests based on their food group categorization. Implementers of this
-// interface should provide logic to handle incoming requests, perform
-// necessary operations, and possibly generate responses.
-type Handler interface {
-	Handle(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error
-}
-
-// NewRouter creates a new Router instance.
-func NewRouter() Router {
-	return Router{
-		entries: make(map[uint16]map[uint16]Handler),
-	}
-}
-
-// Router defines a structure for routing OSCAR protocol requests to
-// appropriate handlers based on group:subGroup identifiers.
-type Router struct {
-	entries map[uint16]map[uint16]Handler
-}
-
-// HandlerFunc defines a function type that implements the Handler interface.
-// This allows using simple functions as handlers for processing OSCAR requests.
-type HandlerFunc func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error
-
-// Handle executes the HandlerFunc, facilitating the Handler interface implementation.
-func (f HandlerFunc) Handle(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
-	return f(ctx, sess, inFrame, r, rw)
-}
-
-// Register adds a new route to the router by associating a HandlerFunc with a
-// specific group:subGroup pair. If a handler is already registered for the
-// group:subGroup pair, it will be overwritten with the new handler.
-func (rt Router) Register(group uint16, subGroup uint16, fn HandlerFunc) {
-	if _, ok := rt.entries[group]; !ok {
-		rt.entries[group] = make(map[uint16]Handler)
-	}
-	rt.entries[group][subGroup] = fn
-}
-
-// Handle directs an incoming OSCAR request to the appropriate handler based on
-// its group and subGroup identifiers found in the SNAC frame. It returns an
-// ErrRouteNotFound error if no matching handler is found for the group:subGroup
-// pair in the request.
-func (rt Router) Handle(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
-	h, ok := rt.entries[inFrame.FoodGroup][inFrame.SubGroup]
-	if !ok {
-		return fmt.Errorf("%w. group: %d subgroup: %d", ErrRouteNotFound, inFrame.FoodGroup, inFrame.SubGroup)
-	}
-	return h.Handle(ctx, sess, inFrame, r, rw)
-}

+ 0 - 57
server/oscar/router_test.go

@@ -1,57 +0,0 @@
-package oscar
-
-import (
-	"context"
-	"io"
-	"testing"
-
-	"github.com/mk6i/retro-aim-server/state"
-	"github.com/mk6i/retro-aim-server/wire"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func TestRouter_OK(t *testing.T) {
-	r := NewRouter()
-
-	var called bool
-	r.Register(wire.OService, wire.OServiceServiceRequest, func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
-		called = true
-		return nil
-	})
-
-	frame := wire.SNACFrame{
-		FoodGroup: wire.OService,
-		SubGroup:  wire.OServiceServiceRequest,
-	}
-	assert.NoError(t, r.Handle(nil, nil, frame, nil, nil))
-	assert.True(t, called)
-}
-
-func TestRouter_Err(t *testing.T) {
-	r := NewRouter()
-
-	r.Register(wire.OService, wire.OServiceServiceRequest, func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
-		return io.EOF
-	})
-
-	frame := wire.SNACFrame{
-		FoodGroup: wire.OService,
-		SubGroup:  wire.OServiceServiceRequest,
-	}
-	assert.ErrorIs(t, r.Handle(nil, nil, frame, nil, nil), io.EOF)
-}
-
-func TestRouter_ErrRouteNotFound(t *testing.T) {
-	r := NewRouter()
-
-	r.Register(wire.OService, wire.OServiceServiceRequest, func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
-		return nil
-	})
-
-	frame := wire.SNACFrame{
-		FoodGroup: wire.ICBM,
-		SubGroup:  wire.ICBMChannelMsgToClient,
-	}
-	assert.ErrorIs(t, r.Handle(nil, nil, frame, nil, nil), ErrRouteNotFound)
-}

+ 645 - 0
server/oscar/server.go

@@ -0,0 +1,645 @@
+package oscar
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"net"
+	"net/netip"
+	"sync"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/patrickmn/go-cache"
+	"golang.org/x/time/rate"
+
+	"github.com/mk6i/retro-aim-server/config"
+	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+func NewServer(
+	authService AuthService,
+	buddyListRegistry BuddyListRegistry,
+	chatSessionManager *state.InMemoryChatSessionManager,
+	departureNotifier DepartureNotifier,
+	logger *slog.Logger,
+	onlineNotifier OnlineNotifier,
+	SNACHandler func(ctx context.Context, serverType uint16, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, advertisedHost string) error,
+	rateLimitUpdater RateLimitUpdater,
+	limits wire.SNACRateLimits,
+	limiter *IPRateLimiter,
+	listenerCfg []config.Listener,
+) *Server {
+	oscarSvc := oscarServer{
+		AuthService:        authService,
+		BuddyListRegistry:  buddyListRegistry,
+		ChatSessionManager: chatSessionManager,
+		DepartureNotifier:  departureNotifier,
+		Logger:             logger,
+		OnlineNotifier:     onlineNotifier,
+		SNACHandler:        SNACHandler,
+		RateLimitUpdater:   rateLimitUpdater,
+		SNACRateLimits:     limits,
+		IPRateLimiter:      limiter,
+	}
+
+	ctx, cancel := context.WithCancel(context.Background())
+
+	return &Server{
+		closed:         make(chan struct{}),
+		conns:          make(map[net.Conn]struct{}),
+		handler:        oscarSvc.routeConnection,
+		listenerCfg:    listenerCfg,
+		logger:         logger,
+		shutdownCancel: cancel,
+		shutdownCtx:    ctx,
+	}
+}
+
+type Server struct {
+	logger *slog.Logger
+
+	listenerCfg []config.Listener
+	listeners   []net.Listener
+
+	connMu sync.Mutex
+	conns  map[net.Conn]struct{}
+
+	connWg   sync.WaitGroup
+	listenWg sync.WaitGroup
+
+	shutdownCtx    context.Context
+	shutdownCancel context.CancelFunc
+	closed         chan struct{}
+
+	handler func(ctx context.Context, conn net.Conn, advertisedHost string) error
+}
+
+func (s *Server) ListenAndServe() error {
+	for _, cfg := range s.listenerCfg {
+		ln, err := net.Listen("tcp", cfg.BOSListenAddress)
+		if err != nil {
+			s.cleanupListeners()
+			s.shutdownCancel()
+			return fmt.Errorf("failed to listen on %s: %w", cfg.BOSListenAddress, err)
+		}
+		s.logger.Info(
+			"starting server",
+			"listen_address",
+			cfg.BOSListenAddress,
+			"advertised_host",
+			cfg.BOSAdvertisedHost,
+		)
+		s.listeners = append(s.listeners, ln)
+		s.listenWg.Add(1)
+		go s.acceptLoop(ln, cfg.BOSAdvertisedHost)
+	}
+
+	<-s.closed // block until Shutdown is called
+	return nil
+}
+
+func (s *Server) Shutdown(ctx context.Context) error {
+	s.logger.Debug("Initiating graceful shutdown...")
+	s.shutdownCancel()
+	s.cleanupListeners()
+
+	// Wait for handlers to complete
+	done := make(chan struct{})
+	go func() {
+		s.connWg.Wait()
+		s.listenWg.Wait()
+		close(done)
+	}()
+
+	select {
+	case <-done:
+		s.logger.Info("shutdown complete")
+	case <-ctx.Done():
+		s.logger.Info("shutdown complete, but connections didn't close cleanly")
+	}
+
+	close(s.closed)
+
+	return nil
+}
+
+func (s *Server) acceptLoop(ln net.Listener, advertisedHost string) {
+	defer s.listenWg.Done()
+
+	for {
+		conn, err := ln.Accept()
+		if err != nil {
+			if errors.Is(err, net.ErrClosed) {
+				return
+			}
+			s.logger.Error("accept error", "err", err.Error())
+			continue
+		}
+
+		// track connection
+		s.connMu.Lock()
+		s.conns[conn] = struct{}{}
+		s.connMu.Unlock()
+
+		s.connWg.Add(1)
+		go s.handleConnection(s.shutdownCtx, conn, advertisedHost)
+	}
+}
+
+func (s *Server) handleConnection(ctx context.Context, conn net.Conn, advertisedHost string) {
+	defer func() {
+		// untrack connections
+		s.connMu.Lock()
+		delete(s.conns, conn)
+		s.connMu.Unlock()
+
+		_ = conn.Close()
+		s.connWg.Done()
+	}()
+	if err := s.handler(ctx, conn, advertisedHost); err != nil {
+		s.logger.InfoContext(ctx, "user session failed", "err", err.Error())
+	}
+}
+
+func (s *Server) cleanupListeners() {
+	for _, ln := range s.listeners {
+		_ = ln.Close()
+	}
+	s.listeners = nil
+}
+
+type oscarServer struct {
+	AuthService
+	BuddyListRegistry
+	ChatSessionManager
+	DepartureNotifier
+	Logger *slog.Logger
+	OnlineNotifier
+	SNACHandler func(ctx context.Context, serverType uint16, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, advertisedHost string) error
+	RateLimitUpdater
+	wire.SNACRateLimits
+	*IPRateLimiter
+}
+
+func (s oscarServer) routeConnection(ctx context.Context, conn net.Conn, advertisedHost string) error {
+	ip, _, err := net.SplitHostPort(conn.RemoteAddr().String())
+	if err != nil {
+		s.Logger.Error("failed to parse remote address", "err", err.Error())
+		return err
+	}
+
+	flapc := wire.NewFlapClient(100, conn, conn)
+
+	if err := flapc.SendSignonFrame(nil); err != nil {
+		return err
+	}
+	flap, err := flapc.ReceiveSignonFrame()
+	if err != nil {
+		return err
+	}
+
+	if flap.HasTag(wire.OServiceTLVTagsLoginCookie) {
+		return s.connectToOSCARService(ctx, flap, flapc, conn, advertisedHost)
+	}
+
+	return s.authenticate(ctx, flap, ip, conn, flapc, advertisedHost)
+}
+
+func (s oscarServer) connectToOSCARService(
+	ctx context.Context,
+	flap wire.FLAPSignonFrame,
+	flapc *wire.FlapClient,
+	conn net.Conn,
+	advertisedHost string,
+) error {
+	authCookie, ok := flap.Bytes(wire.OServiceTLVTagsLoginCookie)
+	if !ok {
+		return errors.New("unable to get session id from payload")
+	}
+
+	cookie, err := s.CrackCookie(authCookie)
+	if err != nil {
+		return err
+	}
+
+	var sess *state.Session
+	switch cookie.Service {
+	case wire.BOS:
+		sess, err = s.AuthService.RegisterBOSSession(ctx, cookie)
+		if err != nil {
+			return err
+		}
+		if sess == nil {
+			return errors.New("session not found")
+		}
+
+		if err := s.BuddyListRegistry.RegisterBuddyList(ctx, sess.IdentScreenName()); err != nil {
+			return fmt.Errorf("unable to init buddy list: %w", err)
+		}
+
+		defer func() {
+			sess.Close()
+			ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+			defer cancel()
+			if err := s.DepartureNotifier.BroadcastBuddyDeparted(ctx, sess); err != nil {
+				s.Logger.ErrorContext(ctx, "error sending buddy departure notifications", "err", err.Error())
+			}
+			// buddy list must be cleared before session is closed, otherwise
+			// there will be a race condition that could cause the buddy list
+			// be prematurely deleted.
+			if err := s.BuddyListRegistry.UnregisterBuddyList(ctx, sess.IdentScreenName()); err != nil {
+				s.Logger.ErrorContext(ctx, "error removing buddy list entry", "err", err.Error())
+			}
+			s.ChatSessionManager.RemoveUserFromAllChats(sess.IdentScreenName())
+			s.Signout(ctx, sess)
+		}()
+		remoteAddr, ok := ctx.Value("ip").(string)
+		if ok {
+			ip, err := netip.ParseAddrPort(remoteAddr)
+			if err != nil {
+				return errors.New("unable to parse ip addr")
+			}
+			sess.SetRemoteAddr(&ip)
+		}
+
+		go s.receiveSessMessages(ctx, sess, flapc)
+
+	case wire.Chat:
+		sess, err = s.AuthService.RegisterChatSession(ctx, cookie)
+		if err != nil {
+			return err
+		}
+		if sess == nil {
+			return errors.New("session not found")
+		}
+
+		defer func() {
+			ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+			defer cancel()
+			s.SignoutChat(ctx, sess)
+		}()
+
+		go s.receiveSessMessages(ctx, sess, flapc)
+	default:
+		sess, err = s.AuthService.RetrieveBOSSession(ctx, cookie)
+		if err != nil {
+			return err
+		}
+		if sess == nil {
+			return errors.New("session not found")
+		}
+	}
+
+	ctx = context.WithValue(ctx, "screenName", sess.IdentScreenName())
+
+	msg := s.OnlineNotifier.HostOnline(cookie.Service)
+	if err := flapc.SendSNAC(msg.Frame, msg.Body); err != nil {
+		return err
+	}
+
+	return s.dispatchIncomingMessages(ctx, cookie.Service, sess, flapc, conn, advertisedHost)
+}
+
+func (s oscarServer) receiveSessMessages(ctx context.Context, sess *state.Session, flapc *wire.FlapClient) {
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case m := <-sess.ReceiveMessage():
+			// forward a notification sent from another client to this client
+			if err := flapc.SendSNAC(m.Frame, m.Body); err != nil {
+				middleware.LogRequestError(ctx, s.Logger, m.Frame, err)
+			} else {
+				middleware.LogRequest(ctx, s.Logger, m.Frame, m.Body)
+			}
+		}
+	}
+}
+
+func (s oscarServer) authenticate(
+	ctx context.Context,
+	flap wire.FLAPSignonFrame,
+	ip string,
+	conn net.Conn,
+	flapc *wire.FlapClient,
+	advertisedHost string,
+) error {
+	if ok, isBUCP := s.Allow(ip); !ok {
+		s.Logger.Error("user rate limited at login", "remote", ip)
+		tlv := wire.TLVRestBlock{
+			TLVList: []wire.TLV{
+				wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, wire.LoginErrRateLimitExceeded),
+			},
+		}
+		// gives wrong response if you quickly switch between BUCP/FLAP clients
+		if isBUCP {
+			return flapc.SendSNAC(
+				wire.SNACFrame{
+					FoodGroup: wire.BUCP,
+					SubGroup:  wire.BUCPLoginResponse,
+				},
+				wire.SNAC_0x17_0x03_BUCPLoginResponse{
+					TLVRestBlock: tlv,
+				},
+			)
+		} else {
+			return flapc.SendSignoffFrame(tlv)
+		}
+	}
+
+	// auth must complete within the next 30 seconds
+	if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
+		return fmt.Errorf("failed to set deadline: %w", err)
+	}
+
+	// decide whether the client is using BUCP or FLAP authentication based on
+	// the presence of the screen name TLV. this block used to check for the
+	// presence of the roasted password TLV, however that proved an unreliable
+	// indicator of FLAP-auth because older ICQ clients appear to omit the
+	// roasted password TLV when the password is not stored client-side.
+	if _, hasScreenName := flap.Uint16BE(wire.LoginTLVTagsScreenName); hasScreenName {
+		return s.processFLAPAuth(ctx, flap, flapc, advertisedHost)
+	}
+
+	s.SetBUCP(ip)
+
+	return s.processBUCPAuth(ctx, flapc, advertisedHost)
+}
+
+func (s oscarServer) processFLAPAuth(
+	ctx context.Context,
+	signonFrame wire.FLAPSignonFrame,
+	flapc *wire.FlapClient,
+	advertisedHost string,
+) error {
+	tlv, err := s.AuthService.FLAPLogin(ctx, signonFrame, state.NewStubUser, advertisedHost)
+	if err != nil {
+		return err
+	}
+	return flapc.SendSignoffFrame(tlv)
+}
+
+func (s oscarServer) processBUCPAuth(ctx context.Context, flapc *wire.FlapClient, advertisedHost string) error {
+	frames := 0
+
+	for {
+		frame, err := flapc.ReceiveFLAP()
+		if err != nil {
+			return err
+		}
+
+		if frames > 10 {
+			// a lot of frames received, the client is misbehaving
+			return fmt.Errorf("too many auth flap packets received")
+		}
+		frames++
+
+		switch frame.FrameType {
+		case wire.FLAPFrameSignoff:
+			s.Logger.Debug("signed off mid-login")
+			return io.EOF // client disconnected
+		case wire.FLAPFrameKeepAlive:
+			s.Logger.Debug("received flap keepalive frame")
+		case wire.FLAPFrameData:
+			buf := bytes.NewReader(frame.Payload)
+			fr := wire.SNACFrame{}
+			if err := wire.UnmarshalBE(&fr, buf); err != nil {
+				return err
+			}
+			switch {
+			case fr.FoodGroup == wire.BUCP && fr.SubGroup == wire.BUCPChallengeRequest:
+				challengeRequest := wire.SNAC_0x17_0x06_BUCPChallengeRequest{}
+				if err := wire.UnmarshalBE(&challengeRequest, buf); err != nil {
+					return err
+				}
+				outSNAC, err := s.BUCPChallenge(ctx, challengeRequest, uuid.New)
+				if err != nil {
+					return err
+				}
+				if err := flapc.SendSNAC(outSNAC.Frame, outSNAC.Body); err != nil {
+					return err
+				}
+
+				if outSNAC.Frame.SubGroup == wire.BUCPLoginResponse {
+					screenName, _ := challengeRequest.String(wire.LoginTLVTagsScreenName)
+					s.Logger.Debug("failed BUCP challenge: user does not exist", "screen_name", screenName)
+					return nil // account does not exist
+				}
+			case fr.FoodGroup == wire.BUCP && fr.SubGroup == wire.BUCPLoginRequest:
+				loginRequest := wire.SNAC_0x17_0x02_BUCPLoginRequest{}
+				if err := wire.UnmarshalBE(&loginRequest, buf); err != nil {
+					return err
+				}
+				outSNAC, err := s.BUCPLogin(ctx, loginRequest, state.NewStubUser, advertisedHost)
+				if err != nil {
+					return err
+				}
+
+				return flapc.SendSNAC(outSNAC.Frame, outSNAC.Body)
+			default:
+				s.Logger.Debug("unexpected SNAC received during login",
+					"foodgroup", wire.FoodGroupName(fr.FoodGroup),
+					"subgroup", wire.SubGroupName(fr.FoodGroup, fr.SubGroup))
+				return io.EOF
+			}
+		default:
+			s.Logger.Debug("unexpected frame type received during login", "type", frame.FrameType)
+			return io.EOF
+		}
+	}
+}
+
+func sendInvalidSNACErr(frameIn wire.SNACFrame, rw ResponseWriter) error {
+	frameOut := wire.SNACFrame{
+		FoodGroup: frameIn.FoodGroup,
+		SubGroup:  0x01, // error subgroup for all SNACs
+		RequestID: frameIn.RequestID,
+	}
+	bodyOut := wire.SNACError{
+		Code: wire.ErrorCodeInvalidSnac,
+	}
+	return rw.SendSNAC(frameOut, bodyOut)
+}
+
+// dispatchIncomingMessages receives incoming messages and sends them to the
+// appropriate message handler. Messages from the client are sent to the
+// router. Messages relayed from the user session are forwarded to the client.
+// This function ensures that the same sequence number is incremented for both
+// types of messages. The function terminates upon receiving a connection error
+// or when the session closes.
+func (s oscarServer) dispatchIncomingMessages(
+	ctx context.Context,
+	fg uint16,
+	sess *state.Session,
+	flapc *wire.FlapClient,
+	r io.ReadCloser,
+	advertisedHost string,
+) error {
+	defer func() {
+		s.Logger.InfoContext(ctx, "user disconnected")
+	}()
+
+	// buffered so that the go routine has room to exit
+	msgCh := make(chan wire.FLAPFrame, 1)
+	errCh := make(chan error, 1)
+
+	// consume flap frames
+	go func() {
+		defer close(msgCh)
+		defer close(errCh)
+
+		for {
+			frame := wire.FLAPFrame{}
+			if err := wire.UnmarshalBE(&frame, r); err != nil {
+				errCh <- err
+				return
+			}
+			msgCh <- frame
+		}
+	}()
+
+	for {
+		select {
+		case flap, ok := <-msgCh:
+			if !ok {
+				return nil
+			}
+			switch flap.FrameType {
+			case wire.FLAPFrameData:
+				flapBuf := bytes.NewBuffer(flap.Payload)
+
+				inFrame := wire.SNACFrame{}
+				if err := wire.UnmarshalBE(&inFrame, flapBuf); err != nil {
+					return err
+				}
+
+				rateClassID, ok := s.SNACRateLimits.RateClassLookup(inFrame.FoodGroup, inFrame.SubGroup)
+				if ok {
+					if status := sess.EvaluateRateLimit(time.Now(), rateClassID); status == wire.RateLimitStatusLimited {
+						s.Logger.DebugContext(ctx, "rate limit exceeded, dropping SNAC",
+							"foodgroup", wire.FoodGroupName(inFrame.FoodGroup),
+							"subgroup", wire.SubGroupName(inFrame.FoodGroup, inFrame.SubGroup))
+						break
+					}
+				} else {
+					s.Logger.ErrorContext(ctx, "rate limit not found, allowing request through")
+				}
+
+				// route a client request to the appropriate service handler. the
+				// handler may write a response to the client connection.
+				if err := s.SNACHandler(ctx, fg, sess, inFrame, flapBuf, flapc, advertisedHost); err != nil {
+					middleware.LogRequestError(ctx, s.Logger, inFrame, err)
+					if errors.Is(err, ErrRouteNotFound) {
+						if err1 := sendInvalidSNACErr(inFrame, flapc); err1 != nil {
+							return errors.Join(err1, err)
+						}
+						break
+					}
+					return err
+				}
+			case wire.FLAPFrameSignon:
+				return fmt.Errorf("shouldn't get FLAPFrameSignon. flap: %v", flap)
+			case wire.FLAPFrameError:
+				return fmt.Errorf("got FLAPFrameError. flap: %v", flap)
+			case wire.FLAPFrameSignoff:
+				s.Logger.InfoContext(ctx, "got FLAPFrameSignoff", "flap", flap)
+				return nil
+			case wire.FLAPFrameKeepAlive:
+				s.Logger.DebugContext(ctx, "keepalive heartbeat")
+			default:
+				return fmt.Errorf("got unknown FLAP frame type. flap: %v", flap)
+			}
+		case <-time.After(1 * time.Second):
+			updates := s.RateLimitUpdater.RateLimitUpdates(ctx, sess, time.Now())
+			for _, update := range updates {
+				if err := flapc.SendSNAC(update.Frame, update.Body); err != nil {
+					middleware.LogRequestError(ctx, s.Logger, update.Frame, err)
+					return err
+				}
+			}
+		case <-sess.Closed():
+			block := wire.TLVRestBlock{}
+			// error code indicating user signed in a different location
+			block.Append(wire.NewTLVBE(0x0009, wire.OServiceDiscErrNewLogin))
+			// "more info" button
+			block.Append(wire.NewTLVBE(0x000b, "https://github.com/mk6i/retro-aim-server"))
+			if err := flapc.SendSignoffFrame(block); err != nil {
+				return fmt.Errorf("unable to gracefully disconnect user. %w", err)
+			}
+			return nil
+		case <-ctx.Done():
+			// application is shutting down
+			if err := flapc.Disconnect(); err != nil {
+				return fmt.Errorf("unable to gracefully disconnect user. %w", err)
+			}
+			return nil
+		case err := <-errCh:
+			if !errors.Is(io.EOF, err) {
+				s.Logger.ErrorContext(ctx, "client disconnected with error", "err", err)
+			}
+			return nil
+		}
+	}
+}
+
+// IPRateLimiter enforces a per-IP rate limit using a token bucket algorithm.
+// It caches individual rate limiters by IP address and supports tagging requests
+// as originating from the BUCP or FLAP auth.
+//
+// The limiter uses an in-memory cache with TTL expiration, so rate limits reset
+// after the TTL if no activity is observed for a given IP.
+type IPRateLimiter struct {
+	cache *cache.Cache // In-memory cache mapping IPs to rate limiters with optional BUCP tag
+	rate  rate.Limit   // Requests allowed per second
+	burst int          // Maximum burst size allowed
+}
+
+type rateLimitEntry struct {
+	isBUCP  bool
+	limiter *rate.Limiter
+}
+
+// NewIPRateLimiter initializes a new IPRateLimiter with the specified rate,
+// burst size, and TTL for each IP's limiter. Entries expire after 2×TTL.
+func NewIPRateLimiter(rate rate.Limit, burst int, ttl time.Duration) *IPRateLimiter {
+	return &IPRateLimiter{
+		cache: cache.New(ttl, 2*ttl),
+		rate:  rate,
+		burst: burst,
+	}
+}
+
+// SetBUCP marks the rate limiter for the given IP as originating from BUCP auth
+// (default FLAP auth).
+func (l *IPRateLimiter) SetBUCP(ip string) {
+	limiter, found := l.cache.Get(ip)
+	if !found {
+		limiter = &rateLimitEntry{
+			isBUCP:  true,
+			limiter: rate.NewLimiter(l.rate, l.burst),
+		}
+		l.cache.Set(ip, limiter, cache.DefaultExpiration)
+	}
+	limiter.(*rateLimitEntry).isBUCP = true
+}
+
+// Allow checks if a request from the given IP is allowed under its rate limit.
+// It returns whether the request is allowed and whether the connection uses
+// BUCP auth.
+func (l *IPRateLimiter) Allow(ip string) (allowed bool, isBUCP bool) {
+	limiter, found := l.cache.Get(ip)
+	if !found {
+		limiter = &rateLimitEntry{
+			limiter: rate.NewLimiter(l.rate, l.burst),
+		}
+		l.cache.Set(ip, limiter, cache.DefaultExpiration)
+	}
+	entry := limiter.(*rateLimitEntry)
+	return entry.limiter.Allow(), entry.isBUCP
+}

+ 601 - 0
server/oscar/server_test.go

@@ -0,0 +1,601 @@
+package oscar
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	"io"
+	"log/slog"
+	"net"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/config"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"golang.org/x/time/rate"
+)
+
+func TestServer_ListenAndServeAndShutdown(t *testing.T) {
+	var mu sync.Mutex
+	var received []string
+
+	var msgWg sync.WaitGroup
+
+	cfg := []config.Listener{
+		{
+			BOSListenAddress:  ":1000",
+			BOSAdvertisedHost: "localhost",
+		},
+		{
+			BOSListenAddress:  ":2000",
+			BOSAdvertisedHost: "localhost",
+		},
+		{
+			BOSListenAddress:  ":3000",
+			BOSAdvertisedHost: "localhost",
+		},
+	}
+	responses := []string{"hello1", "hello2", "hello2"}
+
+	server := NewServer(
+		nil,
+		nil,
+		nil,
+		nil,
+		slog.Default(),
+		nil,
+		nil,
+		nil,
+		wire.DefaultSNACRateLimits(),
+		nil,
+		cfg,
+	)
+
+	server.handler = func(ctx context.Context, conn net.Conn, advertisedHost string) error {
+		go func() {
+			<-ctx.Done()
+			_ = conn.Close()
+		}()
+		for {
+			r := bufio.NewReader(conn)
+			line, err := r.ReadString('\n')
+			if err != nil {
+				break
+			}
+			mu.Lock()
+			received = append(received, strings.TrimSpace(line))
+			mu.Unlock()
+			msgWg.Done()
+		}
+		return nil
+	}
+	server.shutdownCtx, server.shutdownCancel = context.WithCancel(context.Background())
+
+	shutdownCh := make(chan struct{})
+	go func() {
+		defer close(shutdownCh)
+		assert.NoError(t, server.ListenAndServe())
+	}()
+
+	for i := 0; i < len(cfg); i++ {
+		msgWg.Add(1)
+		// Connect and send message
+		conn, err := net.Dial("tcp", "localhost"+cfg[i].BOSListenAddress)
+		assert.NoError(t, err)
+
+		_, err = conn.Write([]byte(responses[i] + "\n"))
+		assert.NoError(t, err)
+	}
+
+	msgWg.Wait()
+
+	// Shutdown
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	err := server.Shutdown(ctx)
+	assert.NoError(t, err)
+
+	<-shutdownCh
+
+	// Check what was received
+	mu.Lock()
+	defer mu.Unlock()
+	assert.ElementsMatch(t, received, responses)
+}
+
+type fakeConn struct {
+	net.Conn // embed the real connection
+	local    net.Addr
+	remote   net.Addr
+}
+
+func (f fakeConn) RemoteAddr() net.Addr { return f.remote }
+
+func TestOscarServer_RouteConnection_Auth_BUCP(t *testing.T) {
+	serverConn, clientConn := net.Pipe()
+	addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8080")
+	assert.NoError(t, err)
+
+	clientFake := fakeConn{
+		Conn:   serverConn,
+		local:  addr,
+		remote: addr,
+	}
+
+	go func() {
+		defer func() {
+			_ = clientConn.Close()
+		}()
+
+		// < receive FLAPSignonFrame
+		flap := wire.FLAPFrame{}
+		assert.NoError(t, wire.UnmarshalBE(&flap, clientConn))
+		flapSignonFrame := wire.FLAPSignonFrame{}
+		assert.NoError(t, wire.UnmarshalBE(&flapSignonFrame, bytes.NewBuffer(flap.Payload)))
+
+		// > send FLAPSignonFrame
+		flapSignonFrame = wire.FLAPSignonFrame{
+			FLAPVersion: 1,
+		}
+		buf := &bytes.Buffer{}
+		assert.NoError(t, wire.MarshalBE(flapSignonFrame, buf))
+		flap = wire.FLAPFrame{
+			StartMarker: 42,
+			FrameType:   wire.FLAPFrameSignon,
+			Payload:     buf.Bytes(),
+		}
+		assert.NoError(t, wire.MarshalBE(flap, clientConn))
+
+		// > send SNAC_0x17_0x06_BUCPChallengeRequest
+		flapc := wire.NewFlapClient(0, clientConn, clientConn)
+		frame := wire.SNACFrame{
+			FoodGroup: wire.BUCP,
+			SubGroup:  wire.BUCPChallengeRequest,
+		}
+		bodyIn := wire.SNAC_0x17_0x06_BUCPChallengeRequest{}
+		assert.NoError(t, flapc.SendSNAC(frame, bodyIn))
+
+		// < receive SNAC_0x17_0x07_BUCPChallengeResponse
+		frame = wire.SNACFrame{}
+		assert.NoError(t, flapc.ReceiveSNAC(&frame, &wire.SNAC_0x17_0x07_BUCPChallengeResponse{}))
+		assert.Equal(t, wire.SNACFrame{FoodGroup: wire.BUCP, SubGroup: wire.BUCPChallengeResponse}, frame)
+
+		// > send keep alive frame (like BSFlite does mid-login)
+		assert.NoError(t, flapc.SendKeepAliveFrame())
+
+		// > send SNAC_0x17_0x02_BUCPLoginRequest
+		frame = wire.SNACFrame{
+			FoodGroup: wire.BUCP,
+			SubGroup:  wire.BUCPLoginRequest,
+		}
+		assert.NoError(t, flapc.SendSNAC(frame, wire.SNAC_0x17_0x02_BUCPLoginRequest{}))
+
+		// < receive SNAC_0x17_0x03_BUCPLoginResponse
+		frame = wire.SNACFrame{}
+		assert.NoError(t, flapc.ReceiveSNAC(&frame, &wire.SNAC_0x17_0x03_BUCPLoginResponse{}))
+		assert.Equal(t, wire.SNACFrame{FoodGroup: wire.BUCP, SubGroup: wire.BUCPLoginResponse}, frame)
+	}()
+
+	wg := &sync.WaitGroup{}
+
+	authService := newMockAuthService(t)
+	authService.EXPECT().
+		BUCPChallenge(matchContext(), mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.BUCP,
+				SubGroup:  wire.BUCPChallengeResponse,
+			},
+			Body: wire.SNAC_0x17_0x07_BUCPChallengeResponse{},
+		}, nil)
+	authService.EXPECT().
+		BUCPLogin(matchContext(), mock.Anything, mock.Anything, "localhost:5190").
+		Return(wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.BUCP,
+				SubGroup:  wire.BUCPLoginResponse,
+			},
+			Body: wire.SNAC_0x17_0x03_BUCPLoginResponse{},
+		}, nil)
+
+	rt := oscarServer{
+		AuthService:   authService,
+		Logger:        slog.Default(),
+		IPRateLimiter: NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
+	}
+	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, "localhost:5190"))
+
+	wg.Wait()
+}
+
+func TestOscarServer_RouteConnection_Auth_FLAP(t *testing.T) {
+	serverConn, clientConn := net.Pipe()
+	addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8080")
+	assert.NoError(t, err)
+
+	clientFake := fakeConn{
+		Conn:   serverConn,
+		local:  addr,
+		remote: addr,
+	}
+
+	go func() {
+		defer func() {
+			_ = clientConn.Close()
+		}()
+
+		// < receive FLAPSignonFrame
+		flap := wire.FLAPFrame{}
+		assert.NoError(t, wire.UnmarshalBE(&flap, clientConn))
+		flapSignonFrame := wire.FLAPSignonFrame{}
+		assert.NoError(t, wire.UnmarshalBE(&flapSignonFrame, bytes.NewBuffer(flap.Payload)))
+
+		// > send FLAPSignonFrame with screen name TLV (indicates FLAP auth)
+		flapSignonFrame = wire.FLAPSignonFrame{
+			FLAPVersion: 1,
+		}
+		// Add screen name TLV to indicate FLAP authentication
+		flapSignonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsScreenName, "testuser"))
+		// Add password hash TLV for authentication
+		flapSignonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsPasswordHash, []byte("password_hash")))
+		// Add client identity TLV
+		flapSignonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsClientIdentity, "ICQ 2000b"))
+
+		buf := &bytes.Buffer{}
+		assert.NoError(t, wire.MarshalBE(flapSignonFrame, buf))
+		flap = wire.FLAPFrame{
+			StartMarker: 42,
+			FrameType:   wire.FLAPFrameSignon,
+			Payload:     buf.Bytes(),
+		}
+		assert.NoError(t, wire.MarshalBE(flap, clientConn))
+
+		// < receive FLAPSignoffFrame with authentication result
+		flap = wire.FLAPFrame{}
+		assert.NoError(t, wire.UnmarshalBE(&flap, clientConn))
+		assert.Equal(t, wire.FLAPFrameSignoff, flap.FrameType)
+
+		// Parse the signoff frame payload to verify authentication response
+		signoffTLVs := wire.TLVRestBlock{}
+		assert.NoError(t, wire.UnmarshalBE(&signoffTLVs, bytes.NewBuffer(flap.Payload)))
+	}()
+
+	wg := &sync.WaitGroup{}
+
+	authService := newMockAuthService(t)
+	authService.EXPECT().
+		FLAPLogin(matchContext(), mock.Anything, mock.Anything, "localhost:5190").
+		Return(wire.TLVRestBlock{
+			TLVList: []wire.TLV{
+				wire.NewTLVBE(wire.LoginTLVTagsScreenName, "testuser"),
+				wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, "localhost:5190"),
+				wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, []byte("auth-cookie")),
+			},
+		}, nil)
+
+	rt := oscarServer{
+		AuthService:   authService,
+		Logger:        slog.Default(),
+		IPRateLimiter: NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
+	}
+	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, "localhost:5190"))
+
+	wg.Wait()
+}
+
+func TestOscarServer_RouteConnection_BOS(t *testing.T) {
+	sess := state.NewSession()
+
+	clientConn, serverConn := net.Pipe()
+	addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8080")
+	assert.NoError(t, err)
+
+	clientFake := fakeConn{
+		Conn:   serverConn,
+		local:  addr,
+		remote: addr,
+	}
+
+	go func() {
+		// < receive FLAPSignonFrame
+		flap := wire.FLAPFrame{}
+		assert.NoError(t, wire.UnmarshalBE(&flap, clientConn))
+		flapSignonFrame := wire.FLAPSignonFrame{}
+		assert.NoError(t, wire.UnmarshalBE(&flapSignonFrame, bytes.NewBuffer(flap.Payload)))
+
+		// > send FLAPSignonFrame
+		flapSignonFrame = wire.FLAPSignonFrame{
+			FLAPVersion: 1,
+		}
+		flapSignonFrame.Append(wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("the-cookie")))
+		buf := &bytes.Buffer{}
+		assert.NoError(t, wire.MarshalBE(flapSignonFrame, buf))
+		flap = wire.FLAPFrame{
+			StartMarker: 42,
+			FrameType:   wire.FLAPFrameSignon,
+			Payload:     buf.Bytes(),
+		}
+		assert.NoError(t, wire.MarshalBE(flap, clientConn))
+
+		flapc := wire.NewFlapClient(0, clientConn, clientConn)
+
+		// < receive SNAC_0x01_0x03_OServiceHostOnline
+		frame := wire.SNACFrame{}
+		body := wire.SNAC_0x01_0x03_OServiceHostOnline{}
+		assert.NoError(t, flapc.ReceiveSNAC(&frame, &body))
+
+		// send the first request that should get relayed to BOSRouter.Handle
+		frame = wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceClientOnline,
+		}
+		assert.NoError(t, flapc.SendSNAC(frame, struct{}{}))
+		assert.NoError(t, clientConn.Close())
+	}()
+
+	wg := &sync.WaitGroup{}
+
+	authService := newMockAuthService(t)
+	authService.EXPECT().
+		RegisterBOSSession(mock.Anything, state.ServerCookie{Service: wire.BOS}).
+		Return(sess, nil)
+	wg.Add(1)
+	authService.EXPECT().
+		Signout(mock.Anything, sess).
+		Run(func(ctx context.Context, s *state.Session) {
+			defer wg.Done()
+		})
+
+	authService.EXPECT().
+		CrackCookie(mock.Anything).
+		Return(state.ServerCookie{Service: wire.BOS}, nil)
+
+	onlineNotifier := newMockOnlineNotifier(t)
+	onlineNotifier.EXPECT().
+		HostOnline(mock.Anything).
+		Return(wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceHostOnline,
+			},
+			Body: wire.SNAC_0x01_0x03_OServiceHostOnline{},
+		})
+
+	buddyListRegistry := newMockBuddyListRegistry(t)
+	buddyListRegistry.EXPECT().
+		RegisterBuddyList(mock.Anything, mock.Anything).
+		Return(nil)
+	buddyListRegistry.EXPECT().
+		UnregisterBuddyList(mock.Anything, mock.Anything).
+		Return(nil)
+
+	departureNotifier := newMockDepartureNotifier(t)
+	departureNotifier.EXPECT().
+		BroadcastBuddyDeparted(mock.Anything, mock.Anything).
+		Return(nil)
+
+	chatSessionManager := newMockChatSessionManager(t)
+	chatSessionManager.EXPECT().
+		RemoveUserFromAllChats(mock.Anything)
+
+	wg.Add(1)
+	handler := func(ctx context.Context, serverType uint16, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, advertisedHost string) error {
+		defer wg.Done()
+		return nil
+	}
+
+	rt := oscarServer{
+		AuthService:        authService,
+		SNACHandler:        handler,
+		Logger:             slog.Default(),
+		OnlineNotifier:     onlineNotifier,
+		BuddyListRegistry:  buddyListRegistry,
+		ChatSessionManager: chatSessionManager,
+		DepartureNotifier:  departureNotifier,
+	}
+	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, ""))
+
+	wg.Wait()
+}
+
+func TestOscarServer_RouteConnection_Chat(t *testing.T) {
+	sess := state.NewSession()
+
+	clientConn, serverConn := net.Pipe()
+	addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8080")
+	assert.NoError(t, err)
+
+	clientFake := fakeConn{
+		Conn:   serverConn,
+		local:  addr,
+		remote: addr,
+	}
+
+	go func() {
+		// < receive FLAPSignonFrame
+		flap := wire.FLAPFrame{}
+		assert.NoError(t, wire.UnmarshalBE(&flap, clientConn))
+		flapSignonFrame := wire.FLAPSignonFrame{}
+		assert.NoError(t, wire.UnmarshalBE(&flapSignonFrame, bytes.NewBuffer(flap.Payload)))
+
+		// > send FLAPSignonFrame
+		flapSignonFrame = wire.FLAPSignonFrame{
+			FLAPVersion: 1,
+		}
+		flapSignonFrame.Append(wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("the-cookie")))
+		buf := &bytes.Buffer{}
+		assert.NoError(t, wire.MarshalBE(flapSignonFrame, buf))
+		flap = wire.FLAPFrame{
+			StartMarker: 42,
+			FrameType:   wire.FLAPFrameSignon,
+			Payload:     buf.Bytes(),
+		}
+		assert.NoError(t, wire.MarshalBE(flap, clientConn))
+
+		flapc := wire.NewFlapClient(0, clientConn, clientConn)
+
+		// < receive SNAC_0x01_0x03_OServiceHostOnline
+		frame := wire.SNACFrame{}
+		body := wire.SNAC_0x01_0x03_OServiceHostOnline{}
+		assert.NoError(t, flapc.ReceiveSNAC(&frame, &body))
+
+		// send the first request that should get relayed to BOSRouter.Handle
+		frame = wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceClientOnline,
+		}
+		assert.NoError(t, flapc.SendSNAC(frame, struct{}{}))
+		assert.NoError(t, clientConn.Close())
+	}()
+
+	wg := &sync.WaitGroup{}
+
+	authService := newMockAuthService(t)
+	authService.EXPECT().
+		RegisterChatSession(mock.Anything, state.ServerCookie{Service: wire.Chat}).
+		Return(sess, nil)
+	wg.Add(1)
+	authService.EXPECT().
+		SignoutChat(mock.Anything, sess).
+		Run(func(ctx context.Context, s *state.Session) {
+			defer wg.Done()
+		})
+
+	authService.EXPECT().
+		CrackCookie(mock.Anything).
+		Return(state.ServerCookie{Service: wire.Chat}, nil)
+
+	onlineNotifier := newMockOnlineNotifier(t)
+	onlineNotifier.EXPECT().
+		HostOnline(mock.Anything).
+		Return(wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceHostOnline,
+			},
+			Body: wire.SNAC_0x01_0x03_OServiceHostOnline{},
+		})
+
+	buddyListRegistry := newMockBuddyListRegistry(t)
+	departureNotifier := newMockDepartureNotifier(t)
+	chatSessionManager := newMockChatSessionManager(t)
+
+	wg.Add(1)
+	handler := func(ctx context.Context, serverType uint16, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, advertisedHost string) error {
+		defer wg.Done()
+		return nil
+	}
+
+	rt := oscarServer{
+		AuthService:        authService,
+		SNACHandler:        handler,
+		Logger:             slog.Default(),
+		OnlineNotifier:     onlineNotifier,
+		BuddyListRegistry:  buddyListRegistry,
+		ChatSessionManager: chatSessionManager,
+		DepartureNotifier:  departureNotifier,
+	}
+	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, ""))
+
+	wg.Wait()
+}
+
+func TestOscarServer_RouteConnection_Admin(t *testing.T) {
+	sess := state.NewSession()
+
+	clientConn, serverConn := net.Pipe()
+	addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8080")
+	assert.NoError(t, err)
+
+	clientFake := fakeConn{
+		Conn:   serverConn,
+		local:  addr,
+		remote: addr,
+	}
+
+	go func() {
+		// < receive FLAPSignonFrame
+		flap := wire.FLAPFrame{}
+		assert.NoError(t, wire.UnmarshalBE(&flap, clientConn))
+		flapSignonFrame := wire.FLAPSignonFrame{}
+		assert.NoError(t, wire.UnmarshalBE(&flapSignonFrame, bytes.NewBuffer(flap.Payload)))
+
+		// > send FLAPSignonFrame
+		flapSignonFrame = wire.FLAPSignonFrame{
+			FLAPVersion: 1,
+		}
+		flapSignonFrame.Append(wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("the-cookie")))
+		buf := &bytes.Buffer{}
+		assert.NoError(t, wire.MarshalBE(flapSignonFrame, buf))
+		flap = wire.FLAPFrame{
+			StartMarker: 42,
+			FrameType:   wire.FLAPFrameSignon,
+			Payload:     buf.Bytes(),
+		}
+		assert.NoError(t, wire.MarshalBE(flap, clientConn))
+
+		flapc := wire.NewFlapClient(0, clientConn, clientConn)
+
+		// < receive SNAC_0x01_0x03_OServiceHostOnline
+		frame := wire.SNACFrame{}
+		body := wire.SNAC_0x01_0x03_OServiceHostOnline{}
+		assert.NoError(t, flapc.ReceiveSNAC(&frame, &body))
+
+		// send the first request that should get relayed to BOSRouter.Handle
+		frame = wire.SNACFrame{
+			FoodGroup: wire.OService,
+			SubGroup:  wire.OServiceClientOnline,
+		}
+		assert.NoError(t, flapc.SendSNAC(frame, struct{}{}))
+		assert.NoError(t, clientConn.Close())
+	}()
+
+	wg := &sync.WaitGroup{}
+
+	authService := newMockAuthService(t)
+	authService.EXPECT().
+		CrackCookie(mock.Anything).
+		Return(state.ServerCookie{Service: wire.Admin}, nil)
+	authService.EXPECT().
+		RetrieveBOSSession(mock.Anything, state.ServerCookie{Service: wire.Admin}).
+		Return(sess, nil)
+
+	onlineNotifier := newMockOnlineNotifier(t)
+	onlineNotifier.EXPECT().
+		HostOnline(mock.Anything).
+		Return(wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.OService,
+				SubGroup:  wire.OServiceHostOnline,
+			},
+			Body: wire.SNAC_0x01_0x03_OServiceHostOnline{},
+		})
+
+	buddyListRegistry := newMockBuddyListRegistry(t)
+	departureNotifier := newMockDepartureNotifier(t)
+	chatSessionManager := newMockChatSessionManager(t)
+
+	wg.Add(1)
+	handler := func(ctx context.Context, serverType uint16, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter, advertisedHost string) error {
+		defer wg.Done()
+		return nil
+	}
+
+	rt := oscarServer{
+		AuthService:        authService,
+		SNACHandler:        handler,
+		Logger:             slog.Default(),
+		OnlineNotifier:     onlineNotifier,
+		BuddyListRegistry:  buddyListRegistry,
+		ChatSessionManager: chatSessionManager,
+		DepartureNotifier:  departureNotifier,
+	}
+	assert.NoError(t, rt.routeConnection(context.Background(), clientFake, ""))
+
+	wg.Wait()
+}

+ 170 - 0
server/oscar/types.go

@@ -0,0 +1,170 @@
+package oscar
+
+import (
+	"context"
+	"time"
+
+	"github.com/google/uuid"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// OnlineNotifier returns a OServiceHostOnline SNAC that is sent to the client
+// at the beginning of the protocol sequence which lists all food groups
+// managed by the server.
+type OnlineNotifier interface {
+	HostOnline(service uint16) 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 {
+	ClearBuddyListRegistry(ctx context.Context) error
+	RegisterBuddyList(ctx context.Context, user state.IdentScreenName) error
+	UnregisterBuddyList(ctx context.Context, user state.IdentScreenName) error
+}
+
+// DepartureNotifier is the interface for sending buddy departure notifications
+// when a client disconnects.
+type DepartureNotifier interface {
+	BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error
+}
+
+// ChatSessionManager is the interface for closing chat sessions
+// when a client disconnects.
+type ChatSessionManager interface {
+	RemoveUserFromAllChats(user state.IdentScreenName)
+}
+
+// RateLimitUpdater provides rate limit updates for subscribed rate limit classes.
+type RateLimitUpdater interface {
+	RateLimitUpdates(ctx context.Context, sess *state.Session, now time.Time) []wire.SNACMessage
+}
+
+type AuthService interface {
+	BUCPChallenge(ctx context.Context, bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
+	BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), here string) (wire.SNACMessage, error)
+	CrackCookie(authCookie []byte) (state.ServerCookie, error)
+	FLAPLogin(ctx context.Context, frame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), here string) (wire.TLVRestBlock, error)
+	KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error)
+	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.Session, error)
+	RegisterChatSession(ctx context.Context, authCookie state.ServerCookie) (*state.Session, error)
+	RetrieveBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.Session, error)
+	Signout(ctx context.Context, sess *state.Session)
+	SignoutChat(ctx context.Context, sess *state.Session)
+}
+
+type AdminService interface {
+	ConfirmRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame) (wire.SNACMessage, error)
+	InfoQuery(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x02_AdminInfoQuery) (wire.SNACMessage, error)
+	InfoChangeRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x04_AdminInfoChangeRequest) (wire.SNACMessage, error)
+}
+
+type BARTService interface {
+	UpsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x02_BARTUploadQuery) (wire.SNACMessage, error)
+	RetrieveItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error)
+}
+
+type BuddyService interface {
+	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+	AddBuddies(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x04_BuddyAddBuddies) error
+	DelBuddies(_ context.Context, sess *state.Session, inBody wire.SNAC_0x03_0x05_BuddyDelBuddies) error
+}
+
+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 FeedbagService interface {
+	DeleteItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (wire.SNACMessage, error)
+	Query(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame) (wire.SNACMessage, error)
+	QueryIfModified(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x05_FeedbagQueryIfModified) (wire.SNACMessage, error)
+	RespondAuthorizeToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x1A_FeedbagRespondAuthorizeToHost) error
+	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+	StartCluster(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x11_FeedbagStartCluster)
+	UpsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, items []wire.FeedbagItem) (wire.SNACMessage, error)
+	Use(ctx context.Context, sess *state.Session) 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 ICQService interface {
+	DeleteMsgReq(ctx context.Context, sess *state.Session, seq uint16) error
+	FindByICQName(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0515_DBQueryMetaReqSearchByDetails, seq uint16) error
+	FindByICQEmail(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0529_DBQueryMetaReqSearchByEmail, seq uint16) error
+	FindByEmail3(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0573_DBQueryMetaReqSearchByEmail3, seq uint16) error
+	FindByICQInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0533_DBQueryMetaReqSearchWhitePages, seq uint16) error
+	FindByUIN(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error
+	FindByUIN2(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0569_DBQueryMetaReqSearchByUIN2, seq uint16) error
+	FindByWhitePages2(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x055F_DBQueryMetaReqSearchWhitePages2, seq uint16) error
+	FullUserInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x051F_DBQueryMetaReqSearchByUIN, seq uint16) error
+	OfflineMsgReq(ctx context.Context, sess *state.Session, seq uint16) error
+	SetAffiliations(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x041A_DBQueryMetaReqSetAffiliations, seq uint16) error
+	SetBasicInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03EA_DBQueryMetaReqSetBasicInfo, seq uint16) error
+	SetEmails(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x040B_DBQueryMetaReqSetEmails, seq uint16) error
+	SetInterests(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0410_DBQueryMetaReqSetInterests, seq uint16) error
+	SetMoreInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03FD_DBQueryMetaReqSetMoreInfo, seq uint16) error
+	SetPermissions(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0424_DBQueryMetaReqSetPermissions, seq uint16) error
+	SetUserNotes(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0406_DBQueryMetaReqSetNotes, seq uint16) error
+	SetWorkInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x03F3_DBQueryMetaReqSetWorkInfo, seq uint16) error
+	ShortUserInfo(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x04BA_DBQueryMetaReqShortInfo, seq uint16) error
+	XMLReqData(ctx context.Context, sess *state.Session, req wire.ICQ_0x07D0_0x0898_DBQueryMetaReqXMLReq, seq uint16) error
+}
+
+type LocateService interface {
+	DirInfo(ctx context.Context, frame wire.SNACFrame, body wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error)
+	RightsQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
+	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
+	SetKeywordInfo(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, body wire.SNAC_0x02_0x0F_LocateSetKeywordInfo) (wire.SNACMessage, error)
+	UserInfoQuery(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error)
+}
+
+type ODirService interface {
+	InfoQuery(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)
+	KeywordListQuery(context.Context, wire.SNACFrame) (wire.SNACMessage, error)
+}
+
+type OServiceService interface {
+	ClientOnline(ctx context.Context, service uint16, bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error
+	ClientVersions(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x17_OServiceClientVersions) wire.SNACMessage
+	HostOnline(service uint16) wire.SNACMessage
+	IdleNotification(ctx context.Context, sess *state.Session, bodyIn wire.SNAC_0x01_0x11_OServiceIdleNotification) error
+	RateParamsQuery(ctx context.Context, sess *state.Session, frame wire.SNACFrame) wire.SNACMessage
+	RateParamsSubAdd(context.Context, *state.Session, wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd)
+	ServiceRequest(ctx context.Context, service uint16, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest, advertisedHost string) (wire.SNACMessage, error)
+	SetPrivacyFlags(ctx context.Context, bodyIn wire.SNAC_0x01_0x14_OServiceSetPrivacyFlags)
+	SetUserInfoFields(ctx context.Context, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (wire.SNACMessage, error)
+	UserInfoQuery(ctx context.Context, sess *state.Session, frame wire.SNACFrame) wire.SNACMessage
+}
+
+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
+}
+
+type UserLookupService interface {
+	FindByEmail(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0A_0x02_UserLookupFindByEmail) (wire.SNACMessage, error)
+}
+
+type StatsService interface {
+	ReportEvents(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0B_0x03_StatsReportEvents) wire.SNACMessage
+}

+ 35 - 27
server/toc/cmd_client.go

@@ -114,23 +114,22 @@ func (c *ChatRegistry) Sessions() []*state.Session {
 //   - Receives incoming messages from the OSCAR server and translates them into
 //     TOC responses for the client.
 type OSCARProxy struct {
-	AdminService        AdminService
-	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
-	SNACRateLimits      wire.SNACRateLimits
-	HTTPIPRateLimiter   *IPRateLimiter
+	AdminService      AdminService
+	AuthService       AuthService
+	BuddyListRegistry BuddyListRegistry
+	BuddyService      BuddyService
+	ChatNavService    ChatNavService
+	ChatService       ChatService
+	CookieBaker       CookieBaker
+	DirSearchService  DirSearchService
+	ICBMService       ICBMService
+	LocateService     LocateService
+	Logger            *slog.Logger
+	OServiceService   OServiceService
+	PermitDenyService PermitDenyService
+	TOCConfigStore    TOCConfigStore
+	SNACRateLimits    wire.SNACRateLimits
+	HTTPIPRateLimiter *IPRateLimiter
 }
 
 // RecvClientCmd processes a client TOC command and returns a server reply.
@@ -473,7 +472,7 @@ func (s OSCARProxy) ChatAccept(
 			},
 		},
 	}
-	svcReqReply, err := s.OServiceServiceBOS.ServiceRequest(ctx, me, wire.SNACFrame{}, svcReqSNAC)
+	svcReqReply, err := s.OServiceService.ServiceRequest(ctx, wire.BOS, me, wire.SNACFrame{}, svcReqSNAC, "")
 	if err != nil {
 		return 0, s.runtimeErr(ctx, fmt.Errorf("OServiceServiceBOS.ServiceRequest: %w", err))
 	}
@@ -491,7 +490,10 @@ func (s OSCARProxy) ChatAccept(
 		return 0, s.runtimeErr(ctx, errors.New("missing wire.OServiceTLVTagsLoginCookie"))
 	}
 
-	chatSess, err := s.AuthService.RegisterChatSession(ctx, loginCookie)
+	// todo: naming for cookie: login cookie, server cookie, or auth cookie?
+	serverCookie, err := s.AuthService.CrackCookie(loginCookie)
+
+	chatSess, err := s.AuthService.RegisterChatSession(ctx, serverCookie)
 	if err != nil {
 		return 0, s.runtimeErr(ctx, fmt.Errorf("AuthService.RegisterChatSession: %w", err))
 	}
@@ -500,7 +502,7 @@ func (s OSCARProxy) ChatAccept(
 		return 0, msg
 	}
 
-	if err := s.OServiceServiceChat.ClientOnline(ctx, wire.SNAC_0x01_0x02_OServiceClientOnline{}, chatSess); err != nil {
+	if err := s.OServiceService.ClientOnline(ctx, wire.Chat, wire.SNAC_0x01_0x02_OServiceClientOnline{}, chatSess); err != nil {
 		return 0, s.runtimeErr(ctx, fmt.Errorf("OServiceServiceChat.ClientOnline: %w", err))
 	}
 
@@ -653,7 +655,7 @@ func (s OSCARProxy) ChatJoin(
 			},
 		},
 	}
-	svcReqReply, err := s.OServiceServiceBOS.ServiceRequest(ctx, me, wire.SNACFrame{}, svcReqSNAC)
+	svcReqReply, err := s.OServiceService.ServiceRequest(ctx, wire.BOS, me, wire.SNACFrame{}, svcReqSNAC, "")
 	if err != nil {
 		return 0, s.runtimeErr(ctx, fmt.Errorf("OServiceServiceBOS.ServiceRequest: %w", err))
 	}
@@ -671,7 +673,10 @@ func (s OSCARProxy) ChatJoin(
 		return 0, s.runtimeErr(ctx, errors.New("svcReqReplyBody.Bytes: missing wire.OServiceTLVTagsLoginCookie"))
 	}
 
-	chatSess, err := s.AuthService.RegisterChatSession(ctx, loginCookie)
+	// todo: naming for cookie: login cookie, server cookie, or auth cookie?
+	serverCookie, err := s.AuthService.CrackCookie(loginCookie)
+
+	chatSess, err := s.AuthService.RegisterChatSession(ctx, serverCookie)
 	if err != nil {
 		return 0, s.runtimeErr(ctx, fmt.Errorf("AuthService.RegisterChatSession: %w", err))
 	}
@@ -680,7 +685,7 @@ func (s OSCARProxy) ChatJoin(
 		return 0, msg
 	}
 
-	if err := s.OServiceServiceChat.ClientOnline(ctx, wire.SNAC_0x01_0x02_OServiceClientOnline{}, chatSess); err != nil {
+	if err := s.OServiceService.ClientOnline(ctx, wire.Chat, wire.SNAC_0x01_0x02_OServiceClientOnline{}, chatSess); err != nil {
 		return 0, s.runtimeErr(ctx, fmt.Errorf("OServiceServiceChat.ClientOnline: %w", err))
 	}
 
@@ -1159,7 +1164,7 @@ func (s OSCARProxy) InitDone(ctx context.Context, sess *state.Session) string {
 	if errMsg, isLimited := s.checkRateLimit(ctx, sess, wire.OService, wire.OServiceClientOnline); isLimited {
 		return errMsg
 	}
-	if err := s.OServiceServiceBOS.ClientOnline(ctx, wire.SNAC_0x01_0x02_OServiceClientOnline{}, sess); err != nil {
+	if err := s.OServiceService.ClientOnline(ctx, wire.BOS, wire.SNAC_0x01_0x02_OServiceClientOnline{}, sess); err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("OServiceServiceBOS.ClientOnliney: %w", err))
 	}
 	return ""
@@ -1590,7 +1595,7 @@ func (s OSCARProxy) SetIdle(ctx context.Context, me *state.Session, args []byte)
 	snac := wire.SNAC_0x01_0x11_OServiceIdleNotification{
 		IdleTime: uint32(time),
 	}
-	if err := s.OServiceServiceBOS.IdleNotification(ctx, me, snac); err != nil {
+	if err := s.OServiceService.IdleNotification(ctx, me, snac); err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("OServiceServiceBOS.IdleNotification: %w", err))
 	}
 
@@ -1668,7 +1673,7 @@ func (s OSCARProxy) Signon(ctx context.Context, args []byte) (*state.Session, []
 	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsScreenName, userName))
 	signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsRoastedTOCPassword, passwordHash))
 
-	block, err := s.AuthService.FLAPLogin(ctx, signonFrame, state.NewStubUser)
+	block, err := s.AuthService.FLAPLogin(ctx, signonFrame, state.NewStubUser, "")
 	if err != nil {
 		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("AuthService.FLAPLogin: %w", err))}
 	}
@@ -1683,7 +1688,10 @@ func (s OSCARProxy) Signon(ctx context.Context, args []byte) (*state.Session, []
 		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("unable to get session id from payload"))}
 	}
 
-	sess, err := s.AuthService.RegisterBOSSession(ctx, authCookie)
+	// todo: naming for cookie: login cookie, server cookie, or auth cookie?
+	serverCookie, err := s.AuthService.CrackCookie(authCookie)
+
+	sess, err := s.AuthService.RegisterBOSSession(ctx, serverCookie)
 	if err != nil {
 		return nil, []string{s.runtimeErr(ctx, fmt.Errorf("AuthService.RegisterBOSSession: %w", err))}
 	}

+ 429 - 251
server/toc/cmd_client_test.go

@@ -537,104 +537,40 @@ func TestOSCARProxy_RecvClientCmd_FormatNickname(t *testing.T) {
 }
 
 func TestOSCARProxy_RecvClientCmd_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{
+	navInfo := 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(0x01, wire.SNAC_0x01_0x04_TLVRoomInfo{
-									Cookie: "the-cookie",
-								}),
+								wire.NewTLVBE(wire.ChatRoomTLVRoomName, "cool room"),
 							},
 						},
-					},
+					}),
 				},
 			},
-		}
-		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"),
-				},
+	svcReq := 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.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"),
+	svcResp := wire.SNACMessage{
+		Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, "chat-auth-cookie"),
 				},
 			},
-		}
-		if err != nil {
-			ret.clientOnlineParams[0].err = err
-		}
-		return ret
+		},
 	}
 
 	cases := []struct {
@@ -669,16 +605,53 @@ func TestOSCARProxy_RecvClientCmd_ChatAccept(t *testing.T) {
 				return reg
 			}(),
 			mockParams: mockParams{
-				chatNavParams:      fnNewChatNavParams(nil),
-				oServiceBOSParams:  fnNewOServiceBOSParams(nil),
-				authParams:         fnNewAuthParams(nil),
-				oServiceChatParams: fnNewOServiceChatParams(nil),
+				chatNavParams: chatNavParams{
+					requestRoomInfoParams: requestRoomInfoParams{
+						{
+							inBody: wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
+								Cookie:         "the-cookie",
+								Exchange:       4,
+								InstanceNumber: 0,
+							},
+							msg: navInfo,
+						},
+					},
+				},
+				oServiceParams: oServiceParams{
+					serviceRequestParams: serviceRequestParams{
+						{
+							me:     state.NewIdentScreenName("me"),
+							bodyIn: svcReq,
+							msg:    svcResp,
+						},
+					},
+					clientOnlineParams: clientOnlineParams{
+						{
+							body: wire.SNAC_0x01_0x02_OServiceClientOnline{},
+							me:   state.NewIdentScreenName("me"),
+						},
+					},
+				},
+				authParams: authParams{
+					crackCookieParams: crackCookieParams{
+						{
+							cookieIn:  []byte("chat-auth-cookie"),
+							cookieOut: state.ServerCookie{ChatCookie: "chat-auth-cookie"},
+						},
+					},
+					registerChatSessionParams: registerChatSessionParams{
+						{
+							authCookie: state.ServerCookie{ChatCookie: "chat-auth-cookie"},
+							sess:       newTestSession("me"),
+						},
+					},
+				},
 			},
 			wantMsg:           "CHAT_JOIN:0:cool room",
 			expectChatSession: true,
 		},
 		{
-			name:     "accept chat, receive error from chat oservice svc",
+			name:     "accept chat, receive error from client online",
 			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_chat_accept 0`),
 			givenChatRegistry: func() *ChatRegistry {
@@ -691,16 +664,54 @@ func TestOSCARProxy_RecvClientCmd_ChatAccept(t *testing.T) {
 				return reg
 			}(),
 			mockParams: mockParams{
-				chatNavParams:      fnNewChatNavParams(nil),
-				oServiceBOSParams:  fnNewOServiceBOSParams(nil),
-				authParams:         fnNewAuthParams(nil),
-				oServiceChatParams: fnNewOServiceChatParams(io.EOF),
+				chatNavParams: chatNavParams{
+					requestRoomInfoParams: requestRoomInfoParams{
+						{
+							inBody: wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
+								Cookie:         "the-cookie",
+								Exchange:       4,
+								InstanceNumber: 0,
+							},
+							msg: navInfo,
+						},
+					},
+				},
+				oServiceParams: oServiceParams{
+					serviceRequestParams: serviceRequestParams{
+						{
+							me:     state.NewIdentScreenName("me"),
+							bodyIn: svcReq,
+							msg:    svcResp,
+						},
+					},
+					clientOnlineParams: clientOnlineParams{
+						{
+							body: wire.SNAC_0x01_0x02_OServiceClientOnline{},
+							me:   state.NewIdentScreenName("me"),
+							err:  io.EOF,
+						},
+					},
+				},
+				authParams: authParams{
+					crackCookieParams: crackCookieParams{
+						{
+							cookieIn:  []byte("chat-auth-cookie"),
+							cookieOut: state.ServerCookie{ChatCookie: "chat-auth-cookie"},
+						},
+					},
+					registerChatSessionParams: registerChatSessionParams{
+						{
+							authCookie: state.ServerCookie{ChatCookie: "chat-auth-cookie"},
+							sess:       newTestSession("me"),
+						},
+					},
+				},
 			},
 			wantMsg:           cmdInternalSvcErr,
 			expectChatSession: false,
 		},
 		{
-			name:     "accept chat, receive error from auth svc",
+			name:     "accept chat, receive error from register chat session",
 			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_chat_accept 0`),
 			givenChatRegistry: func() *ChatRegistry {
@@ -713,9 +724,42 @@ func TestOSCARProxy_RecvClientCmd_ChatAccept(t *testing.T) {
 				return reg
 			}(),
 			mockParams: mockParams{
-				chatNavParams:     fnNewChatNavParams(nil),
-				oServiceBOSParams: fnNewOServiceBOSParams(nil),
-				authParams:        fnNewAuthParams(io.EOF),
+				chatNavParams: chatNavParams{
+					requestRoomInfoParams: requestRoomInfoParams{
+						{
+							inBody: wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
+								Cookie:         "the-cookie",
+								Exchange:       4,
+								InstanceNumber: 0,
+							},
+							msg: navInfo,
+						},
+					},
+				},
+				oServiceParams: oServiceParams{
+					serviceRequestParams: serviceRequestParams{
+						{
+							me:     state.NewIdentScreenName("me"),
+							bodyIn: svcReq,
+							msg:    svcResp,
+						},
+					},
+				},
+				authParams: authParams{
+					crackCookieParams: crackCookieParams{
+						{
+							cookieIn:  []byte("chat-auth-cookie"),
+							cookieOut: state.ServerCookie{ChatCookie: "chat-auth-cookie"},
+						},
+					},
+					registerChatSessionParams: registerChatSessionParams{
+						{
+							authCookie: state.ServerCookie{ChatCookie: "chat-auth-cookie"},
+							sess:       newTestSession("me"),
+							err:        io.EOF,
+						},
+					},
+				},
 			},
 			wantMsg:           cmdInternalSvcErr,
 			expectChatSession: false,
@@ -734,8 +778,27 @@ func TestOSCARProxy_RecvClientCmd_ChatAccept(t *testing.T) {
 				return reg
 			}(),
 			mockParams: mockParams{
-				chatNavParams:     fnNewChatNavParams(nil),
-				oServiceBOSParams: fnNewOServiceBOSParams(io.EOF),
+				chatNavParams: chatNavParams{
+					requestRoomInfoParams: requestRoomInfoParams{
+						{
+							inBody: wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
+								Cookie:         "the-cookie",
+								Exchange:       4,
+								InstanceNumber: 0,
+							},
+							msg: navInfo,
+						},
+					},
+				},
+				oServiceParams: oServiceParams{
+					serviceRequestParams: serviceRequestParams{
+						{
+							me:     state.NewIdentScreenName("me"),
+							bodyIn: svcReq,
+							err:    io.EOF,
+						},
+					},
+				},
 			},
 			wantMsg:           cmdInternalSvcErr,
 			expectChatSession: false,
@@ -754,7 +817,18 @@ func TestOSCARProxy_RecvClientCmd_ChatAccept(t *testing.T) {
 				return reg
 			}(),
 			mockParams: mockParams{
-				chatNavParams: fnNewChatNavParams(io.EOF),
+				chatNavParams: chatNavParams{
+					requestRoomInfoParams: requestRoomInfoParams{
+						{
+							inBody: wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
+								Cookie:         "the-cookie",
+								Exchange:       4,
+								InstanceNumber: 0,
+							},
+							err: io.EOF,
+						},
+					},
+				},
 			},
 			wantMsg:           cmdInternalSvcErr,
 			expectChatSession: false,
@@ -788,16 +862,15 @@ func TestOSCARProxy_RecvClientCmd_ChatAccept(t *testing.T) {
 					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).
+			oServiceSvc := newMockOServiceService(t)
+			for _, params := range tc.mockParams.oServiceParams.serviceRequestParams {
+				oServiceSvc.EXPECT().
+					ServiceRequest(ctx, wire.BOS, 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)).
+			for _, params := range tc.mockParams.oServiceParams.clientOnlineParams {
+				oServiceSvc.EXPECT().
+					ClientOnline(ctx, wire.Chat, params.body, matchSession(params.me)).
 					Return(params.err)
 			}
 			authSvc := newMockAuthService(t)
@@ -806,13 +879,17 @@ func TestOSCARProxy_RecvClientCmd_ChatAccept(t *testing.T) {
 					RegisterChatSession(ctx, params.authCookie).
 					Return(params.sess, params.err)
 			}
+			for _, params := range tc.mockParams.authParams.crackCookieParams {
+				authSvc.EXPECT().
+					CrackCookie(params.cookieIn).
+					Return(params.cookieOut, params.err)
+			}
 
 			svc := OSCARProxy{
-				AuthService:         authSvc,
-				ChatNavService:      chatNavSvc,
-				Logger:              slog.Default(),
-				OServiceServiceBOS:  bosOServiceSvc,
-				OServiceServiceChat: chatOServiceSvc,
+				AuthService:     authSvc,
+				ChatNavService:  chatNavSvc,
+				Logger:          slog.Default(),
+				OServiceService: oServiceSvc,
 			}
 
 			g := &errgroup.Group{}
@@ -984,104 +1061,44 @@ func TestOSCARProxy_RecvClientCmd_ChatInvite(t *testing.T) {
 }
 
 func TestOSCARProxy_RecvClientCmd_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 :)"),
-							},
-						},
-					},
-				},
+	roomInfo := 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",
-								}),
-							},
-						},
-					},
+	navInfo := 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",
+					}),
 				},
 			},
-		}
-		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"),
-				},
+	svcReq := 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.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"),
+	svcResp := wire.SNACMessage{
+		Body: wire.SNAC_0x01_0x05_OServiceServiceResponse{
+			TLVRestBlock: wire.TLVRestBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, "chat-auth-cookie"),
 				},
 			},
-		}
-		if err != nil {
-			ret.clientOnlineParams[0].err = err
-		}
-		return ret
+		},
 	}
 
 	cases := []struct {
@@ -1108,49 +1125,164 @@ func TestOSCARProxy_RecvClientCmd_ChatJoin(t *testing.T) {
 			givenCmd:          []byte(`toc_chat_join 4 "cool room :\)"`),
 			givenChatRegistry: NewChatRegistry(),
 			mockParams: mockParams{
-				chatNavParams:      fnNewChatNavParams(nil),
-				oServiceBOSParams:  fnNewOServiceBOSParams(nil),
-				authParams:         fnNewAuthParams(nil),
-				oServiceChatParams: fnNewOServiceChatParams(nil),
+				chatNavParams: chatNavParams{
+					createRoomParams: createRoomParams{
+						{
+							me:     state.NewIdentScreenName("me"),
+							inBody: roomInfo,
+							msg:    navInfo,
+						},
+					},
+				},
+				oServiceParams: oServiceParams{
+					serviceRequestParams: serviceRequestParams{
+						{
+							me:     state.NewIdentScreenName("me"),
+							bodyIn: svcReq,
+							msg:    svcResp,
+						},
+					},
+					clientOnlineParams: clientOnlineParams{
+						{
+							body: wire.SNAC_0x01_0x02_OServiceClientOnline{},
+							me:   state.NewIdentScreenName("me"),
+						},
+					},
+				},
+				authParams: authParams{
+					crackCookieParams: crackCookieParams{
+						{
+							cookieIn:  []byte("chat-auth-cookie"),
+							cookieOut: state.ServerCookie{ChatCookie: "chat-auth-cookie"},
+						},
+					},
+					registerChatSessionParams: registerChatSessionParams{
+						{
+							authCookie: state.ServerCookie{ChatCookie: "chat-auth-cookie"},
+							sess:       newTestSession("me"),
+						},
+					},
+				},
 			},
 			wantMsg:           "CHAT_JOIN:0:cool room :)",
 			expectChatSession: true,
 		},
 		{
-			name:              "join chat, receive error from chat oservice svc",
+			name:              "accept chat, receive error from client online",
 			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),
+				chatNavParams: chatNavParams{
+					createRoomParams: createRoomParams{
+						{
+							me:     state.NewIdentScreenName("me"),
+							inBody: roomInfo,
+							msg:    navInfo,
+						},
+					},
+				},
+				oServiceParams: oServiceParams{
+					serviceRequestParams: serviceRequestParams{
+						{
+							me:     state.NewIdentScreenName("me"),
+							bodyIn: svcReq,
+							msg:    svcResp,
+						},
+					},
+					clientOnlineParams: clientOnlineParams{
+						{
+							body: wire.SNAC_0x01_0x02_OServiceClientOnline{},
+							me:   state.NewIdentScreenName("me"),
+							err:  io.EOF,
+						},
+					},
+				},
+				authParams: authParams{
+					crackCookieParams: crackCookieParams{
+						{
+							cookieIn:  []byte("chat-auth-cookie"),
+							cookieOut: state.ServerCookie{ChatCookie: "chat-auth-cookie"},
+						},
+					},
+					registerChatSessionParams: registerChatSessionParams{
+						{
+							authCookie: state.ServerCookie{ChatCookie: "chat-auth-cookie"},
+							sess:       newTestSession("me"),
+						},
+					},
+				},
 			},
 			wantMsg:           cmdInternalSvcErr,
 			expectChatSession: false,
 		},
 		{
-			name:              "join chat, receive error from auth svc",
+			name:              "join chat, receive error from register chat session",
 			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),
+				chatNavParams: chatNavParams{
+					createRoomParams: createRoomParams{
+						{
+							me:     state.NewIdentScreenName("me"),
+							inBody: roomInfo,
+							msg:    navInfo,
+						},
+					},
+				},
+				oServiceParams: oServiceParams{
+					serviceRequestParams: serviceRequestParams{
+						{
+							me:     state.NewIdentScreenName("me"),
+							bodyIn: svcReq,
+							msg:    svcResp,
+						},
+					},
+				},
+				authParams: authParams{
+					crackCookieParams: crackCookieParams{
+						{
+							cookieIn:  []byte("chat-auth-cookie"),
+							cookieOut: state.ServerCookie{ChatCookie: "chat-auth-cookie"},
+						},
+					},
+					registerChatSessionParams: registerChatSessionParams{
+						{
+							authCookie: state.ServerCookie{ChatCookie: "chat-auth-cookie"},
+							sess:       newTestSession("me"),
+							err:        io.EOF,
+						},
+					},
+				},
 			},
 			wantMsg:           cmdInternalSvcErr,
 			expectChatSession: false,
 		},
 		{
-			name:              "join chat, receive error from BOS oservice svc",
+			name:              "join chat, receive error from service request",
 			me:                newTestSession("me"),
 			givenCmd:          []byte(`toc_chat_join 4 "cool room :\)"`),
 			givenChatRegistry: NewChatRegistry(),
 			mockParams: mockParams{
-				chatNavParams:     fnNewChatNavParams(nil),
-				oServiceBOSParams: fnNewOServiceBOSParams(io.EOF),
+				chatNavParams: chatNavParams{
+					createRoomParams: createRoomParams{
+						{
+							me:     state.NewIdentScreenName("me"),
+							inBody: roomInfo,
+							msg:    navInfo,
+						},
+					},
+				},
+				oServiceParams: oServiceParams{
+					serviceRequestParams: serviceRequestParams{
+						{
+							me:     state.NewIdentScreenName("me"),
+							bodyIn: svcReq,
+							err:    io.EOF,
+						},
+					},
+				},
 			},
 			wantMsg:           cmdInternalSvcErr,
 			expectChatSession: false,
@@ -1161,7 +1293,15 @@ func TestOSCARProxy_RecvClientCmd_ChatJoin(t *testing.T) {
 			givenCmd:          []byte(`toc_chat_join 4 "cool room :\)"`),
 			givenChatRegistry: NewChatRegistry(),
 			mockParams: mockParams{
-				chatNavParams: fnNewChatNavParams(io.EOF),
+				chatNavParams: chatNavParams{
+					createRoomParams: createRoomParams{
+						{
+							me:     state.NewIdentScreenName("me"),
+							inBody: roomInfo,
+							err:    io.EOF,
+						},
+					},
+				},
 			},
 			wantMsg:           cmdInternalSvcErr,
 			expectChatSession: false,
@@ -1196,15 +1336,14 @@ func TestOSCARProxy_RecvClientCmd_ChatJoin(t *testing.T) {
 					Return(params.msg, params.err)
 			}
 			bosOServiceSvc := newMockOServiceService(t)
-			for _, params := range tc.mockParams.oServiceBOSParams.serviceRequestParams {
+			for _, params := range tc.mockParams.oServiceParams.serviceRequestParams {
 				bosOServiceSvc.EXPECT().
-					ServiceRequest(ctx, matchSession(params.me), wire.SNACFrame{}, params.bodyIn).
+					ServiceRequest(ctx, wire.BOS, 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)).
+			for _, params := range tc.mockParams.oServiceParams.clientOnlineParams {
+				bosOServiceSvc.EXPECT().
+					ClientOnline(ctx, wire.Chat, params.body, matchSession(params.me)).
 					Return(params.err)
 			}
 			authSvc := newMockAuthService(t)
@@ -1213,13 +1352,17 @@ func TestOSCARProxy_RecvClientCmd_ChatJoin(t *testing.T) {
 					RegisterChatSession(ctx, params.authCookie).
 					Return(params.sess, params.err)
 			}
+			for _, params := range tc.mockParams.authParams.crackCookieParams {
+				authSvc.EXPECT().
+					CrackCookie(params.cookieIn).
+					Return(params.cookieOut, params.err)
+			}
 
 			svc := OSCARProxy{
-				AuthService:         authSvc,
-				ChatNavService:      chatNavSvc,
-				Logger:              slog.Default(),
-				OServiceServiceBOS:  bosOServiceSvc,
-				OServiceServiceChat: chatOServiceSvc,
+				AuthService:     authSvc,
+				ChatNavService:  chatNavSvc,
+				Logger:          slog.Default(),
+				OServiceService: bosOServiceSvc,
 			}
 
 			g := &errgroup.Group{}
@@ -2471,7 +2614,7 @@ func TestOSCARProxy_RecvClientCmd_InitDone(t *testing.T) {
 			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_init_done`),
 			mockParams: mockParams{
-				oServiceBOSParams: oServiceParams{
+				oServiceParams: oServiceParams{
 					clientOnlineParams: clientOnlineParams{
 						{
 							me:   state.NewIdentScreenName("me"),
@@ -2486,7 +2629,7 @@ func TestOSCARProxy_RecvClientCmd_InitDone(t *testing.T) {
 			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_init_done`),
 			mockParams: mockParams{
-				oServiceBOSParams: oServiceParams{
+				oServiceParams: oServiceParams{
 					clientOnlineParams: clientOnlineParams{
 						{
 							me:   state.NewIdentScreenName("me"),
@@ -2505,15 +2648,15 @@ func TestOSCARProxy_RecvClientCmd_InitDone(t *testing.T) {
 			ctx := context.Background()
 
 			oSvc := newMockOServiceService(t)
-			for _, params := range tc.mockParams.oServiceBOSParams.clientOnlineParams {
+			for _, params := range tc.mockParams.oServiceParams.clientOnlineParams {
 				oSvc.EXPECT().
-					ClientOnline(ctx, params.body, matchSession(params.me)).
+					ClientOnline(ctx, wire.BOS, params.body, matchSession(params.me)).
 					Return(params.err)
 			}
 
 			svc := OSCARProxy{
-				Logger:             slog.Default(),
-				OServiceServiceBOS: oSvc,
+				Logger:          slog.Default(),
+				OServiceService: oSvc,
 			}
 			msg := svc.RecvClientCmd(ctx, tc.me, nil, tc.givenCmd, nil, nil)
 
@@ -3532,7 +3675,7 @@ func TestOSCARProxy_RecvClientCmd_SetIdle(t *testing.T) {
 			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_set_idle 10`),
 			mockParams: mockParams{
-				oServiceBOSParams: oServiceParams{
+				oServiceParams: oServiceParams{
 					idleNotificationParams: idleNotificationParams{
 						{
 							me: state.NewIdentScreenName("me"),
@@ -3549,7 +3692,7 @@ func TestOSCARProxy_RecvClientCmd_SetIdle(t *testing.T) {
 			me:       newTestSession("me"),
 			givenCmd: []byte(`toc_set_idle 10`),
 			mockParams: mockParams{
-				oServiceBOSParams: oServiceParams{
+				oServiceParams: oServiceParams{
 					idleNotificationParams: idleNotificationParams{
 						{
 							me: state.NewIdentScreenName("me"),
@@ -3581,15 +3724,15 @@ func TestOSCARProxy_RecvClientCmd_SetIdle(t *testing.T) {
 			ctx := context.Background()
 
 			oServiceSvc := newMockOServiceService(t)
-			for _, params := range tc.mockParams.oServiceBOSParams.idleNotificationParams {
+			for _, params := range tc.mockParams.oServiceParams.idleNotificationParams {
 				oServiceSvc.EXPECT().
 					IdleNotification(ctx, matchSession(params.me), params.bodyIn).
 					Return(params.err)
 			}
 
 			svc := OSCARProxy{
-				Logger:             slog.Default(),
-				OServiceServiceBOS: oServiceSvc,
+				Logger:          slog.Default(),
+				OServiceService: oServiceSvc,
 			}
 			msg := svc.RecvClientCmd(ctx, tc.me, nil, tc.givenCmd, nil, nil)
 
@@ -3728,9 +3871,15 @@ func TestOSCARProxy_Signon(t *testing.T) {
 							},
 						},
 					},
+					crackCookieParams: crackCookieParams{
+						{
+							cookieIn:  []byte("thecookie"),
+							cookieOut: state.ServerCookie{Service: wire.BOS},
+						},
+					},
 					registerBOSSessionParams: registerBOSSessionParams{
 						{
-							authCookie: []byte("thecookie"),
+							authCookie: state.ServerCookie{Service: wire.BOS},
 							sess:       newTestSession("me"),
 						},
 					},
@@ -3801,9 +3950,15 @@ func TestOSCARProxy_Signon(t *testing.T) {
 							},
 						},
 					},
+					crackCookieParams: crackCookieParams{
+						{
+							cookieIn:  []byte("thecookie"),
+							cookieOut: state.ServerCookie{Service: wire.BOS},
+						},
+					},
 					registerBOSSessionParams: registerBOSSessionParams{
 						{
-							authCookie: []byte("thecookie"),
+							authCookie: state.ServerCookie{Service: wire.BOS},
 							err:        io.EOF,
 						},
 					},
@@ -3834,9 +3989,15 @@ func TestOSCARProxy_Signon(t *testing.T) {
 							},
 						},
 					},
+					crackCookieParams: crackCookieParams{
+						{
+							cookieIn:  []byte("thecookie"),
+							cookieOut: state.ServerCookie{Service: wire.BOS},
+						},
+					},
 					registerBOSSessionParams: registerBOSSessionParams{
 						{
-							authCookie: []byte("thecookie"),
+							authCookie: state.ServerCookie{Service: wire.BOS},
 							sess:       newTestSession("me"),
 						},
 					},
@@ -3875,9 +4036,15 @@ func TestOSCARProxy_Signon(t *testing.T) {
 							},
 						},
 					},
+					crackCookieParams: crackCookieParams{
+						{
+							cookieIn:  []byte("thecookie"),
+							cookieOut: state.ServerCookie{Service: wire.BOS},
+						},
+					},
 					registerBOSSessionParams: registerBOSSessionParams{
 						{
-							authCookie: []byte("thecookie"),
+							authCookie: state.ServerCookie{Service: wire.BOS},
 							sess:       newTestSession("me"),
 						},
 					},
@@ -3923,9 +4090,15 @@ func TestOSCARProxy_Signon(t *testing.T) {
 							},
 						},
 					},
+					crackCookieParams: crackCookieParams{
+						{
+							cookieIn:  []byte("thecookie"),
+							cookieOut: state.ServerCookie{Service: wire.BOS},
+						},
+					},
 					registerBOSSessionParams: registerBOSSessionParams{
 						{
-							authCookie: []byte("thecookie"),
+							authCookie: state.ServerCookie{Service: wire.BOS},
 							sess:       newTestSession("me"),
 						},
 					},
@@ -3989,9 +4162,14 @@ func TestOSCARProxy_Signon(t *testing.T) {
 			authSvc := newMockAuthService(t)
 			for _, params := range tc.mockParams.flapLoginParams {
 				authSvc.EXPECT().
-					FLAPLogin(matchContext(), params.frame, mock.Anything).
+					FLAPLogin(matchContext(), params.frame, mock.Anything, "").
 					Return(params.tlv, params.err)
 			}
+			for _, params := range tc.mockParams.crackCookieParams {
+				authSvc.EXPECT().
+					CrackCookie(params.cookieIn).
+					Return(params.cookieOut, params.err)
+			}
 			for _, params := range tc.mockParams.registerBOSSessionParams {
 				authSvc.EXPECT().
 					RegisterBOSSession(matchContext(), params.authCookie).

+ 6 - 0
server/toc/cmd_server.go

@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"net"
 	"strings"
+	"time"
 
 	"github.com/google/uuid"
 
@@ -28,6 +29,11 @@ func (s OSCARProxy) RecvBOS(ctx context.Context, me *state.Session, chatRegistry
 	for {
 		select {
 		case <-ctx.Done():
+			func() {
+				shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+				defer cancel()
+				s.Signout(shutdownCtx, me, chatRegistry)
+			}()
 			return nil
 		case <-me.Closed():
 			return errDisconnect

+ 36 - 20
server/toc/cmd_server_test.go

@@ -8,6 +8,7 @@ import (
 	"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"
@@ -239,9 +240,7 @@ func TestOSCARProxy_RecvBOS_Eviled(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			ctx, cancel := context.WithCancel(context.Background())
 
-			svc := OSCARProxy{
-				Logger: slog.Default(),
-			}
+			svc := testOSCARProxy(t)
 
 			ch := make(chan []byte)
 			wg := &sync.WaitGroup{}
@@ -249,7 +248,7 @@ func TestOSCARProxy_RecvBOS_Eviled(t *testing.T) {
 
 			go func() {
 				defer wg.Done()
-				err := svc.RecvBOS(ctx, tc.me, tc.chatRegistry, ch)
+				err := svc.RecvBOS(ctx, tc.me, NewChatRegistry(), ch)
 				assert.NoError(t, err)
 			}()
 
@@ -273,8 +272,6 @@ func TestOSCARProxy_RecvBOS_IMIn(t *testing.T) {
 		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
 	}{
@@ -373,8 +370,7 @@ func TestOSCARProxy_RecvBOS_IMIn(t *testing.T) {
 					},
 				},
 			},
-			chatRegistry: NewChatRegistry(),
-			wantCmd:      []byte("CHAT_INVITE:the room:0:them:join my chat!"),
+			wantCmd: []byte("CHAT_INVITE:the room:0:them:join my chat!"),
 		},
 		{
 			name: "receive file transfer rendezvous IM",
@@ -413,9 +409,7 @@ func TestOSCARProxy_RecvBOS_IMIn(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			ctx, cancel := context.WithCancel(context.Background())
 
-			svc := OSCARProxy{
-				Logger: slog.Default(),
-			}
+			svc := testOSCARProxy(t)
 
 			ch := make(chan []byte)
 			wg := &sync.WaitGroup{}
@@ -423,7 +417,7 @@ func TestOSCARProxy_RecvBOS_IMIn(t *testing.T) {
 
 			go func() {
 				defer wg.Done()
-				err := svc.RecvBOS(ctx, tc.me, tc.chatRegistry, ch)
+				err := svc.RecvBOS(ctx, tc.me, NewChatRegistry(), ch)
 				assert.NoError(t, err)
 			}()
 
@@ -514,9 +508,7 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyArrival(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			ctx, cancel := context.WithCancel(context.Background())
 
-			svc := OSCARProxy{
-				Logger: slog.Default(),
-			}
+			svc := testOSCARProxy(t)
 
 			ch := make(chan []byte)
 			wg := &sync.WaitGroup{}
@@ -524,7 +516,7 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyArrival(t *testing.T) {
 
 			go func() {
 				defer wg.Done()
-				err := svc.RecvBOS(ctx, tc.me, nil, ch)
+				err := svc.RecvBOS(ctx, tc.me, NewChatRegistry(), ch)
 				assert.NoError(t, err)
 			}()
 
@@ -569,9 +561,7 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyDeparted(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			ctx, cancel := context.WithCancel(context.Background())
 
-			svc := OSCARProxy{
-				Logger: slog.Default(),
-			}
+			svc := testOSCARProxy(t)
 
 			ch := make(chan []byte)
 			wg := &sync.WaitGroup{}
@@ -579,7 +569,7 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyDeparted(t *testing.T) {
 
 			go func() {
 				defer wg.Done()
-				err := svc.RecvBOS(ctx, tc.me, nil, ch)
+				err := svc.RecvBOS(ctx, tc.me, NewChatRegistry(), ch)
 				assert.NoError(t, err)
 			}()
 
@@ -597,3 +587,29 @@ func TestOSCARProxy_RecvBOS_UpdateBuddyDeparted(t *testing.T) {
 
 func TestOSCARProxy_RecvBOS_Signout(t *testing.T) {
 }
+
+func testOSCARProxy(t *testing.T) OSCARProxy {
+	buddyService := newMockBuddyService(t)
+	buddyService.EXPECT().
+		BroadcastBuddyDeparted(mock.Anything, mock.Anything).
+		Maybe().
+		Return(nil)
+	buddyListRegistry := newMockBuddyListRegistry(t)
+	buddyListRegistry.EXPECT().
+		UnregisterBuddyList(mock.Anything, mock.Anything).
+		Maybe().
+		Return(nil)
+	authService := newMockAuthService(t)
+	authService.EXPECT().
+		Signout(mock.Anything, mock.Anything).
+		Maybe()
+	authService.EXPECT().
+		SignoutChat(mock.Anything, mock.Anything).
+		Maybe()
+	return OSCARProxy{
+		AuthService:       authService,
+		BuddyListRegistry: buddyListRegistry,
+		BuddyService:      buddyService,
+		Logger:            slog.Default(),
+	}
+}

+ 10 - 4
server/toc/helpers_test.go

@@ -126,13 +126,13 @@ type flapLoginParams []struct {
 }
 
 type registerBOSSessionParams []struct {
-	authCookie []byte
+	authCookie state.ServerCookie
 	sess       *state.Session
 	err        error
 }
 
 type registerChatSessionParams []struct {
-	authCookie []byte
+	authCookie state.ServerCookie
 	sess       *state.Session
 	err        error
 }
@@ -146,6 +146,7 @@ type signoutChatParams []struct {
 }
 
 type authParams struct {
+	crackCookieParams
 	flapLoginParams
 	registerBOSSessionParams
 	registerChatSessionParams
@@ -153,6 +154,12 @@ type authParams struct {
 	signoutChatParams
 }
 
+type crackCookieParams []struct {
+	cookieIn  []byte
+	cookieOut state.ServerCookie
+	err       error
+}
+
 type setDirInfoParams []struct {
 	me     state.IdentScreenName
 	inBody wire.SNAC_0x02_0x09_LocateSetDirInfo
@@ -256,8 +263,7 @@ type mockParams struct {
 	dirSearchParams
 	icbmParams
 	locateParams
-	oServiceBOSParams  oServiceParams
-	oServiceChatParams oServiceParams
+	oServiceParams
 	permitDenyParams
 	tocConfigParams
 }

+ 110 - 52
server/toc/mock_auth_service_test.go

@@ -84,9 +84,9 @@ func (_c *mockAuthService_BUCPChallenge_Call) RunAndReturn(run func(context.Cont
 	return _c
 }
 
-// BUCPLogin provides a mock function with given fields: ctx, bodyIn, newUserFn
-func (_m *mockAuthService) BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error) {
-	ret := _m.Called(ctx, bodyIn, newUserFn)
+// BUCPLogin provides a mock function with given fields: ctx, bodyIn, newUserFn, advertisedHost
+func (_m *mockAuthService) BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, bodyIn, newUserFn, advertisedHost)
 
 	if len(ret) == 0 {
 		panic("no return value specified for BUCPLogin")
@@ -94,17 +94,17 @@ func (_m *mockAuthService) BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_
 
 	var r0 wire.SNACMessage
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)); ok {
-		return rf(ctx, bodyIn, newUserFn)
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error), string) (wire.SNACMessage, error)); ok {
+		return rf(ctx, bodyIn, newUserFn, advertisedHost)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error)) wire.SNACMessage); ok {
-		r0 = rf(ctx, bodyIn, newUserFn)
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error), string) wire.SNACMessage); ok {
+		r0 = rf(ctx, bodyIn, newUserFn, advertisedHost)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error)) error); ok {
-		r1 = rf(ctx, bodyIn, newUserFn)
+	if rf, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error), string) error); ok {
+		r1 = rf(ctx, bodyIn, newUserFn, advertisedHost)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -121,13 +121,14 @@ type mockAuthService_BUCPLogin_Call struct {
 //   - ctx context.Context
 //   - bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest
 //   - newUserFn func(state.DisplayScreenName)(state.User , error)
-func (_e *mockAuthService_Expecter) BUCPLogin(ctx interface{}, bodyIn interface{}, newUserFn interface{}) *mockAuthService_BUCPLogin_Call {
-	return &mockAuthService_BUCPLogin_Call{Call: _e.mock.On("BUCPLogin", ctx, bodyIn, newUserFn)}
+//   - advertisedHost string
+func (_e *mockAuthService_Expecter) BUCPLogin(ctx interface{}, bodyIn interface{}, newUserFn interface{}, advertisedHost interface{}) *mockAuthService_BUCPLogin_Call {
+	return &mockAuthService_BUCPLogin_Call{Call: _e.mock.On("BUCPLogin", ctx, bodyIn, newUserFn, advertisedHost)}
 }
 
-func (_c *mockAuthService_BUCPLogin_Call) Run(run func(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error))) *mockAuthService_BUCPLogin_Call {
+func (_c *mockAuthService_BUCPLogin_Call) Run(run func(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(state.DisplayScreenName) (state.User, error), advertisedHost string)) *mockAuthService_BUCPLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(wire.SNAC_0x17_0x02_BUCPLoginRequest), args[2].(func(state.DisplayScreenName) (state.User, error)))
+		run(args[0].(context.Context), args[1].(wire.SNAC_0x17_0x02_BUCPLoginRequest), args[2].(func(state.DisplayScreenName) (state.User, error)), args[3].(string))
 	})
 	return _c
 }
@@ -137,14 +138,70 @@ func (_c *mockAuthService_BUCPLogin_Call) Return(_a0 wire.SNACMessage, _a1 error
 	return _c
 }
 
-func (_c *mockAuthService_BUCPLogin_Call) RunAndReturn(run func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)) *mockAuthService_BUCPLogin_Call {
+func (_c *mockAuthService_BUCPLogin_Call) RunAndReturn(run func(context.Context, wire.SNAC_0x17_0x02_BUCPLoginRequest, func(state.DisplayScreenName) (state.User, error), string) (wire.SNACMessage, error)) *mockAuthService_BUCPLogin_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
-// FLAPLogin provides a mock function with given fields: ctx, frame, newUserFn
-func (_m *mockAuthService) FLAPLogin(ctx context.Context, frame wire.FLAPSignonFrame, newUserFn func(state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error) {
-	ret := _m.Called(ctx, frame, newUserFn)
+// CrackCookie provides a mock function with given fields: authCookie
+func (_m *mockAuthService) CrackCookie(authCookie []byte) (state.ServerCookie, error) {
+	ret := _m.Called(authCookie)
+
+	if len(ret) == 0 {
+		panic("no return value specified for CrackCookie")
+	}
+
+	var r0 state.ServerCookie
+	var r1 error
+	if rf, ok := ret.Get(0).(func([]byte) (state.ServerCookie, error)); ok {
+		return rf(authCookie)
+	}
+	if rf, ok := ret.Get(0).(func([]byte) state.ServerCookie); ok {
+		r0 = rf(authCookie)
+	} else {
+		r0 = ret.Get(0).(state.ServerCookie)
+	}
+
+	if rf, ok := ret.Get(1).(func([]byte) error); ok {
+		r1 = rf(authCookie)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAuthService_CrackCookie_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CrackCookie'
+type mockAuthService_CrackCookie_Call struct {
+	*mock.Call
+}
+
+// CrackCookie is a helper method to define mock.On call
+//   - authCookie []byte
+func (_e *mockAuthService_Expecter) CrackCookie(authCookie interface{}) *mockAuthService_CrackCookie_Call {
+	return &mockAuthService_CrackCookie_Call{Call: _e.mock.On("CrackCookie", authCookie)}
+}
+
+func (_c *mockAuthService_CrackCookie_Call) Run(run func(authCookie []byte)) *mockAuthService_CrackCookie_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].([]byte))
+	})
+	return _c
+}
+
+func (_c *mockAuthService_CrackCookie_Call) Return(_a0 state.ServerCookie, _a1 error) *mockAuthService_CrackCookie_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAuthService_CrackCookie_Call) RunAndReturn(run func([]byte) (state.ServerCookie, error)) *mockAuthService_CrackCookie_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// FLAPLogin provides a mock function with given fields: ctx, frame, newUserFn, here
+func (_m *mockAuthService) FLAPLogin(ctx context.Context, frame wire.FLAPSignonFrame, newUserFn func(state.DisplayScreenName) (state.User, error), here string) (wire.TLVRestBlock, error) {
+	ret := _m.Called(ctx, frame, newUserFn, here)
 
 	if len(ret) == 0 {
 		panic("no return value specified for FLAPLogin")
@@ -152,17 +209,17 @@ func (_m *mockAuthService) FLAPLogin(ctx context.Context, frame wire.FLAPSignonF
 
 	var r0 wire.TLVRestBlock
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error)); ok {
-		return rf(ctx, frame, newUserFn)
+	if rf, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error), string) (wire.TLVRestBlock, error)); ok {
+		return rf(ctx, frame, newUserFn, here)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error)) wire.TLVRestBlock); ok {
-		r0 = rf(ctx, frame, newUserFn)
+	if rf, ok := ret.Get(0).(func(context.Context, wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error), string) wire.TLVRestBlock); ok {
+		r0 = rf(ctx, frame, newUserFn, here)
 	} else {
 		r0 = ret.Get(0).(wire.TLVRestBlock)
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error)) error); ok {
-		r1 = rf(ctx, frame, newUserFn)
+	if rf, ok := ret.Get(1).(func(context.Context, wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error), string) error); ok {
+		r1 = rf(ctx, frame, newUserFn, here)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -179,13 +236,14 @@ type mockAuthService_FLAPLogin_Call struct {
 //   - ctx context.Context
 //   - frame wire.FLAPSignonFrame
 //   - newUserFn func(state.DisplayScreenName)(state.User , error)
-func (_e *mockAuthService_Expecter) FLAPLogin(ctx interface{}, frame interface{}, newUserFn interface{}) *mockAuthService_FLAPLogin_Call {
-	return &mockAuthService_FLAPLogin_Call{Call: _e.mock.On("FLAPLogin", ctx, frame, newUserFn)}
+//   - here string
+func (_e *mockAuthService_Expecter) FLAPLogin(ctx interface{}, frame interface{}, newUserFn interface{}, here interface{}) *mockAuthService_FLAPLogin_Call {
+	return &mockAuthService_FLAPLogin_Call{Call: _e.mock.On("FLAPLogin", ctx, frame, newUserFn, here)}
 }
 
-func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, frame wire.FLAPSignonFrame, newUserFn func(state.DisplayScreenName) (state.User, error))) *mockAuthService_FLAPLogin_Call {
+func (_c *mockAuthService_FLAPLogin_Call) Run(run func(ctx context.Context, frame wire.FLAPSignonFrame, newUserFn func(state.DisplayScreenName) (state.User, error), here string)) *mockAuthService_FLAPLogin_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(wire.FLAPSignonFrame), args[2].(func(state.DisplayScreenName) (state.User, error)))
+		run(args[0].(context.Context), args[1].(wire.FLAPSignonFrame), args[2].(func(state.DisplayScreenName) (state.User, error)), args[3].(string))
 	})
 	return _c
 }
@@ -195,13 +253,13 @@ func (_c *mockAuthService_FLAPLogin_Call) Return(_a0 wire.TLVRestBlock, _a1 erro
 	return _c
 }
 
-func (_c *mockAuthService_FLAPLogin_Call) RunAndReturn(run func(context.Context, wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error)) *mockAuthService_FLAPLogin_Call {
+func (_c *mockAuthService_FLAPLogin_Call) RunAndReturn(run func(context.Context, wire.FLAPSignonFrame, func(state.DisplayScreenName) (state.User, error), string) (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) {
+func (_m *mockAuthService) RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.Session, error) {
 	ret := _m.Called(ctx, authCookie)
 
 	if len(ret) == 0 {
@@ -210,10 +268,10 @@ func (_m *mockAuthService) RegisterBOSSession(ctx context.Context, authCookie []
 
 	var r0 *state.Session
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, []byte) (*state.Session, error)); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, state.ServerCookie) (*state.Session, error)); ok {
 		return rf(ctx, authCookie)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, []byte) *state.Session); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, state.ServerCookie) *state.Session); ok {
 		r0 = rf(ctx, authCookie)
 	} else {
 		if ret.Get(0) != nil {
@@ -221,7 +279,7 @@ func (_m *mockAuthService) RegisterBOSSession(ctx context.Context, authCookie []
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
+	if rf, ok := ret.Get(1).(func(context.Context, state.ServerCookie) error); ok {
 		r1 = rf(ctx, authCookie)
 	} else {
 		r1 = ret.Error(1)
@@ -237,14 +295,14 @@ type mockAuthService_RegisterBOSSession_Call struct {
 
 // RegisterBOSSession is a helper method to define mock.On call
 //   - ctx context.Context
-//   - authCookie []byte
+//   - authCookie state.ServerCookie
 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 {
+func (_c *mockAuthService_RegisterBOSSession_Call) Run(run func(ctx context.Context, authCookie state.ServerCookie)) *mockAuthService_RegisterBOSSession_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].([]byte))
+		run(args[0].(context.Context), args[1].(state.ServerCookie))
 	})
 	return _c
 }
@@ -254,13 +312,13 @@ func (_c *mockAuthService_RegisterBOSSession_Call) Return(_a0 *state.Session, _a
 	return _c
 }
 
-func (_c *mockAuthService_RegisterBOSSession_Call) RunAndReturn(run func(context.Context, []byte) (*state.Session, error)) *mockAuthService_RegisterBOSSession_Call {
+func (_c *mockAuthService_RegisterBOSSession_Call) RunAndReturn(run func(context.Context, state.ServerCookie) (*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) {
+func (_m *mockAuthService) RegisterChatSession(ctx context.Context, authCookie state.ServerCookie) (*state.Session, error) {
 	ret := _m.Called(ctx, authCookie)
 
 	if len(ret) == 0 {
@@ -269,10 +327,10 @@ func (_m *mockAuthService) RegisterChatSession(ctx context.Context, authCookie [
 
 	var r0 *state.Session
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, []byte) (*state.Session, error)); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, state.ServerCookie) (*state.Session, error)); ok {
 		return rf(ctx, authCookie)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, []byte) *state.Session); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, state.ServerCookie) *state.Session); ok {
 		r0 = rf(ctx, authCookie)
 	} else {
 		if ret.Get(0) != nil {
@@ -280,7 +338,7 @@ func (_m *mockAuthService) RegisterChatSession(ctx context.Context, authCookie [
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
+	if rf, ok := ret.Get(1).(func(context.Context, state.ServerCookie) error); ok {
 		r1 = rf(ctx, authCookie)
 	} else {
 		r1 = ret.Error(1)
@@ -296,14 +354,14 @@ type mockAuthService_RegisterChatSession_Call struct {
 
 // RegisterChatSession is a helper method to define mock.On call
 //   - ctx context.Context
-//   - authCookie []byte
+//   - authCookie state.ServerCookie
 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 {
+func (_c *mockAuthService_RegisterChatSession_Call) Run(run func(ctx context.Context, authCookie state.ServerCookie)) *mockAuthService_RegisterChatSession_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].([]byte))
+		run(args[0].(context.Context), args[1].(state.ServerCookie))
 	})
 	return _c
 }
@@ -313,13 +371,13 @@ func (_c *mockAuthService_RegisterChatSession_Call) Return(_a0 *state.Session, _
 	return _c
 }
 
-func (_c *mockAuthService_RegisterChatSession_Call) RunAndReturn(run func(context.Context, []byte) (*state.Session, error)) *mockAuthService_RegisterChatSession_Call {
+func (_c *mockAuthService_RegisterChatSession_Call) RunAndReturn(run func(context.Context, state.ServerCookie) (*state.Session, error)) *mockAuthService_RegisterChatSession_Call {
 	_c.Call.Return(run)
 	return _c
 }
 
 // RetrieveBOSSession provides a mock function with given fields: ctx, authCookie
-func (_m *mockAuthService) RetrieveBOSSession(ctx context.Context, authCookie []byte) (*state.Session, error) {
+func (_m *mockAuthService) RetrieveBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.Session, error) {
 	ret := _m.Called(ctx, authCookie)
 
 	if len(ret) == 0 {
@@ -328,10 +386,10 @@ func (_m *mockAuthService) RetrieveBOSSession(ctx context.Context, authCookie []
 
 	var r0 *state.Session
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, []byte) (*state.Session, error)); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, state.ServerCookie) (*state.Session, error)); ok {
 		return rf(ctx, authCookie)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, []byte) *state.Session); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, state.ServerCookie) *state.Session); ok {
 		r0 = rf(ctx, authCookie)
 	} else {
 		if ret.Get(0) != nil {
@@ -339,7 +397,7 @@ func (_m *mockAuthService) RetrieveBOSSession(ctx context.Context, authCookie []
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
+	if rf, ok := ret.Get(1).(func(context.Context, state.ServerCookie) error); ok {
 		r1 = rf(ctx, authCookie)
 	} else {
 		r1 = ret.Error(1)
@@ -355,14 +413,14 @@ type mockAuthService_RetrieveBOSSession_Call struct {
 
 // RetrieveBOSSession is a helper method to define mock.On call
 //   - ctx context.Context
-//   - authCookie []byte
+//   - authCookie state.ServerCookie
 func (_e *mockAuthService_Expecter) RetrieveBOSSession(ctx interface{}, authCookie interface{}) *mockAuthService_RetrieveBOSSession_Call {
 	return &mockAuthService_RetrieveBOSSession_Call{Call: _e.mock.On("RetrieveBOSSession", ctx, authCookie)}
 }
 
-func (_c *mockAuthService_RetrieveBOSSession_Call) Run(run func(ctx context.Context, authCookie []byte)) *mockAuthService_RetrieveBOSSession_Call {
+func (_c *mockAuthService_RetrieveBOSSession_Call) Run(run func(ctx context.Context, authCookie state.ServerCookie)) *mockAuthService_RetrieveBOSSession_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].([]byte))
+		run(args[0].(context.Context), args[1].(state.ServerCookie))
 	})
 	return _c
 }
@@ -372,7 +430,7 @@ func (_c *mockAuthService_RetrieveBOSSession_Call) Return(_a0 *state.Session, _a
 	return _c
 }
 
-func (_c *mockAuthService_RetrieveBOSSession_Call) RunAndReturn(run func(context.Context, []byte) (*state.Session, error)) *mockAuthService_RetrieveBOSSession_Call {
+func (_c *mockAuthService_RetrieveBOSSession_Call) RunAndReturn(run func(context.Context, state.ServerCookie) (*state.Session, error)) *mockAuthService_RetrieveBOSSession_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 28 - 25
server/toc/mock_oservice_service_test.go

@@ -24,17 +24,17 @@ 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)
+// ClientOnline provides a mock function with given fields: ctx, service, bodyIn, sess
+func (_m *mockOServiceService) ClientOnline(ctx context.Context, service uint16, bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error {
+	ret := _m.Called(ctx, service, bodyIn, 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)
+	if rf, ok := ret.Get(0).(func(context.Context, uint16, wire.SNAC_0x01_0x02_OServiceClientOnline, *state.Session) error); ok {
+		r0 = rf(ctx, service, bodyIn, sess)
 	} else {
 		r0 = ret.Error(0)
 	}
@@ -49,15 +49,16 @@ type mockOServiceService_ClientOnline_Call struct {
 
 // ClientOnline is a helper method to define mock.On call
 //   - ctx context.Context
-//   - _a1 wire.SNAC_0x01_0x02_OServiceClientOnline
+//   - service uint16
+//   - bodyIn 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 (_e *mockOServiceService_Expecter) ClientOnline(ctx interface{}, service interface{}, bodyIn interface{}, sess interface{}) *mockOServiceService_ClientOnline_Call {
+	return &mockOServiceService_ClientOnline_Call{Call: _e.mock.On("ClientOnline", ctx, service, bodyIn, sess)}
 }
 
-func (_c *mockOServiceService_ClientOnline_Call) Run(run func(ctx context.Context, _a1 wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session)) *mockOServiceService_ClientOnline_Call {
+func (_c *mockOServiceService_ClientOnline_Call) Run(run func(ctx context.Context, service uint16, bodyIn 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))
+		run(args[0].(context.Context), args[1].(uint16), args[2].(wire.SNAC_0x01_0x02_OServiceClientOnline), args[3].(*state.Session))
 	})
 	return _c
 }
@@ -67,7 +68,7 @@ func (_c *mockOServiceService_ClientOnline_Call) Return(_a0 error) *mockOService
 	return _c
 }
 
-func (_c *mockOServiceService_ClientOnline_Call) RunAndReturn(run func(context.Context, wire.SNAC_0x01_0x02_OServiceClientOnline, *state.Session) error) *mockOServiceService_ClientOnline_Call {
+func (_c *mockOServiceService_ClientOnline_Call) RunAndReturn(run func(context.Context, uint16, wire.SNAC_0x01_0x02_OServiceClientOnline, *state.Session) error) *mockOServiceService_ClientOnline_Call {
 	_c.Call.Return(run)
 	return _c
 }
@@ -120,9 +121,9 @@ func (_c *mockOServiceService_IdleNotification_Call) RunAndReturn(run func(conte
 	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)
+// ServiceRequest provides a mock function with given fields: ctx, service, sess, frame, bodyIn, advertisedHost
+func (_m *mockOServiceService) ServiceRequest(ctx context.Context, service uint16, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest, advertisedHost string) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, service, sess, frame, bodyIn, advertisedHost)
 
 	if len(ret) == 0 {
 		panic("no return value specified for ServiceRequest")
@@ -130,17 +131,17 @@ func (_m *mockOServiceService) ServiceRequest(ctx context.Context, sess *state.S
 
 	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, uint16, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, string) (wire.SNACMessage, error)); ok {
+		return rf(ctx, service, sess, frame, bodyIn, advertisedHost)
 	}
-	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)
+	if rf, ok := ret.Get(0).(func(context.Context, uint16, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, string) wire.SNACMessage); ok {
+		r0 = rf(ctx, service, sess, frame, bodyIn, advertisedHost)
 	} 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)
+	if rf, ok := ret.Get(1).(func(context.Context, uint16, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, string) error); ok {
+		r1 = rf(ctx, service, sess, frame, bodyIn, advertisedHost)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -155,16 +156,18 @@ type mockOServiceService_ServiceRequest_Call struct {
 
 // ServiceRequest is a helper method to define mock.On call
 //   - ctx context.Context
+//   - service uint16
 //   - 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)}
+//   - advertisedHost string
+func (_e *mockOServiceService_Expecter) ServiceRequest(ctx interface{}, service interface{}, sess interface{}, frame interface{}, bodyIn interface{}, advertisedHost interface{}) *mockOServiceService_ServiceRequest_Call {
+	return &mockOServiceService_ServiceRequest_Call{Call: _e.mock.On("ServiceRequest", ctx, service, sess, frame, bodyIn, advertisedHost)}
 }
 
-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 {
+func (_c *mockOServiceService_ServiceRequest_Call) Run(run func(ctx context.Context, service uint16, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest, advertisedHost string)) *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))
+		run(args[0].(context.Context), args[1].(uint16), args[2].(*state.Session), args[3].(wire.SNACFrame), args[4].(wire.SNAC_0x01_0x04_OServiceServiceRequest), args[5].(string))
 	})
 	return _c
 }
@@ -174,7 +177,7 @@ func (_c *mockOServiceService_ServiceRequest_Call) Return(_a0 wire.SNACMessage,
 	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 {
+func (_c *mockOServiceService_ServiceRequest_Call) RunAndReturn(run func(context.Context, uint16, *state.Session, wire.SNACFrame, wire.SNAC_0x01_0x04_OServiceServiceRequest, string) (wire.SNACMessage, error)) *mockOServiceService_ServiceRequest_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 161 - 87
server/toc/server.go

@@ -65,17 +65,19 @@ func (b bufferedConn) Read(p []byte) (int, error) {
 // 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.
+	ch  chan net.Conn // Channel used to receive connections.
+	ctx context.Context
 }
 
 // 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 {
+	select {
+	case <-l.ctx.Done():
 		return nil, io.EOF
+	case ch := <-l.ch:
+		return ch, io.EOF
 	}
-	return ch, nil
 }
 
 // Close closes the listener. Since channelListener does not manage an actual
@@ -120,76 +122,153 @@ func (l *IPRateLimiter) Allow(ip string) (allowed bool) {
 	return limiter.(*rate.Limiter).Allow()
 }
 
+func NewServer(listenerCfg []string, logger *slog.Logger, BOSProxy OSCARProxy, ipRateLimiter *IPRateLimiter) *Server {
+	ctx, cancel := context.WithCancel(context.Background())
+
+	s := &Server{
+		bosProxy:           BOSProxy,
+		conns:              make(map[net.Conn]struct{}),
+		listenerCfg:        listenerCfg,
+		logger:             logger,
+		loginIPRateLimiter: ipRateLimiter,
+		servers:            make([]*http.Server, 0, len(listenerCfg)),
+		shutdownCancel:     cancel,
+		shutdownCtx:        ctx,
+	}
+
+	for range listenerCfg {
+		s.servers = append(s.servers, &http.Server{
+			Handler: BOSProxy.NewServeMux(),
+			BaseContext: func(net.Listener) context.Context {
+				return s.shutdownCtx
+			},
+		})
+	}
+
+	return s
+}
+
 // 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
-	LoginIPRateLimiter *IPRateLimiter
+	bosProxy           OSCARProxy
+	logger             *slog.Logger
+	loginIPRateLimiter *IPRateLimiter
+
+	listenerCfg []string
+	listeners   []net.Listener
+	servers     []*http.Server
+
+	connMu sync.Mutex
+	conns  map[net.Conn]struct{}
+
+	connWg   sync.WaitGroup
+	listenWg sync.WaitGroup
+
+	shutdownCtx    context.Context
+	shutdownCancel context.CancelFunc
 }
 
-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)
+func (s *Server) ListenAndServe() error {
+	g, ctx := errgroup.WithContext(s.shutdownCtx)
+
+	for i, cfg := range s.listenerCfg {
+		ln, err := net.Listen("tcp", cfg)
+		if err != nil {
+			s.cleanupListeners()
+			s.shutdownCancel()
+			return fmt.Errorf("unable to start TOC server: %w", err)
+		}
+
+		s.logger.InfoContext(ctx, "starting server", "listen_host", cfg)
+
+		s.listeners = append(s.listeners, ln)
+		s.listenWg.Add(1)
+
+		httpCh := make(chan net.Conn)
+
+		g.Go(func() error {
+			cl := &channelListener{
+				ch:  httpCh,
+				ctx: s.shutdownCtx,
+			}
+			if err := s.servers[i].Serve(cl); !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, io.EOF) {
+				fmt.Println("HAHA")
+				s.shutdownCancel()
+				return err
+			}
+			return nil
+		})
+
+		g.Go(func() error {
+			s.acceptLoop(ctx, ln, httpCh)
+			return nil
+		})
 	}
 
-	rt.Logger.InfoContext(ctx, "starting server", "listen_host", rt.ListenAddr)
+	return g.Wait()
+}
+
+func (s *Server) Shutdown(ctx context.Context) error {
+	s.logger.Debug("Initiating graceful shutdown...")
+	s.shutdownCancel()
+	s.cleanupListeners()
 
+	// Wait for handlers to complete
+	done := make(chan struct{})
 	go func() {
-		<-ctx.Done()
-		_ = listener.Close()
+		s.connWg.Wait()
+		s.listenWg.Wait()
+		close(done)
 	}()
 
-	httpServer := &http.Server{
-		Handler: rt.BOSProxy.NewServeMux(),
-		BaseContext: func(net.Listener) context.Context {
-			return ctx
-		},
+	for _, srv := range s.servers {
+		_ = srv.Shutdown(ctx)
 	}
 
-	httpCh := make(chan net.Conn)
-	defer close(httpCh)
+	select {
+	case <-done:
+		s.logger.Info("shutdown complete")
+	case <-ctx.Done():
+		s.logger.Info("shutdown complete, but connections didn't close cleanly")
+	}
 
-	go func() {
-		_ = httpServer.Serve(&channelListener{ch: httpCh})
-	}()
+	return nil
+}
+
+func (s *Server) cleanupListeners() {
+	for _, ln := range s.listeners {
+		_ = ln.Close()
+	}
+	s.listeners = nil
+}
+
+func (s *Server) acceptLoop(ctx context.Context, ln net.Listener, httpCh chan net.Conn) {
+	defer s.listenWg.Done()
 
-	wg := sync.WaitGroup{}
 	for {
-		conn, err := listener.Accept()
+		conn, err := ln.Accept()
 		if err != nil {
 			if errors.Is(err, net.ErrClosed) {
-				break
+				return
 			}
-			rt.Logger.ErrorContext(ctx, "accept failed", "err", err.Error())
+			s.logger.Error("accept error", "err", err.Error())
 			continue
 		}
 
-		wg.Add(1)
 		go func() {
-			defer wg.Done()
-			if err := rt.dispatchConn(conn, ctx, httpCh); err != nil {
-				rt.Logger.ErrorContext(ctx, "client disconnected with error", "err", err.Error())
+			if err := s.handleConnection(conn, ctx, httpCh); err != nil {
+				s.logger.InfoContext(ctx, "user session failed", "err", err.Error())
 			}
 		}()
 	}
-
-	if !waitForShutdown(&wg) {
-		rt.Logger.ErrorContext(ctx, "shutdown complete, but connections didn't close cleanly")
-	} else {
-		rt.Logger.InfoContext(ctx, "shutdown complete")
-	}
-
-	return nil
 }
 
-// dispatchConn inspects and routes an incoming connection. If the connection
+// handleConnection inspects and routes an incoming connection. If the connection
 // starts with "FLAP", handle as TOC/FLAP; otherwise, dispatch for HTTP
 // processing.
-func (rt Server) dispatchConn(conn net.Conn, ctx context.Context, httpCh chan net.Conn) error {
+func (s *Server) handleConnection(conn net.Conn, ctx context.Context, httpCh chan net.Conn) error {
 	bufCon := newBufferedConn(conn)
 
 	doFlap := "FLAP"
@@ -200,14 +279,31 @@ func (rt Server) dispatchConn(conn net.Conn, ctx context.Context, httpCh chan ne
 
 	// handle TOC/FLAP
 	if string(buf) == doFlap {
-		if err = rt.dispatchFLAP(ctx, bufCon); err != nil {
+		defer func() {
+			// untrack connections
+			s.connMu.Lock()
+			delete(s.conns, conn)
+			s.connMu.Unlock()
+
+			_ = conn.Close()
+			s.connWg.Done()
+		}()
+
+		// track connection
+		s.connMu.Lock()
+		s.conns[conn] = struct{}{}
+		s.connMu.Unlock()
+
+		s.connWg.Add(1)
+
+		if err = s.dispatchFLAP(ctx, bufCon); err != nil {
 			switch {
 			case errors.Is(err, io.EOF):
 			case errors.Is(err, net.ErrClosed):
 			case errors.Is(err, syscall.ECONNRESET):
 				return nil
 			default:
-				return fmt.Errorf("rt.dispatchFLAP: %w", err)
+				return fmt.Errorf("s.dispatchFLAP: %w", err)
 			}
 		}
 		return nil
@@ -222,7 +318,7 @@ func (rt Server) dispatchConn(conn net.Conn, ctx context.Context, httpCh chan ne
 	}
 }
 
-func (rt Server) dispatchFLAP(ctx context.Context, conn net.Conn) error {
+func (s *Server) dispatchFLAP(ctx context.Context, conn net.Conn) error {
 	var once sync.Once
 
 	closeConn := func() {
@@ -234,27 +330,27 @@ func (rt Server) dispatchFLAP(ctx context.Context, conn net.Conn) error {
 
 	ctx = context.WithValue(ctx, "ip", conn.RemoteAddr().String())
 
-	clientFlap, err := rt.initFLAP(conn)
+	clientFlap, err := s.initFLAP(conn)
 	if err != nil {
 		return err
 	}
 
 	ip, _, err := net.SplitHostPort(conn.RemoteAddr().String())
 	if err != nil {
-		rt.Logger.Error("failed to parse remote address", "err", err.Error())
+		s.logger.Error("failed to parse remote address", "err", err.Error())
 		return err
 	}
 
-	if ok := rt.LoginIPRateLimiter.Allow(ip); !ok {
+	if ok := s.loginIPRateLimiter.Allow(ip); !ok {
 		if err := clientFlap.SendDataFrame([]byte("ERROR:983")); err != nil {
 			return fmt.Errorf("clientFlap.SendDataFrame: %w", err)
 		}
 		return nil
 	}
 
-	sessBOS, err := rt.login(ctx, clientFlap)
+	sessBOS, err := s.login(ctx, clientFlap)
 	if err != nil {
-		return fmt.Errorf("rt.login: %w", err)
+		return fmt.Errorf("s.login: %w", err)
 	}
 	if sessBOS == nil {
 		return nil // user not found
@@ -273,9 +369,7 @@ func (rt Server) dispatchFLAP(ctx context.Context, conn net.Conn) error {
 
 	chatRegistry := NewChatRegistry()
 
-	defer rt.BOSProxy.Signout(ctx, sessBOS, chatRegistry)
-
-	return rt.handleTOCRequest(ctx, closeConn, sessBOS, chatRegistry, clientFlap)
+	return s.handleTOCRequest(ctx, closeConn, sessBOS, chatRegistry, clientFlap)
 }
 
 // handleTOCRequest processes incoming TOC requests and coordinates their handling.
@@ -286,7 +380,7 @@ func (rt Server) dispatchFLAP(ctx context.Context, conn net.Conn) error {
 //     io.EOF if the client disconnected.
 //   - errTOCProcessing if an error occurs while processing the TOC command.
 //   - errServerWrite if an error occurs while sending the TOC response.
-func (rt Server) handleTOCRequest(
+func (s *Server) handleTOCRequest(
 	ctx context.Context,
 	closeConn func(),
 	sessBOS *state.Session,
@@ -300,20 +394,20 @@ func (rt Server) handleTOCRequest(
 
 	// process TOC client requests and enqueue TOC server responses
 	g.Go(func() error {
-		err := rt.runClientCommands(ctx, g.Go, sessBOS, chatRegistry, clientFlap, msgCh)
+		err := s.runClientCommands(ctx, g.Go, sessBOS, chatRegistry, clientFlap, msgCh)
 		return errors.Join(err, errClientReq)
 	})
 
 	// translate OSCAR server responses to TOC responses and enqueue them
 	g.Go(func() error {
-		err := rt.BOSProxy.RecvBOS(ctx, sessBOS, chatRegistry, msgCh)
+		err := s.bosProxy.RecvBOS(ctx, sessBOS, chatRegistry, msgCh)
 		closeConn() // unblock runClientCommands
 		return errors.Join(err, errTOCProcessing)
 	})
 
 	// send TOC server responses to the client
 	g.Go(func() error {
-		err := rt.sendToClient(ctx, msgCh, clientFlap)
+		err := s.sendToClient(ctx, msgCh, clientFlap)
 		closeConn() // unblock runClientCommands
 		return errors.Join(err, errServerWrite)
 	})
@@ -321,7 +415,7 @@ func (rt Server) handleTOCRequest(
 	return g.Wait()
 }
 
-func (rt Server) runClientCommands(ctx context.Context, doAsync func(f func() error), sessBOS *state.Session, chatRegistry *ChatRegistry, clientFlap *wire.FlapClient, toCh chan<- []byte) error {
+func (s *Server) runClientCommands(ctx context.Context, doAsync func(f func() error), sessBOS *state.Session, chatRegistry *ChatRegistry, clientFlap *wire.FlapClient, toCh chan<- []byte) error {
 	for {
 		clientFrame, err := clientFlap.ReceiveFLAP()
 		if err != nil {
@@ -343,7 +437,7 @@ func (rt Server) runClientCommands(ctx context.Context, doAsync func(f func() er
 				return errors.New("TOC command exceeds maximum length (2048)")
 			}
 
-			msg := rt.BOSProxy.RecvClientCmd(ctx, sessBOS, chatRegistry, clientFrame.Payload, toCh, doAsync)
+			msg := s.bosProxy.RecvClientCmd(ctx, sessBOS, chatRegistry, clientFrame.Payload, toCh, doAsync)
 			if len(msg) > 0 {
 				select {
 				case toCh <- []byte(msg):
@@ -357,7 +451,7 @@ func (rt Server) runClientCommands(ctx context.Context, doAsync func(f func() er
 	}
 }
 
-func (rt Server) sendToClient(ctx context.Context, toClient <-chan []byte, clientFlap *wire.FlapClient) error {
+func (s *Server) sendToClient(ctx context.Context, toClient <-chan []byte, clientFlap *wire.FlapClient) error {
 	for {
 		select {
 		case <-ctx.Done():
@@ -366,21 +460,21 @@ func (rt Server) sendToClient(ctx context.Context, toClient <-chan []byte, clien
 			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)
+			if s.logger.Enabled(ctx, slog.LevelDebug) {
+				s.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])
+				s.logger.InfoContext(ctx, "server response", "command", msg[0:idx])
 			}
 		}
 	}
 }
 
-func (rt Server) login(ctx context.Context, clientFlap *wire.FlapClient) (*state.Session, error) {
+func (s *Server) login(ctx context.Context, clientFlap *wire.FlapClient) (*state.Session, error) {
 	clientFrame, err := clientFlap.ReceiveFLAP()
 	if err != nil {
 		if errors.Is(err, io.EOF) {
@@ -399,7 +493,7 @@ func (rt Server) login(ctx context.Context, clientFlap *wire.FlapClient) (*state
 		return nil, errors.New("expected toc_signon")
 	}
 
-	sessBOS, reply := rt.BOSProxy.Signon(ctx, args)
+	sessBOS, reply := s.bosProxy.Signon(ctx, args)
 	for _, m := range reply {
 		if err := clientFlap.SendDataFrame([]byte(m)); err != nil {
 			return nil, fmt.Errorf("clientFlap.SendDataFrame: %w", err)
@@ -411,7 +505,7 @@ func (rt Server) login(ctx context.Context, clientFlap *wire.FlapClient) (*state
 
 // initFLAP sets up a new FLAP connection. It returns a flap client if the
 // connection successfully initialized.
-func (rt Server) initFLAP(rw io.ReadWriter) (*wire.FlapClient, error) {
+func (s *Server) initFLAP(rw io.ReadWriter) (*wire.FlapClient, error) {
 	expected := "FLAPON\r\n\r\n"
 	buf := make([]byte, len(expected))
 
@@ -434,23 +528,3 @@ func (rt Server) initFLAP(rw io.ReadWriter) (*wire.FlapClient, error) {
 
 	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
-	}
-}

+ 15 - 19
server/toc/server_test.go

@@ -23,8 +23,8 @@ func TestServer_handleTOCRequest_serverShutdown(t *testing.T) {
 	go func() {
 		defer wg.Done()
 		sv := Server{
-			BOSProxy: OSCARProxy{},
-			Logger:   slog.Default(),
+			bosProxy: testOSCARProxy(t),
+			logger:   slog.Default(),
 		}
 
 		serverReader, _ := io.Pipe()
@@ -34,7 +34,7 @@ func TestServer_handleTOCRequest_serverShutdown(t *testing.T) {
 			_ = serverReader.Close()
 		}
 		sess := newTestSession("me")
-		err := sv.handleTOCRequest(ctx, closeConn, sess, nil, fc)
+		err := sv.handleTOCRequest(ctx, closeConn, sess, NewChatRegistry(), fc)
 		assert.True(t, errors.Is(err, errTOCProcessing) || errors.Is(err, errServerWrite))
 	}()
 
@@ -61,10 +61,10 @@ func TestServer_handleTOCRequest_clientReadDisconnect(t *testing.T) {
 		fc := wire.NewFlapClient(0, serverReader, nil)
 
 		sv := Server{
-			BOSProxy: OSCARProxy{},
-			Logger:   slog.Default(),
+			bosProxy: testOSCARProxy(t),
+			logger:   slog.Default(),
 		}
-		err := sv.handleTOCRequest(context.Background(), closeConn, sess, nil, fc)
+		err := sv.handleTOCRequest(context.Background(), closeConn, sess, NewChatRegistry(), fc)
 		assert.ErrorIs(t, err, errClientReq)
 		assert.ErrorIs(t, err, io.ErrClosedPipe)
 	}()
@@ -94,10 +94,10 @@ func TestServer_handleTOCRequest_sessClose(t *testing.T) {
 		}
 
 		sv := Server{
-			BOSProxy: OSCARProxy{},
-			Logger:   slog.Default(),
+			bosProxy: testOSCARProxy(t),
+			logger:   slog.Default(),
 		}
-		err := sv.handleTOCRequest(context.Background(), closeConn, sess, nil, fc)
+		err := sv.handleTOCRequest(context.Background(), closeConn, sess, NewChatRegistry(), fc)
 		assert.ErrorIs(t, err, errTOCProcessing)
 		assert.ErrorIs(t, err, errDisconnect)
 	}()
@@ -126,12 +126,10 @@ func TestServer_handleTOCRequest_replyFailure(t *testing.T) {
 		fc := wire.NewFlapClient(0, serverReader, serverWriter)
 
 		sv := Server{
-			BOSProxy: OSCARProxy{
-				Logger: slog.Default(),
-			},
-			Logger: slog.Default(),
+			bosProxy: testOSCARProxy(t),
+			logger:   slog.Default(),
 		}
-		err := sv.handleTOCRequest(context.Background(), closeConn, sess, nil, fc)
+		err := sv.handleTOCRequest(context.Background(), closeConn, sess, NewChatRegistry(), fc)
 		assert.ErrorIs(t, err, errServerWrite)
 		assert.ErrorIs(t, err, io.ErrClosedPipe)
 	}()
@@ -165,12 +163,10 @@ func TestServer_handleTOCRequest_happyPath(t *testing.T) {
 		}
 		fc := wire.NewFlapClient(0, serverReader, serverWriter)
 		sv := Server{
-			BOSProxy: OSCARProxy{
-				Logger: slog.Default(),
-			},
-			Logger: slog.Default(),
+			bosProxy: testOSCARProxy(t),
+			logger:   slog.Default(),
 		}
-		err := sv.handleTOCRequest(context.Background(), closeConn, newTestSession("me"), nil, fc)
+		err := sv.handleTOCRequest(context.Background(), closeConn, newTestSession("me"), NewChatRegistry(), fc)
 		assert.ErrorIs(t, err, errClientReq)
 		assert.ErrorIs(t, err, io.ErrClosedPipe)
 	}()

+ 8 - 7
server/toc/types.go

@@ -36,18 +36,19 @@ type ICBMService interface {
 }
 
 type OServiceService interface {
-	ClientOnline(ctx context.Context, _ wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error
+	ClientOnline(ctx context.Context, service uint16, bodyIn 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)
+	ServiceRequest(ctx context.Context, service uint16, sess *state.Session, frame wire.SNACFrame, bodyIn wire.SNAC_0x01_0x04_OServiceServiceRequest, advertisedHost string) (wire.SNACMessage, error)
 }
 
 type AuthService interface {
 	BUCPChallenge(ctx context.Context, bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
-	BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.SNACMessage, error)
-	FLAPLogin(ctx context.Context, frame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error)) (wire.TLVRestBlock, error)
-	RegisterBOSSession(ctx context.Context, authCookie []byte) (*state.Session, error)
-	RetrieveBOSSession(ctx context.Context, authCookie []byte) (*state.Session, error)
-	RegisterChatSession(ctx context.Context, authCookie []byte) (*state.Session, error)
+	BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error)
+	CrackCookie(authCookie []byte) (state.ServerCookie, error)
+	FLAPLogin(ctx context.Context, frame wire.FLAPSignonFrame, newUserFn func(screenName state.DisplayScreenName) (state.User, error), here string) (wire.TLVRestBlock, error)
+	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.Session, error)
+	RegisterChatSession(ctx context.Context, authCookie state.ServerCookie) (*state.Session, error)
+	RetrieveBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.Session, error)
 	Signout(ctx context.Context, sess *state.Session)
 	SignoutChat(ctx context.Context, sess *state.Session)
 }

+ 9 - 0
state/cookie.go

@@ -16,6 +16,15 @@ import (
 // authCookieLen is the fixed auth cookie length.
 const authCookieLen = 256
 
+// ServerCookie represents a token containing client metadata passed to the BOS
+// service upon connection.
+type ServerCookie struct {
+	Service    uint16
+	ScreenName DisplayScreenName `oscar:"len_prefix=uint8"`
+	ClientID   string            `oscar:"len_prefix=uint8"`
+	ChatCookie string            `oscar:"len_prefix=uint8"`
+}
+
 func NewHMACCookieBaker() (HMACCookieBaker, error) {
 	cb := HMACCookieBaker{}
 	cb.key = make([]byte, 32)

+ 21 - 0
wire/frames.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"fmt"
 	"io"
+	"sync"
 )
 
 type SNACError struct {
@@ -67,6 +68,7 @@ func NewFlapClient(startSeq uint32, r io.Reader, w io.Writer) *FlapClient {
 		sequence: startSeq,
 		r:        r,
 		w:        w,
+		mutex:    sync.Mutex{},
 	}
 }
 
@@ -78,6 +80,7 @@ type FlapClient struct {
 	sequence uint32
 	r        io.Reader
 	w        io.Writer
+	mutex    sync.Mutex
 }
 
 // Fixes a race condition caused by testify. Yup...
@@ -100,6 +103,9 @@ func (f *FlapClient) SendSignonFrame(tlvs []TLV) error {
 		return err
 	}
 
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
 	flap := FLAPFrame{
 		StartMarker: 42,
 		FrameType:   FLAPFrameSignon,
@@ -151,6 +157,9 @@ func (f *FlapClient) SendSignoffFrame(tlvs TLVRestBlock) error {
 		return err
 	}
 
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
 	flap := FLAPFrame{
 		StartMarker: 42,
 		FrameType:   FLAPFrameSignoff,
@@ -176,6 +185,9 @@ func (f *FlapClient) SendSNAC(frame SNACFrame, body any) error {
 		return err
 	}
 
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
 	flap := FLAPFrame{
 		StartMarker: 42,
 		FrameType:   FLAPFrameData,
@@ -191,6 +203,9 @@ func (f *FlapClient) SendSNAC(frame SNACFrame, body any) error {
 }
 
 func (f *FlapClient) SendDataFrame(payload []byte) error {
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
 	flap := FLAPFrame{
 		StartMarker: 42,
 		FrameType:   FLAPFrameData,
@@ -206,6 +221,9 @@ func (f *FlapClient) SendDataFrame(payload []byte) error {
 }
 
 func (f *FlapClient) SendKeepAliveFrame() error {
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
 	flap := FLAPFrame{
 		StartMarker: 42,
 		FrameType:   FLAPFrameKeepAlive,
@@ -234,6 +252,9 @@ func (f *FlapClient) ReceiveSNAC(frame *SNACFrame, body any) error {
 
 // Disconnect sends a signoff FLAP frame.
 func (f *FlapClient) Disconnect() error {
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
 	flap := FLAPFrameDisconnect{
 		StartMarker: 42,
 		FrameType:   FLAPFrameSignoff,

+ 1 - 0
wire/snacs.go

@@ -13,6 +13,7 @@ import (
 //
 
 const (
+	BOS         uint16 = 0x0000
 	OService    uint16 = 0x0001
 	Locate      uint16 = 0x0002
 	Buddy       uint16 = 0x0003