Mike há 1 semana atrás
pai
commit
44c1bfcf29

+ 1 - 1
docker-compose.yaml

@@ -21,7 +21,7 @@ services:
          && echo \"basicConstraints=critical,CA:FALSE\" > server.ext
          && echo \"keyUsage=critical,digitalSignature,keyEncipherment\" >> server.ext
          && echo \"extendedKeyUsage=serverAuth\" >> server.ext
-         && echo \"subjectAltName=DNS:${OSCAR_HOST},DNS:api.oscar.aol.com,DNS:api.screenname.aol.com,DNS:login.oscar.aol.com,DNS:my.screenname.aol.com,DNS:api.aim.net\" >> server.ext
+         && echo \"subjectAltName=DNS:${OSCAR_HOST},DNS:api.oscar.aol.com,DNS:api.screenname.aol.com,DNS:login.oscar.aol.com,DNS:my.screenname.aol.com,DNS:api.aim.net,DNS:api.icq.net,DNS:api.login.icq.net\" >> server.ext
          && openssl x509 -req
          -in server.csr
          -CA ca.crt -CAkey ca-key.pem -CAcreateserial

+ 1 - 0
foodgroup/auth.go

@@ -665,6 +665,7 @@ func (s AuthService) loginSuccessResponse(ctx context.Context, props loginProper
 		ScreenName:    props.screenName,
 		ClientID:      props.clientID,
 		MultiConnFlag: props.multiConnFlag,
+		TokenTTL:      uint32(props.tokenTTL.Seconds()),
 	}
 	if props.isKerberosPlaintextAuth || props.isKerberosRoastedAuth {
 		loginCookie.KerberosAuth = 1

+ 20 - 0
foodgroup/auth_test.go

@@ -75,6 +75,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:      uint32((state.DefaultCookieTTL).Seconds()),
 									ScreenName:    user.DisplayScreenName,
 									MultiConnFlag: uint8(wire.MultiConnFlagsRecentClient),
 								}
@@ -140,6 +141,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 							ttlIn: time.Hour,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:   uint32((time.Hour).Seconds()),
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -195,6 +197,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:      uint32((state.DefaultCookieTTL).Seconds()),
 									ScreenName:    user.DisplayScreenName,
 									MultiConnFlag: uint8(wire.MultiConnFlagsRecentClient),
 								}
@@ -323,6 +326,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:   uint32((state.DefaultCookieTTL).Seconds()),
 									ScreenName: user.DisplayScreenName,
 									ClientID:   "ICQ 2000b",
 								}
@@ -527,6 +531,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:   uint32((state.DefaultCookieTTL).Seconds()),
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -677,6 +682,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:   uint32((state.DefaultCookieTTL).Seconds()),
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -753,6 +759,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:   uint32((state.DefaultCookieTTL).Seconds()),
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -807,6 +814,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:   uint32((state.DefaultCookieTTL).Seconds()),
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -870,6 +878,7 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:   uint32((state.DefaultCookieTTL).Seconds()),
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -1029,6 +1038,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:   uint32((state.DefaultCookieTTL).Seconds()),
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -1076,6 +1086,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 							ttlIn: time.Hour,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:   uint32((time.Hour).Seconds()),
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -1123,6 +1134,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 							ttlIn: maxTokenTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:   uint32((maxTokenTTL).Seconds()),
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -1213,6 +1225,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:   uint32((state.DefaultCookieTTL).Seconds()),
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -1260,6 +1273,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:   uint32((state.DefaultCookieTTL).Seconds()),
 									ScreenName: user.DisplayScreenName,
 									ClientID:   "ICQ 2000b",
 								}
@@ -1395,6 +1409,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:   uint32((state.DefaultCookieTTL).Seconds()),
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -1449,6 +1464,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:   uint32((state.DefaultCookieTTL).Seconds()),
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -1495,6 +1511,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:   uint32((state.DefaultCookieTTL).Seconds()),
 									ScreenName: user.DisplayScreenName,
 								}
 								buf := &bytes.Buffer{}
@@ -1565,6 +1582,7 @@ func TestAuthService_FLAPLogin(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:   uint32((state.DefaultCookieTTL).Seconds()),
 									ScreenName: user.DisplayScreenName,
 									ClientID:   "AOL Instant Messenger (TM) version 1.1.19 for Java",
 								}
@@ -1722,6 +1740,7 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:      uint32((state.DefaultCookieTTL).Seconds()),
 									Service:       wire.BOS,
 									ScreenName:    user.DisplayScreenName,
 									ClientID:      "",
@@ -1861,6 +1880,7 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 							ttlIn: state.DefaultCookieTTL,
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
+									TokenTTL:      uint32((state.DefaultCookieTTL).Seconds()),
 									Service:       wire.BOS,
 									ScreenName:    user.DisplayScreenName,
 									ClientID:      "",

+ 76 - 63
foodgroup/oservice_test.go

@@ -79,11 +79,12 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 							dataIn: []byte{
 								0x00, 0x07, // admin service
 								0x02, 'm', 'e',
-								0x0,  // no client ID
-								0x0,  // no chat cookie
-								0x0,  // multi conn flag
-								0x0,  // kerberos flag
-								0x01, // session num
+								0x0,                // no client ID
+								0x0,                // no chat cookie
+								0x0,                // multi conn flag
+								0x0,                // kerberos flag
+								0x01,               // session num
+								0x0, 0x0, 0x0, 0x0, // no token ttl
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -128,11 +129,12 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 							dataIn: []byte{
 								0x00, 0x18, // alert service
 								0x02, 'm', 'e',
-								0x0,  // no client ID
-								0x0,  // no chat cookie
-								0x0,  // multi conn flag
-								0x0,  // kerberos flag
-								0x01, // session num
+								0x0,                // no client ID
+								0x0,                // no chat cookie
+								0x0,                // multi conn flag
+								0x0,                // kerberos flag
+								0x01,               // session num
+								0x0, 0x0, 0x0, 0x0, // no token ttl
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -175,13 +177,14 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 					cookieIssueParams: cookieIssueParams{
 						{
 							dataIn: []byte{
-								0x00, 0x10, // chatnav service
+								0x00, 0x10, // bart service
 								0x02, 'm', 'e',
-								0x0,  // no client ID
-								0x0,  // no chat cookie
-								0x0,  // multi conn flag
-								0x0,  // kerberos flag
-								0x01, // session num
+								0x0,                // no client ID
+								0x0,                // no chat cookie
+								0x0,                // multi conn flag
+								0x0,                // kerberos flag
+								0x01,               // session num
+								0x0, 0x0, 0x0, 0x0, // no token ttl
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -226,11 +229,12 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 							dataIn: []byte{
 								0x00, 0x0d, // chatnav service
 								0x02, 'm', 'e',
-								0x0,  // no client ID
-								0x0,  // no chat cookie
-								0x0,  // multi conn flag
-								0x0,  // kerberos flag
-								0x01, // session num
+								0x0,                // no client ID
+								0x0,                // no chat cookie
+								0x0,                // multi conn flag
+								0x0,                // kerberos flag
+								0x01,               // session num
+								0x0, 0x0, 0x0, 0x0, // no token ttl
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -295,9 +299,10 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 									0x02, 'm', 'e', // screen name
 									0x00, // no client ID
 									0x11, '4', '-', '0', '-', 't', 'h', 'e', '-', 'c', 'h', 'a', 't', '-', 'r', 'o', 'o', 'm',
-									0x0,  // multi conn flag
-									0x0,  // kerberos flag
-									0x01, // session num
+									0x0,                // multi conn flag
+									0x0,                // kerberos flag
+									0x01,               // session num
+									0x0, 0x0, 0x0, 0x0, // no token ttl
 								},
 								cookieOut: []byte("the-auth-cookie"),
 							},
@@ -341,13 +346,14 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 					cookieIssueParams: cookieIssueParams{
 						{
 							dataIn: []byte{
-								0x00, 0x0F, // chatnav service
+								0x00, 0x0F, // odir service
 								0x02, 'm', 'e',
-								0x0,  // no client ID
-								0x0,  // no chat cookie
-								0x0,  // multi conn flag
-								0x0,  // kerberos flag
-								0x01, // session num
+								0x0,                // no client ID
+								0x0,                // no chat cookie
+								0x0,                // multi conn flag
+								0x0,                // kerberos flag
+								0x01,               // session num
+								0x0, 0x0, 0x0, 0x0, // no token ttl
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -477,11 +483,12 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 							dataIn: []byte{
 								0x00, 0x07, // admin service
 								0x02, 'm', 'e',
-								0x0,  // no client ID
-								0x0,  // no chat cookie
-								0x0,  // multi conn flag
-								0x0,  // kerberos flag
-								0x01, // session num
+								0x0,                // no client ID
+								0x0,                // no chat cookie
+								0x0,                // multi conn flag
+								0x0,                // kerberos flag
+								0x01,               // session num
+								0x0, 0x0, 0x0, 0x0, // no token ttl
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -531,11 +538,12 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 							dataIn: []byte{
 								0x00, 0x18, // alert service
 								0x02, 'm', 'e',
-								0x0,  // no client ID
-								0x0,  // no chat cookie
-								0x0,  // multi conn flag
-								0x0,  // kerberos flag
-								0x01, // session num
+								0x0,                // no client ID
+								0x0,                // no chat cookie
+								0x0,                // multi conn flag
+								0x0,                // kerberos flag
+								0x01,               // session num
+								0x0, 0x0, 0x0, 0x0, // no token ttl
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -585,11 +593,12 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 							dataIn: []byte{
 								0x00, 0x10, // BART service
 								0x02, 'm', 'e',
-								0x0,  // no client ID
-								0x0,  // no chat cookie
-								0x0,  // multi conn flag
-								0x0,  // kerberos flag
-								0x01, // session num
+								0x0,                // no client ID
+								0x0,                // no chat cookie
+								0x0,                // multi conn flag
+								0x0,                // kerberos flag
+								0x01,               // session num
+								0x0, 0x0, 0x0, 0x0, // no token ttl
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -639,11 +648,12 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 							dataIn: []byte{
 								0x00, 0x0d, // chatnav service
 								0x02, 'm', 'e',
-								0x0,  // no client ID
-								0x0,  // no chat cookie
-								0x0,  // multi conn flag
-								0x0,  // kerberos flag
-								0x01, // session num
+								0x0,                // no client ID
+								0x0,                // no chat cookie
+								0x0,                // multi conn flag
+								0x0,                // kerberos flag
+								0x01,               // session num
+								0x0, 0x0, 0x0, 0x0, // no token ttl
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -709,9 +719,10 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 									0x02, 'm', 'e', // screen name
 									0x00, // no client ID
 									0x11, '4', '-', '0', '-', 't', 'h', 'e', '-', 'c', 'h', 'a', 't', '-', 'r', 'o', 'o', 'm',
-									0x0,  // multi conn flag
-									0x0,  // kerberos flag
-									0x01, // session num
+									0x0,                // multi conn flag
+									0x0,                // kerberos flag
+									0x01,               // session num
+									0x0, 0x0, 0x0, 0x0, // no token ttl
 								},
 								cookieOut: []byte("the-auth-cookie"),
 							},
@@ -762,11 +773,12 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 							dataIn: []byte{
 								0x00, 0x0F, // ODir service
 								0x02, 'm', 'e',
-								0x0,  // no client ID
-								0x0,  // no chat cookie
-								0x0,  // multi conn flag
-								0x0,  // kerberos flag
-								0x01, // session num
+								0x0,                // no client ID
+								0x0,                // no chat cookie
+								0x0,                // multi conn flag
+								0x0,                // kerberos flag
+								0x01,               // session num
+								0x0, 0x0, 0x0, 0x0, // no token ttl
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -816,11 +828,12 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 							dataIn: []byte{
 								0x00, 0x07, // admin service
 								0x02, 'm', 'e',
-								0x0,  // no client ID
-								0x0,  // no chat cookie
-								0x0,  // multi conn flag
-								0x0,  // kerberos flag
-								0x01, // session num
+								0x0,                // no client ID
+								0x0,                // no chat cookie
+								0x0,                // multi conn flag
+								0x0,                // kerberos flag
+								0x01,               // session num
+								0x0, 0x0, 0x0, 0x0, // no token ttl
 							},
 							cookieOut: []byte("the-cookie"),
 						},

+ 12 - 10
server/webapi/aim_handler.go

@@ -140,24 +140,20 @@ func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// Parse parameters
-	params := r.URL.Query()
-
-	// Get authentication token if provided
-	authToken := params.Get("a")
+	authToken := param(r, "a")
 
 	// Get client info
-	clientName := params.Get("clientName")
+	clientName := param(r, "clientName")
 	if clientName == "" {
 		clientName = "WebAIM"
 	}
-	clientVersion := params.Get("clientVersion")
+	clientVersion := param(r, "clientVersion")
 	if clientVersion == "" {
 		clientVersion = "1.0"
 	}
 
 	// Get events to subscribe to
-	eventsParam := params.Get("events")
+	eventsParam := param(r, "events")
 	var events []string
 	if eventsParam != "" {
 		events = strings.Split(eventsParam, ",")
@@ -175,7 +171,7 @@ func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 
 	// Get timeout settings
 	timeout := 60000 // Default 60 seconds for better stability with Gromit
-	if t := params.Get("timeout"); t != "" {
+	if t := param(r, "timeout"); t != "" {
 		if val, err := strconv.Atoi(t); err == nil && val > 0 {
 			timeout = val * 1000 // Convert to milliseconds
 		}
@@ -587,6 +583,12 @@ func (h *AimHandler) FetchEvents(w http.ResponseWriter, r *http.Request, session
 		newLastSeqNum = events[len(events)-1].SeqNum
 	}
 
+	// A nil slice renders as JSON null, which a client reading data.events
+	// strictly rejects.
+	if events == nil {
+		events = []Event{}
+	}
+
 	// Prepare response
 	data := &FetchEventsData{
 		Events:          events,
@@ -811,7 +813,7 @@ func (h *AimHandler) StartOSCARSession(w http.ResponseWriter, r *http.Request) {
 
 	resp := &StartOSCARSessionResponse{}
 	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
+	resp.Response.StatusText = "Ok"
 	resp.Response.Data.Host = host
 	resp.Response.Data.Port = port
 	// Base64, the encoding the client decodes the cookie with.

+ 4 - 4
server/webapi/aim_handler_test.go

@@ -78,7 +78,7 @@ func TestAimHandler_AddTempBuddy(t *testing.T) {
 				LastAccessed: time.Now(),
 			},
 			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["buddy1"],"resultCode":"success"}}}`,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{"buddyNames":["buddy1"],"resultCode":"success"}}}`,
 			checkSession: func(t *testing.T, session *Session) {
 				assert.NotNil(t, session.TempBuddies)
 				assert.True(t, session.TempBuddies["buddy1"])
@@ -99,7 +99,7 @@ func TestAimHandler_AddTempBuddy(t *testing.T) {
 				LastAccessed: time.Now(),
 			},
 			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["buddy1","buddy2","buddy3"],"resultCode":"success"}}}`,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{"buddyNames":["buddy1","buddy2","buddy3"],"resultCode":"success"}}}`,
 			checkSession: func(t *testing.T, session *Session) {
 				assert.NotNil(t, session.TempBuddies)
 				assert.True(t, session.TempBuddies["buddy1"])
@@ -124,7 +124,7 @@ func TestAimHandler_AddTempBuddy(t *testing.T) {
 				LastAccessed: time.Now(),
 			},
 			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["buddy2"],"resultCode":"success"}}}`,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{"buddyNames":["buddy2"],"resultCode":"success"}}}`,
 			checkSession: func(t *testing.T, session *Session) {
 				assert.NotNil(t, session.TempBuddies)
 				assert.True(t, session.TempBuddies["buddy1"])
@@ -160,7 +160,7 @@ func TestAimHandler_AddTempBuddy(t *testing.T) {
 				LastAccessed: time.Now(),
 			},
 			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["  buddy1  ","buddy2 "," buddy3"],"resultCode":"success"}}}`,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{"buddyNames":["  buddy1  ","buddy2 "," buddy3"],"resultCode":"success"}}}`,
 			checkSession: func(t *testing.T, session *Session) {
 				assert.NotNil(t, session.TempBuddies)
 				assert.True(t, session.TempBuddies["buddy1"])

+ 121 - 5
server/webapi/auth_handler.go

@@ -46,6 +46,18 @@ type UserAttributes struct {
 	LoginID string `json:"loginId" xml:"loginId"`
 }
 
+// GetInfoData is the auth/getInfo payload.
+type GetInfoData struct {
+	UserData GetInfoUser `json:"userData" xml:"userData"`
+}
+
+// GetInfoUser names the account the presented token belongs to. Clients read both
+// keys strictly, so neither is omitted when empty.
+type GetInfoUser struct {
+	LoginID     string `json:"loginId" xml:"loginId"`
+	DisplayName string `json:"displayName" xml:"displayName"`
+}
+
 // ClientLoginData is the clientLogin payload.
 type ClientLoginData struct {
 	Token          AuthToken `json:"token" xml:"token"`
@@ -194,11 +206,9 @@ func clientIDForDevID(devID string) string {
 }
 
 func (h *AuthHandler) loginRedirectURL(r *http.Request) string {
-	scheme := "http"
-	if r.TLS != nil {
-		scheme = "https"
-	}
-	return fmt.Sprintf("%s://%s/_cqr/login/login.psp", scheme, r.Host)
+	// requestScheme, not r.TLS: TLS is terminated upstream, so the scheme survives
+	// only in the header.
+	return fmt.Sprintf("%s://%s/_cqr/login/login.psp", requestScheme(r), r.Host)
 }
 
 // Logout clears the token cookie and sends the browser to the login page. A
@@ -308,6 +318,112 @@ func (h *AuthHandler) ClientLogin(w http.ResponseWriter, r *http.Request) {
 		"tokenTTL", ttl)
 }
 
+// defaultAuthFreshness is the reqAuthFreshness applied when a caller names none.
+const defaultAuthFreshness = 24 * time.Hour
+
+// GetInfo handles GET and POST /auth/getInfo, reporting which account the token in
+// "a" belongs to. It mints nothing: a caller that cannot be answered is told to
+// authenticate again.
+func (h *AuthHandler) GetInfo(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// This endpoint does not renew. Refusing gives a client expecting a token some
+	// status to react to; a 200 with no token gives it nothing.
+	if isTrueParam(param(r, "renewToken")) {
+		h.Logger.DebugContext(ctx, "getInfo: refused a renewal request")
+		h.sendGetInfoRedirect(w, r, http.StatusUnauthorized, "Unauthorized")
+		return
+	}
+
+	token := param(r, "a")
+	if token == "" {
+		h.sendGetInfoRedirect(w, r, http.StatusUnauthorized, "Unauthorized")
+		return
+	}
+
+	rawCookie, err := base64.URLEncoding.DecodeString(strings.TrimSpace(token))
+	if err != nil {
+		h.Logger.DebugContext(ctx, "getInfo: token is not valid base64", "error", err)
+		h.sendGetInfoRedirect(w, r, http.StatusUnauthorized, "Unauthorized")
+		return
+	}
+
+	serverCookie, expiry, err := h.AuthService.CrackCookie(rawCookie)
+	if err != nil {
+		h.Logger.DebugContext(ctx, "getInfo: token rejected", "error", err)
+		h.sendGetInfoRedirect(w, r, http.StatusUnauthorized, "Unauthorized")
+		return
+	}
+
+	// One key signs every cookie, so a service-transfer cookie verifies just like a
+	// login token while recording no authentication. A non-zero TokenTTL is the only
+	// positive evidence of a login: wire.BOS is 0x0000, so testing Service alone
+	// admits the linked-account transfer cookie (foodgroup/oservice.go:662), which
+	// then satisfies any freshness check.
+	if serverCookie.Service != wire.BOS || serverCookie.ChatCookie != "" || serverCookie.TokenTTL == 0 {
+		h.Logger.WarnContext(ctx, "getInfo: rejected a cookie that is not a login token",
+			"service", serverCookie.Service, "loginId", serverCookie.ScreenName)
+		h.sendGetInfoRedirect(w, r, http.StatusUnauthorized, "Unauthorized")
+		return
+	}
+
+	freshness, ok := authFreshness(r)
+	if !ok {
+		SendEnvelopeStatus(w, r, statusParameterError, "reqAuthFreshness is not a count of seconds", h.Logger)
+		return
+	}
+
+	if age := authAge(serverCookie, expiry); age > freshness {
+		h.Logger.DebugContext(ctx, "getInfo: authentication is too stale",
+			"loginId", serverCookie.ScreenName, "age", age, "required", freshness)
+		h.sendGetInfoRedirect(w, r, statusMoreAuthRequired, "More authentication required")
+		return
+	}
+
+	// The cookie's screen name carries the user's own capitalization; without a
+	// session there is nothing better to look up.
+	name := serverCookie.ScreenName.String()
+	SendOK(w, r, &GetInfoData{
+		UserData: GetInfoUser{LoginID: name, DisplayName: name},
+	}, h.Logger)
+}
+
+// sendGetInfoRedirect refuses a getInfo, naming where to authenticate instead.
+// SendEnvelopeStatus cannot be used: it sends no data, so the client has nowhere to
+// go.
+func (h *AuthHandler) sendGetInfoRedirect(w http.ResponseWriter, r *http.Request, statusCode int, statusText string) {
+	resp := BaseResponse{}
+	resp.Response.StatusCode = statusCode
+	resp.Response.StatusText = statusText
+	resp.Response.Data = &RedirectData{RedirectURL: h.loginRedirectURL(r)}
+	SendResponse(w, r, resp, h.Logger)
+}
+
+// authFreshness reads the reqAuthFreshness parameter, reporting false when it is
+// present but unusable. Absent, it is the spec's 24 hours.
+func authFreshness(r *http.Request) (time.Duration, bool) {
+	raw := strings.TrimSpace(param(r, "reqAuthFreshness"))
+	if raw == "" {
+		return defaultAuthFreshness, true
+	}
+	secs, err := strconv.ParseUint(raw, 10, 32)
+	if err != nil {
+		return 0, false
+	}
+	return time.Duration(secs) * time.Second, true
+}
+
+// authAge is how long ago the cookie's owner authenticated. Nothing records a login
+// time, but the granted lifetime subtracted from the expiry gives the issue instant.
+// Callers must reject a cookie with no TokenTTL first.
+//
+// CrackCookie has already rejected an expired cookie, so the age is always under the
+// granted lifetime: a freshness requirement at or above the grant never fires.
+func authAge(cookie state.ServerCookie, expiry time.Time) time.Duration {
+	issued := expiry.Add(-time.Duration(cookie.TokenTTL) * time.Second)
+	return max(time.Since(issued), 0)
+}
+
 // generateToken generates a secure random token.
 func (h *AuthHandler) generateToken() (string, error) {
 	b := make([]byte, 32)

+ 306 - 3
server/webapi/auth_handler_test.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/base64"
 	"errors"
+	"fmt"
 	"log/slog"
 	"net/http"
 	"net/http/httptest"
@@ -20,8 +21,8 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-// testAuthService implements AuthService for ClientLogin tests (only FLAPLogin and
-// CrackCookie are exercised).
+// testAuthService implements AuthService for the auth-handler tests (only
+// FLAPLogin and CrackCookie are exercised).
 type testAuthService struct {
 	flapLogin   func(ctx context.Context, inFrame wire.FLAPSignonFrame, endpointCfg config.Endpoint) (wire.TLVRestBlock, error)
 	crackCookie func(authCookie []byte) (state.ServerCookie, time.Time, error)
@@ -62,7 +63,11 @@ func crackSignedCookieExpiring(remaining time.Duration) func([]byte) (state.Serv
 		if !ok {
 			return state.ServerCookie{}, time.Time{}, errors.New("bad signature")
 		}
-		return state.ServerCookie{ScreenName: state.DisplayScreenName(name)}, time.Now().Add(remaining), nil
+		return state.ServerCookie{
+			Service:    wire.BOS,
+			ScreenName: state.DisplayScreenName(name),
+			TokenTTL:   uint32(shortTermTTL.Seconds()),
+		}, time.Now().Add(remaining), nil
 	}
 }
 
@@ -840,3 +845,301 @@ func TestSafeLoginRedirectURL(t *testing.T) {
 	assert.Equal(t, "http://localhost:8000/", safeLoginRedirectURL(req, "http://localhost:8000/"))
 	assert.Equal(t, "http://localhost/", safeLoginRedirectURL(req, "http://evil.example/"))
 }
+
+// getInfoHandler builds a handler whose CrackCookie returns cookie, expiring at
+// shortTermTTL from now unless the caller says otherwise.
+func getInfoHandler(cookie state.ServerCookie) *AuthHandler {
+	return &AuthHandler{
+		AuthService: &testAuthService{
+			crackCookie: func([]byte) (state.ServerCookie, time.Time, error) {
+				return cookie, time.Now().Add(time.Duration(cookie.TokenTTL) * time.Second), nil
+			},
+		},
+		Logger: slog.Default(),
+	}
+}
+
+func getInfoGET(h *AuthHandler, query string) *httptest.ResponseRecorder {
+	req := httptest.NewRequest(http.MethodGet, "/auth/getInfo?f=json&devId=ic1"+query, nil)
+	rr := httptest.NewRecorder()
+	h.GetInfo(rr, req)
+	return rr
+}
+
+func TestAuthHandler_GetInfo(t *testing.T) {
+	validToken := base64.URLEncoding.EncodeToString(signedCookieFor("ChattingChuck"))
+
+	tests := []struct {
+		name      string
+		query     string
+		crack     func([]byte) (state.ServerCookie, time.Time, error)
+		checkBody func(*testing.T, string)
+	}{
+		{
+			name:  "Success",
+			query: "&a=" + url.QueryEscape(validToken),
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":200`)
+				assert.Contains(t, body, `"userData":{"loginId":"ChattingChuck","displayName":"ChattingChuck"}`)
+				// getInfo reports; it does not mint. A token in the reply means
+				// renewal has crept back in.
+				assert.NotContains(t, body, `"token"`)
+				assert.NotContains(t, body, `"expiresIn"`)
+			},
+		},
+		{
+			name:  "NoToken",
+			query: "",
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":401`)
+				// The web client re-authenticates from data.redirectURL on any
+				// non-200, and reports a hard failure without it.
+				assert.Contains(t, body, `"redirectURL":"http://example.com/_cqr/login/login.psp"`)
+				assert.NotContains(t, body, "userData")
+			},
+		},
+		{
+			name:  "MalformedToken",
+			query: "&a=not-base64!!",
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":401`)
+				assert.Contains(t, body, `"redirectURL":`)
+				assert.NotContains(t, body, "userData")
+			},
+		},
+		{
+			name:  "RejectedToken",
+			query: "&a=" + url.QueryEscape(base64.URLEncoding.EncodeToString([]byte("unsigned"))),
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":401`)
+				assert.Contains(t, body, `"redirectURL":`)
+				assert.NotContains(t, body, "userData")
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			handler := &AuthHandler{
+				AuthService: &testAuthService{crackCookie: crackSignedCookie},
+				Logger:      slog.Default(),
+			}
+			tt.checkBody(t, getInfoGET(handler, tt.query).Body.String())
+		})
+	}
+}
+
+// TestAuthHandler_GetInfo_RejectsNonLoginTokens covers the one thing CrackCookie does
+// not check: what the cookie is for. One key signs every cookie, so a service-transfer
+// cookie verifies like a login token while recording no authentication.
+func TestAuthHandler_GetInfo_RejectsNonLoginTokens(t *testing.T) {
+	tests := []struct {
+		name       string
+		cookie     state.ServerCookie
+		wantStatus int
+	}{
+		{
+			name:       "login cookie is accepted",
+			cookie:     state.ServerCookie{Service: wire.BOS, ScreenName: "chuck", TokenTTL: 3600},
+			wantStatus: 200,
+		},
+		{
+			name:       "chat transfer cookie is refused",
+			cookie:     state.ServerCookie{Service: wire.Chat, ScreenName: "chuck", ChatCookie: "room-1"},
+			wantStatus: 401,
+		},
+		{
+			name:       "bart transfer cookie is refused",
+			cookie:     state.ServerCookie{Service: wire.BART, ScreenName: "chuck", SessionNum: 1},
+			wantStatus: 401,
+		},
+		{
+			name:       "chatnav transfer cookie is refused",
+			cookie:     state.ServerCookie{Service: wire.ChatNav, ScreenName: "chuck", SessionNum: 1},
+			wantStatus: 401,
+		},
+		{
+			// The one transfer cookie issued as wire.BOS
+			// (foodgroup/oservice.go:662). wire.BOS is the zero value, so only the
+			// absent grant distinguishes it from a login.
+			name:       "linked-account transfer cookie is refused",
+			cookie:     state.ServerCookie{Service: wire.BOS, ScreenName: "chuck", MultiConnFlag: 1},
+			wantStatus: 401,
+		},
+		{
+			// A login cookie has no room to belong to, so one carrying a chat
+			// cookie did not come from a login.
+			name:       "login cookie carrying a chat cookie is refused",
+			cookie:     state.ServerCookie{Service: wire.BOS, ScreenName: "chuck", ChatCookie: "room-1", TokenTTL: 3600},
+			wantStatus: 401,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			token := base64.URLEncoding.EncodeToString(signedCookieFor("chuck"))
+			body := getInfoGET(getInfoHandler(tt.cookie), "&a="+url.QueryEscape(token)).Body.String()
+
+			assert.Contains(t, body, fmt.Sprintf(`"statusCode":%d`, tt.wantStatus))
+			if tt.wantStatus != 200 {
+				assert.NotContains(t, body, "chuck")
+			}
+		})
+	}
+}
+
+// TestAuthHandler_GetInfo_Freshness drives both outcomes from one cookie, varying only
+// reqAuthFreshness. A test that used a different cookie per outcome could pass because
+// the cookie was rejected rather than because the freshness rule fired.
+func TestAuthHandler_GetInfo_Freshness(t *testing.T) {
+	// Authenticated an hour ago: expiry is a full grant from then, so it sits
+	// shortTermTTL-minus-an-hour in the future.
+	hourOldAuth := func([]byte) (state.ServerCookie, time.Time, error) {
+		cookie := state.ServerCookie{
+			Service:    wire.BOS,
+			ScreenName: "ChattingChuck",
+			TokenTTL:   uint32(shortTermTTL.Seconds()),
+		}
+		return cookie, time.Now().Add(shortTermTTL - time.Hour), nil
+	}
+	handler := &AuthHandler{
+		AuthService: &testAuthService{crackCookie: hourOldAuth},
+		Logger:      slog.Default(),
+	}
+	token := url.QueryEscape(base64.URLEncoding.EncodeToString(signedCookieFor("ChattingChuck")))
+
+	t.Run("within the default window", func(t *testing.T) {
+		body := getInfoGET(handler, "&a="+token).Body.String()
+
+		assert.Contains(t, body, `"statusCode":200`)
+		assert.Contains(t, body, `"loginId":"ChattingChuck"`)
+	})
+
+	t.Run("stale against a narrower window redirects", func(t *testing.T) {
+		body := getInfoGET(handler, "&a="+token+"&reqAuthFreshness=60").Body.String()
+
+		assert.Contains(t, body, `"statusCode":330`)
+		assert.Contains(t, body, `"redirectURL":"http://example.com/_cqr/login/login.psp"`)
+		// 330 says "authenticate again", so it must not also answer the question.
+		assert.NotContains(t, body, "userData")
+	})
+
+	t.Run("fresh against a window that still covers it", func(t *testing.T) {
+		body := getInfoGET(handler, "&a="+token+"&reqAuthFreshness=7200").Body.String()
+
+		assert.Contains(t, body, `"statusCode":200`)
+	})
+
+	t.Run("unusable reqAuthFreshness is a parameter error", func(t *testing.T) {
+		body := getInfoGET(handler, "&a="+token+"&reqAuthFreshness=soon").Body.String()
+
+		assert.Contains(t, body, `"statusCode":462`)
+	})
+
+	t.Run("a cookie naming no grant is refused, not waved through", func(t *testing.T) {
+		// Without a TokenTTL there is no issue instant, so no freshness claim can
+		// be made; answering 200 would assert one.
+		noTTL := getInfoHandler(state.ServerCookie{Service: wire.BOS, ScreenName: "ChattingChuck"})
+		body := getInfoGET(noTTL, "&a="+token+"&reqAuthFreshness=99999999").Body.String()
+
+		assert.Contains(t, body, `"statusCode":401`)
+		assert.NotContains(t, body, "userData")
+	})
+}
+
+// TestAuthHandler_GetInfo_AcceptsGETAndPOST pins that the parameters may arrive either
+// way, which is the reason the route is registered for both methods.
+func TestAuthHandler_GetInfo_AcceptsGETAndPOST(t *testing.T) {
+	token := base64.URLEncoding.EncodeToString(signedCookieFor("ChattingChuck"))
+	cookie := state.ServerCookie{
+		Service:    wire.BOS,
+		ScreenName: "ChattingChuck",
+		TokenTTL:   uint32(shortTermTTL.Seconds()),
+	}
+
+	getBody := getInfoGET(getInfoHandler(cookie), "&a="+url.QueryEscape(token)).Body.String()
+
+	form := url.Values{"f": {"json"}, "devId": {"ic1"}, "a": {token}}
+	req := httptest.NewRequest(http.MethodPost, "/auth/getInfo", strings.NewReader(form.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr := httptest.NewRecorder()
+	getInfoHandler(cookie).GetInfo(rr, req)
+
+	assert.Contains(t, getBody, `"loginId":"ChattingChuck"`)
+	assert.Equal(t, getBody, rr.Body.String())
+}
+
+// TestAuthHandler_GetInfo_EchoesRequestID covers the paths that hand-build their
+// envelope, bypassing SendOK. Only a BaseResponse gets the request id filled in by
+// normalizeEnvelope, and a JSONP client discards a reply that is missing it.
+func TestAuthHandler_GetInfo_EchoesRequestID(t *testing.T) {
+	token := url.QueryEscape(base64.URLEncoding.EncodeToString(signedCookieFor("ChattingChuck")))
+	staleAuth := &AuthHandler{
+		AuthService: &testAuthService{crackCookie: func([]byte) (state.ServerCookie, time.Time, error) {
+			cookie := state.ServerCookie{Service: wire.BOS, ScreenName: "ChattingChuck", TokenTTL: uint32(shortTermTTL.Seconds())}
+			return cookie, time.Now().Add(shortTermTTL - time.Hour), nil
+		}},
+		Logger: slog.Default(),
+	}
+	ok := &AuthHandler{
+		AuthService: &testAuthService{crackCookie: crackSignedCookie},
+		Logger:      slog.Default(),
+	}
+
+	tests := []struct {
+		name       string
+		handler    *AuthHandler
+		query      string
+		wantStatus int
+	}{
+		{name: "200", handler: ok, query: "&a=" + token, wantStatus: 200},
+		{name: "330", handler: staleAuth, query: "&a=" + token + "&reqAuthFreshness=60", wantStatus: 330},
+		{name: "401", handler: ok, query: "", wantStatus: 401},
+		{name: "462", handler: ok, query: "&a=" + token + "&reqAuthFreshness=soon", wantStatus: 462},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			body := getInfoGET(tt.handler, tt.query+"&r=req-42").Body.String()
+
+			assert.Contains(t, body, fmt.Sprintf(`"statusCode":%d`, tt.wantStatus))
+			assert.Contains(t, body, `"requestId":"req-42"`)
+		})
+	}
+}
+
+// TestAuthHandler_GetInfo_RefusesRenewal covers the clients that still ask this
+// endpoint for a replacement token. Refusing is what lets them recover: any non-200
+// sends them back through a full login.
+func TestAuthHandler_GetInfo_RefusesRenewal(t *testing.T) {
+	token := url.QueryEscape(base64.URLEncoding.EncodeToString(signedCookieFor("ChattingChuck")))
+	handler := &AuthHandler{
+		AuthService: &testAuthService{crackCookie: crackSignedCookie},
+		Logger:      slog.Default(),
+	}
+
+	t.Run("a renewal request is refused even with a good token", func(t *testing.T) {
+		body := getInfoGET(handler, "&a="+token+"&renewToken=true").Body.String()
+
+		assert.Contains(t, body, `"statusCode":401`)
+		assert.Contains(t, body, `"redirectURL":`)
+		// Neither an answer nor a token: the caller is told to log in again.
+		assert.NotContains(t, body, "userData")
+		assert.NotContains(t, body, `"token"`)
+	})
+
+	t.Run("renewToken=false still gets an answer", func(t *testing.T) {
+		// Only an actual request to renew is refused. A client spelling out that
+		// it does not want one is asking the question this endpoint answers.
+		body := getInfoGET(handler, "&a="+token+"&renewToken=false").Body.String()
+
+		assert.Contains(t, body, `"statusCode":200`)
+		assert.Contains(t, body, `"loginId":"ChattingChuck"`)
+	})
+
+	t.Run("an ordinary request is unaffected", func(t *testing.T) {
+		body := getInfoGET(handler, "&a="+token).Body.String()
+
+		assert.Contains(t, body, `"statusCode":200`)
+	})
+}

+ 8 - 3
server/webapi/buddy_list_manager.go

@@ -34,7 +34,10 @@ func NewBuddyListManager(feedbagService FeedbagService, locateService LocateServ
 
 // BuddyGroup represents a group in the WebAPI buddy list format.
 type BuddyGroup struct {
-	Name    string      `json:"name" xml:"name"`
+	Name string `json:"name" xml:"name"`
+	// ID is the feedbag group id, read strictly by clients. It follows Name to keep
+	// the rendered element order unchanged.
+	ID      int         `json:"id" xml:"id"`
 	Buddies []BuddyInfo `json:"buddies" xml:"buddies>buddy"`
 	Recent  bool        `json:"recent,omitempty" xml:"recent,omitempty"`
 	// Smart is null or a number. It is a pointer rather than an interface so
@@ -153,7 +156,7 @@ func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, sess *Sessio
 		if groupName == "" {
 			groupName = "Buddies"
 		}
-		wg := BuddyGroup{Name: groupName, Buddies: []BuddyInfo{}}
+		wg := BuddyGroup{ID: int(gid), Name: groupName, Buddies: []BuddyInfo{}}
 		for _, bid := range g.order {
 			b, ok := g.buddies[bid]
 			if !ok {
@@ -225,7 +228,9 @@ func (m *BuddyListManager) getBuddyInfo(ctx context.Context, instance *state.Ses
 		info.OnlineTime = int64(tod)
 	}
 
-	if userInfo.IsAway() {
+	if st := statusBitState(userInfo.TLVUserInfo); st != "" {
+		info.State = st
+	} else if userInfo.IsAway() {
 		info.State = "away"
 		if msg, ok := userInfo.LocateInfo.String(wire.LocateTLVTagsInfoUnavailableData); ok {
 			info.AwayMsg = msg

+ 10 - 0
server/webapi/buddy_list_manager_test.go

@@ -80,6 +80,7 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 			want: []BuddyGroup{
 				{
 					Name: "Buddies",
+					ID:   100,
 					Buddies: []BuddyInfo{
 						offlineWebAPIBuddy("user1", "user1"),
 						offlineWebAPIBuddy("user2", "user2"),
@@ -120,6 +121,7 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 			want: []BuddyGroup{
 				{
 					Name: "Buddies",
+					ID:   100,
 					// The buddy is offline, so no locate reply supplies a display
 					// name and displayId falls back to the normalized feedbag name.
 					Buddies: []BuddyInfo{withAlias(offlineWebAPIBuddy("bob", "bob"), "Bob Smith")},
@@ -140,6 +142,7 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 			want: []BuddyGroup{
 				{
 					Name:    "Buddies",
+					ID:      100,
 					Buddies: []BuddyInfo{offlineWebAPIBuddy("mikekelly", "Mike Kelly")},
 				},
 			},
@@ -161,6 +164,7 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 			want: []BuddyGroup{
 				{
 					Name:    "Buddies",
+					ID:      100,
 					Buddies: []BuddyInfo{offlineWebAPIBuddy("alice", "alice")},
 				},
 			},
@@ -182,10 +186,12 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 			want: []BuddyGroup{
 				{
 					Name:    "Buddies",
+					ID:      100,
 					Buddies: []BuddyInfo{offlineWebAPIBuddy("friend1", "friend1")},
 				},
 				{
 					Name:    "Family",
+					ID:      200,
 					Buddies: []BuddyInfo{offlineWebAPIBuddy("mom", "mom")},
 				},
 			},
@@ -205,6 +211,7 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 			want: []BuddyGroup{
 				{
 					Name: "Buddies",
+					ID:   100,
 					Buddies: []BuddyInfo{
 						offlineWebAPIBuddy("secondinslice", "secondInSlice"),
 						offlineWebAPIBuddy("firstinslice", "firstInSlice"),
@@ -229,10 +236,12 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 			want: []BuddyGroup{
 				{
 					Name:    "Family",
+					ID:      200,
 					Buddies: []BuddyInfo{offlineWebAPIBuddy("infamily", "inFamily")},
 				},
 				{
 					Name:    "Buddies",
+					ID:      100,
 					Buddies: []BuddyInfo{offlineWebAPIBuddy("inbuddies", "inBuddies")},
 				},
 			},
@@ -251,6 +260,7 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 			want: []BuddyGroup{
 				{
 					Name:    "Buddies",
+					ID:      100,
 					Buddies: []BuddyInfo{offlineWebAPIBuddy("solo", "solo")},
 				},
 			},

+ 58 - 99
server/webapi/buddylist_handler.go

@@ -23,8 +23,14 @@ func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request, sess
 	ctx := r.Context()
 	aimsid := r.URL.Query().Get("aimsid")
 
-	buddyName := strings.TrimSpace(r.URL.Query().Get("buddy"))
-	groupName := strings.TrimSpace(r.URL.Query().Get("group"))
+	buddyName := strings.TrimSpace(param(r, "buddy"))
+	groupName := strings.TrimSpace(param(r, "group"))
+
+	// preAuthorized says the caller permits the contact they are adding to add
+	// them back without an authorization prompt. authorizationMsg is the note
+	// shown to that contact alongside the grant.
+	preAuthorized := isTrueParam(param(r, "preAuthorized"))
+	authorizationMsg := strings.TrimSpace(param(r, "authorizationMsg"))
 
 	if buddyName == "" {
 		SendError(w, r, http.StatusBadRequest, "missing buddy parameter")
@@ -36,30 +42,16 @@ func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request, sess
 	}
 
 	// Add buddy to feedbag
-	resultCode, buddyInfo := h.addBuddyToFeedbag(ctx, session, buddyName, groupName)
+	resultCode := h.addBuddyToFeedbag(ctx, session, buddyName, groupName, preAuthorized, authorizationMsg)
 
 	// Prepare response
-	responseData := &ResultCodeData{ResultCode: resultCode}
-	if resultCode == "success" {
-		responseData.BuddyInfo = buddyInfo
-	}
-
-	SendOK(w, r, responseData, h.Logger)
-
-	if resultCode == "success" {
-		groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
-		if err != nil {
-			h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
-		} else {
-			blPayload := &BuddyListData{Groups: groups}
-			session.EventQueue.Push(EventTypeBuddyList, blPayload)
-		}
-	}
+	sendMutationResult(w, r, resultCode, h.Logger)
 
 	h.Logger.InfoContext(ctx, "buddy added",
 		"aimsid", aimsid,
 		"buddy", buddyName,
 		"group", groupName,
+		"preAuthorized", preAuthorized,
 		"result", resultCode,
 	)
 }
@@ -77,17 +69,7 @@ func (h *BuddyListHandler) AddGroup(w http.ResponseWriter, r *http.Request, sess
 
 	resultCode := h.addGroupToFeedbag(ctx, session, groupName)
 
-	SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
-
-	if resultCode == "success" {
-		groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
-		if err != nil {
-			h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
-		} else {
-			blPayload := &BuddyListData{Groups: groups}
-			session.EventQueue.Push(EventTypeBuddyList, blPayload)
-		}
-	}
+	sendMutationResult(w, r, resultCode, h.Logger)
 
 	h.Logger.InfoContext(ctx, "buddy list group added",
 		"aimsid", aimsid,
@@ -127,7 +109,7 @@ func (h *BuddyListHandler) addGroupToFeedbag(ctx context.Context, sess *Session,
 		return "error"
 	}
 
-	return "success"
+	return resultSuccess
 }
 
 // RemoveBuddy handles GET /buddylist/removeBuddy requests.
@@ -149,17 +131,7 @@ func (h *BuddyListHandler) RemoveBuddy(w http.ResponseWriter, r *http.Request, s
 		h.Logger.ErrorContext(ctx, "remove buddy failed", "err", rmErr.Error())
 	}
 
-	SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
-
-	if resultCode == "success" {
-		groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
-		if err != nil {
-			h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
-		} else {
-			blPayload := &BuddyListData{Groups: groups}
-			session.EventQueue.Push(EventTypeBuddyList, blPayload)
-		}
-	}
+	sendMutationResult(w, r, resultCode, h.Logger)
 
 	h.Logger.InfoContext(ctx, "buddy removed",
 		"aimsid", aimsid,
@@ -186,17 +158,7 @@ func (h *BuddyListHandler) RemoveGroup(w http.ResponseWriter, r *http.Request, s
 		h.Logger.ErrorContext(ctx, "remove group failed", "err", rmErr.Error())
 	}
 
-	SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
-
-	if resultCode == "success" {
-		groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
-		if err != nil {
-			h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
-		} else {
-			blPayload := &BuddyListData{Groups: groups}
-			session.EventQueue.Push(EventTypeBuddyList, blPayload)
-		}
-	}
+	sendMutationResult(w, r, resultCode, h.Logger)
 
 	h.Logger.InfoContext(ctx, "buddy list group removed",
 		"aimsid", aimsid,
@@ -206,7 +168,7 @@ func (h *BuddyListHandler) RemoveGroup(w http.ResponseWriter, r *http.Request, s
 }
 
 // addBuddyToFeedbag adds a buddy to the user's feedbag.
-func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, sess *Session, buddyName, groupName string) (string, *BuddyPresenceInfo) {
+func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, sess *Session, buddyName, groupName string, preAuthorized bool, authorizationMsg string) string {
 	defer sess.InvalidateAliases()
 
 	// Retrieve current feedbag
@@ -214,13 +176,13 @@ func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, sess *Session,
 	snac, err := h.FeedbagService.Query(ctx, sess.OSCARSession, frame)
 	if err != nil {
 		h.Logger.ErrorContext(ctx, "failed to retrieve feedbag", "err", err.Error())
-		return "error", nil
+		return "error"
 	}
 
 	reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
 	if !ok {
 		// todo what
-		return "error", nil
+		return "error"
 	}
 
 	fl := state.NewFeedbagList(reply.Items, rand.Intn)
@@ -230,17 +192,17 @@ func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, sess *Session,
 		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
 		if _, err := h.FeedbagService.UpsertItem(ctx, sess.OSCARSession, frame, pending); err != nil {
 			h.Logger.ErrorContext(ctx, "failed to add buddy", "err", err.Error())
-			return "error", nil
+			return "error"
 		}
 	}
 
 	added, err := fl.AddBuddy(groupName, buddyName, "", "")
 	if err != nil {
 		h.Logger.ErrorContext(ctx, "failed to add buddy to feedbag", "err", err.Error())
-		return "error", nil
+		return "error"
 	}
 	if !added {
-		return "alreadyExists", nil
+		return "alreadyExists"
 	}
 
 	if pending := fl.PendingUpdates(); len(pending) > 0 {
@@ -259,7 +221,7 @@ func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, sess *Session,
 			frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
 			if _, err := h.FeedbagService.UpsertItem(ctx, sess.OSCARSession, frame, buddies); err != nil {
 				h.Logger.ErrorContext(ctx, "failed to add buddy", "err", err.Error())
-				return "error", nil
+				return "error"
 			}
 		}
 
@@ -268,23 +230,32 @@ func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, sess *Session,
 				frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
 				if _, err := h.FeedbagService.UpsertItem(ctx, sess.OSCARSession, frame, []wire.FeedbagItem{item}); err != nil {
 					h.Logger.ErrorContext(ctx, "failed to add buddy", "err", err.Error())
-					return "error", nil
+					return "error"
 				}
 			}
 		}
 	}
 
-	// Get current presence for the buddy
-	buddyInfo := &BuddyPresenceInfo{
-		AimID:     state.NewIdentScreenName(buddyName).String(),
-		DisplayID: buddyName,
-		State:     "offline", // Default to offline
-		UserType:  "aim",
+	if preAuthorized {
+		// preAuthorized is a grant, not a retry: the caller is allowing the buddy
+		// they just added to add them back without an authorization prompt. This
+		// is the same request an ICQ client makes with SNAC(0x13,0x14), so it
+		// records the grant and tells the buddy about it.
+		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagPreAuthorizeBuddy}
+		body := wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy{
+			ScreenName: buddyName,
+			Message:    authorizationMsg,
+		}
+		// The buddy is on the list either way, so a failure here is logged rather
+		// than reported: answering "error" would only make the client retry an add
+		// that already succeeded.
+		if _, err := h.FeedbagService.PreAuthorizeBuddy(ctx, sess.OSCARSession, frame, body); err != nil {
+			h.Logger.ErrorContext(ctx, "failed to pre-authorize buddy",
+				"buddy", buddyName, "err", err.Error())
+		}
 	}
 
-	// TODO: Check actual presence status and update buddyInfo accordingly
-
-	return "success", buddyInfo
+	return resultSuccess
 }
 
 // RenameGroup handles GET /buddylist/renameGroup requests.
@@ -308,11 +279,7 @@ func (h *BuddyListHandler) RenameGroup(w http.ResponseWriter, r *http.Request, s
 		h.Logger.ErrorContext(ctx, "rename group failed", "err", rnErr.Error())
 	}
 
-	SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
-
-	if resultCode == "success" {
-		h.pushBuddyListEvent(ctx, session)
-	}
+	sendMutationResult(w, r, resultCode, h.Logger)
 
 	h.Logger.InfoContext(ctx, "buddy list group renamed",
 		"aimsid", aimsid,
@@ -350,11 +317,7 @@ func (h *BuddyListHandler) MoveBuddy(w http.ResponseWriter, r *http.Request, ses
 		h.Logger.ErrorContext(ctx, "move buddy failed", "err", mvErr.Error())
 	}
 
-	SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
-
-	if resultCode == "success" {
-		h.pushBuddyListEvent(ctx, session)
-	}
+	sendMutationResult(w, r, resultCode, h.Logger)
 
 	h.Logger.InfoContext(ctx, "buddy moved",
 		"aimsid", aimsid,
@@ -387,11 +350,7 @@ func (h *BuddyListHandler) SetBuddyAttribute(w http.ResponseWriter, r *http.Requ
 		h.Logger.ErrorContext(ctx, "set buddy attribute failed", "err", saErr.Error())
 	}
 
-	SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
-
-	if resultCode == "success" {
-		h.pushBuddyListEvent(ctx, session)
-	}
+	sendMutationResult(w, r, resultCode, h.Logger)
 
 	h.Logger.InfoContext(ctx, "buddy attribute set",
 		"aimsid", aimsid,
@@ -424,11 +383,7 @@ func (h *BuddyListHandler) SetGroupAttribute(w http.ResponseWriter, r *http.Requ
 		h.Logger.ErrorContext(ctx, "set group attribute failed", "err", saErr.Error())
 	}
 
-	SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
-
-	if resultCode == "success" {
-		h.pushBuddyListEvent(ctx, session)
-	}
+	sendMutationResult(w, r, resultCode, h.Logger)
 
 	h.Logger.InfoContext(ctx, "buddy list group attribute set",
 		"aimsid", aimsid,
@@ -438,15 +393,21 @@ func (h *BuddyListHandler) SetGroupAttribute(w http.ResponseWriter, r *http.Requ
 	)
 }
 
-// pushBuddyListEvent refreshes the buddy list and pushes it to the session's
-// event queue so the Web client re-renders after a mutation.
-func (h *BuddyListHandler) pushBuddyListEvent(ctx context.Context, session *Session) {
-	groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
-	if err != nil {
-		h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
+// resultSuccess is the result code the buddy list methods no longer report; see
+// sendMutationResult.
+const resultSuccess = "success"
+
+// sendMutationResult answers a buddy-list mutation. It reports only an error or a
+// request that did not apply ("alreadyExists", "notFound"). Success is not reported:
+// the authoritative roster arrives asynchronously as a buddylist event, and an item
+// accepted here can still be declined by the feedbag service.
+func sendMutationResult(w http.ResponseWriter, r *http.Request, resultCode string, logger *slog.Logger) {
+	if resultCode == resultSuccess {
+		// Nil data renders as an empty "data":{} — no resultCode, no buddyInfo.
+		SendOK(w, r, nil, logger)
 		return
 	}
-	session.EventQueue.Push(EventTypeBuddyList, &BuddyListData{Groups: groups})
+	SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, logger)
 }
 
 // ResultCodeData is the payload the buddy list editing methods answer with.
@@ -455,8 +416,6 @@ func (h *BuddyListHandler) pushBuddyListEvent(ctx context.Context, session *Sess
 // reads resultCode from it, so the server sends one.
 type ResultCodeData struct {
 	ResultCode string `json:"resultCode" xml:"resultCode"`
-	// BuddyInfo accompanies a successful addBuddy only.
-	BuddyInfo *BuddyPresenceInfo `json:"buddyInfo,omitempty" xml:"buddyInfo,omitempty"`
 	// BuddyNames accompanies the temp-buddy methods only.
 	BuddyNames []string `json:"buddyNames,omitempty" xml:"buddyNames>buddyName,omitempty"`
 }

+ 174 - 147
server/webapi/buddylist_handler_test.go

@@ -1,6 +1,7 @@
 package webapi
 
 import (
+	"context"
 	"errors"
 	"log/slog"
 	"net/http"
@@ -35,6 +36,7 @@ func TestBuddyListHandler_AddBuddy(t *testing.T) {
 				session := &Session{
 					AimSID:       aimsid,
 					ScreenName:   state.DisplayScreenName("testuser"),
+					OSCARSession: state.NewSession().AddInstance(),
 					EventQueue:   NewEventQueue(100),
 					LastAccessed: time.Now(),
 				}
@@ -47,42 +49,10 @@ func TestBuddyListHandler_AddBuddy(t *testing.T) {
 				// addBuddyToFeedbag calls UpsertItem twice: once for group order update, once for buddy insert
 				fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
 					Return((*wire.SNACMessage)(nil), nil)
-				blmFs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
-					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
-				return session
-			},
-			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyInfo":{"aimId":"newbuddy","displayId":"newbuddy","state":"offline","userType":"aim"},"resultCode":"success"}}}`,
-		},
-		{
-			name: "Success_EventPushSkippedOnBLMError",
-			queryParams: map[string][]string{
-				"aimsid": {"test-session"},
-				"buddy":  {"newbuddy"},
-				"group":  {"Friends"},
-			},
-			setupMocks: func(sm *mockSessionResolver, fs *mockFeedbagService, blmFs *mockFeedbagService, aimsid string) *Session {
-				session := &Session{
-					AimSID:       aimsid,
-					ScreenName:   state.DisplayScreenName("testuser"),
-					EventQueue:   NewEventQueue(100),
-					LastAccessed: time.Now(),
-				}
-
-				items := []wire.FeedbagItem{
-					{GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
-				}
-				fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
-					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
-				// addBuddyToFeedbag calls UpsertItem twice: once for group order update, once for buddy insert
-				fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
-					Return((*wire.SNACMessage)(nil), nil)
-				blmFs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
-					Return(wire.SNACMessage{}, errors.New("feedbag unavailable")).Once()
 				return session
 			},
 			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyInfo":{"aimId":"newbuddy","displayId":"newbuddy","state":"offline","userType":"aim"},"resultCode":"success"}}}`,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
 		},
 		{
 			name: "Error_BuddyAlreadyExists",
@@ -95,6 +65,7 @@ func TestBuddyListHandler_AddBuddy(t *testing.T) {
 				session := &Session{
 					AimSID:       aimsid,
 					ScreenName:   state.DisplayScreenName("testuser"),
+					OSCARSession: state.NewSession().AddInstance(),
 					EventQueue:   NewEventQueue(100),
 					LastAccessed: time.Now(),
 				}
@@ -109,7 +80,7 @@ func TestBuddyListHandler_AddBuddy(t *testing.T) {
 				return session
 			},
 			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"alreadyExists"}}}`,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"alreadyExists"}}}`,
 		},
 		{
 			name: "Error_MissingBuddyParameter",
@@ -180,6 +151,7 @@ func TestBuddyListHandler_AddGroup(t *testing.T) {
 		return &Session{
 			AimSID:       aimsid,
 			ScreenName:   state.DisplayScreenName("testuser"),
+			OSCARSession: state.NewSession().AddInstance(),
 			EventQueue:   NewEventQueue(100),
 			LastAccessed: time.Now(),
 		}
@@ -211,28 +183,10 @@ func TestBuddyListHandler_AddGroup(t *testing.T) {
 					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
 				fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
 					Return((*wire.SNACMessage)(nil), nil).Once()
-				blmFs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
-					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
 				return sess
 			},
 			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
-		},
-		{
-			name:        "Success_EventPushSkippedOnBLMError",
-			queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"NewGroup"}},
-			setup: func(sm *mockSessionResolver, fs *mockFeedbagService, blmFs *mockFeedbagService, aimsid string) *Session {
-				sess := newSession(aimsid)
-				fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
-					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
-				fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
-					Return((*wire.SNACMessage)(nil), nil).Once()
-				blmFs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
-					Return(wire.SNACMessage{}, errors.New("feedbag unavailable")).Once()
-				return sess
-			},
-			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
 		},
 		{
 			name:        "Success_GroupAlreadyExists",
@@ -247,7 +201,7 @@ func TestBuddyListHandler_AddGroup(t *testing.T) {
 				return sess
 			},
 			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"alreadyExists"}}}`,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"alreadyExists"}}}`,
 		},
 		{
 			name:        "Error_FeedbagQueryFails",
@@ -259,7 +213,7 @@ func TestBuddyListHandler_AddGroup(t *testing.T) {
 				return sess
 			},
 			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"error"}}}`,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"error"}}}`,
 		},
 	}
 
@@ -342,42 +296,10 @@ func TestBuddyListHandler_RemoveBuddy(t *testing.T) {
 					Return((*wire.SNACMessage)(nil), nil).Once()
 				fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
 					Return((*wire.SNACMessage)(nil), nil).Once()
-				// Second Query for GetBuddyListForUser event push
-				fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
-					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
-				return sess
-			},
-			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
-		},
-		{
-			name:        "Success_EventPushSkippedOnBLMError",
-			queryParams: map[string][]string{"aimsid": {"sess"}, "buddy": {"someBuddy"}, "group": {"Friends"}},
-			setup: func(sm *mockSessionResolver, blm *BuddyListManager, fs *mockFeedbagService, aimsid string) *Session {
-				sess := &Session{
-					AimSID:       aimsid,
-					ScreenName:   state.DisplayScreenName("testuser"),
-					OSCARSession: state.NewSession().AddInstance(),
-					EventQueue:   NewEventQueue(100),
-					LastAccessed: time.Now(),
-				}
-				items := []wire.FeedbagItem{
-					{GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
-					{GroupID: 1, ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "someBuddy"},
-				}
-				fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
-					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
-				fs.EXPECT().DeleteItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
-					Return((*wire.SNACMessage)(nil), nil).Once()
-				fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
-					Return((*wire.SNACMessage)(nil), nil).Once()
-				// BLM query fails — response should still be success
-				fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
-					Return(wire.SNACMessage{}, errors.New("feedbag unavailable")).Once()
 				return sess
 			},
 			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
 		},
 		{
 			name:        "Success_BuddyNotFound",
@@ -398,7 +320,7 @@ func TestBuddyListHandler_RemoveBuddy(t *testing.T) {
 				return sess
 			},
 			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"notFound"}}}`,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"notFound"}}}`,
 		},
 		{
 			name:        "Success_GroupNotFound",
@@ -415,7 +337,7 @@ func TestBuddyListHandler_RemoveBuddy(t *testing.T) {
 				return sess
 			},
 			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"notFound"}}}`,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"notFound"}}}`,
 		},
 	}
 
@@ -502,47 +424,10 @@ func TestBuddyListHandler_RemoveGroup(t *testing.T) {
 					Return((*wire.SNACMessage)(nil), nil).Once()
 				fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
 					Return((*wire.SNACMessage)(nil), nil).Once()
-				// Second Query for GetBuddyListForUser event push
-				fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
-					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
-				return sess
-			},
-			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
-		},
-		{
-			name:        "Success_EventPushSkippedOnBLMError",
-			queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"Friends"}},
-			setup: func(sm *mockSessionResolver, blm *BuddyListManager, fs *mockFeedbagService, aimsid string) *Session {
-				sess := &Session{
-					AimSID:       aimsid,
-					ScreenName:   state.DisplayScreenName("testuser"),
-					OSCARSession: state.NewSession().AddInstance(),
-					EventQueue:   NewEventQueue(100),
-					LastAccessed: time.Now(),
-				}
-				items := []wire.FeedbagItem{
-					{
-						GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "",
-						TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1}),
-						}},
-					},
-					{GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
-				}
-				fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
-					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
-				fs.EXPECT().DeleteItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
-					Return((*wire.SNACMessage)(nil), nil).Once()
-				fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
-					Return((*wire.SNACMessage)(nil), nil).Once()
-				// BLM query fails — response should still be success
-				fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
-					Return(wire.SNACMessage{}, errors.New("feedbag unavailable")).Once()
 				return sess
 			},
 			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
 		},
 		{
 			name:        "Success_GroupNotFound",
@@ -559,7 +444,7 @@ func TestBuddyListHandler_RemoveGroup(t *testing.T) {
 				return sess
 			},
 			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"notFound"}}}`,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"notFound"}}}`,
 		},
 	}
 
@@ -657,7 +542,7 @@ func TestRequireSession(t *testing.T) {
 				sm.EXPECT().TouchSession(mock.Anything, aimsid).Return(nil)
 			},
 			expectedStatusCode: http.StatusOK,
-			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{}}}`,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
 			expectNextCalled:   true,
 		},
 	}
@@ -672,7 +557,7 @@ func TestRequireSession(t *testing.T) {
 				nextCalled = true
 				resp := BaseResponse{}
 				resp.Response.StatusCode = 200
-				resp.Response.StatusText = "OK"
+				resp.Response.StatusText = "Ok"
 				SendResponse(w, r, resp, slog.Default())
 			}
 
@@ -738,12 +623,10 @@ func TestBuddyListHandler_RenameGroup(t *testing.T) {
 					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
 				fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
 					Return((*wire.SNACMessage)(nil), nil).Once()
-				fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
-					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
 				return sessWithOSCAR(aimsid)
 			},
 			expectStatusCode: http.StatusOK,
-			expectResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+			expectResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
 		},
 		{
 			name:        "NotFound",
@@ -754,7 +637,7 @@ func TestBuddyListHandler_RenameGroup(t *testing.T) {
 				return sessWithOSCAR(aimsid)
 			},
 			expectStatusCode: http.StatusOK,
-			expectResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"notFound"}}}`,
+			expectResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"notFound"}}}`,
 		},
 	}
 
@@ -826,12 +709,10 @@ func TestBuddyListHandler_MoveBuddy(t *testing.T) {
 					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
 				fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
 					Return((*wire.SNACMessage)(nil), nil).Once()
-				fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
-					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
 				return sessWithOSCAR(aimsid)
 			},
 			expectStatusCode: http.StatusOK,
-			expectResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+			expectResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
 		},
 		{
 			name:        "NotFound_Buddy",
@@ -845,7 +726,7 @@ func TestBuddyListHandler_MoveBuddy(t *testing.T) {
 				return sessWithOSCAR(aimsid)
 			},
 			expectStatusCode: http.StatusOK,
-			expectResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"notFound"}}}`,
+			expectResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"notFound"}}}`,
 		},
 	}
 
@@ -913,12 +794,10 @@ func TestBuddyListHandler_SetBuddyAttribute(t *testing.T) {
 					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
 				fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
 					Return((*wire.SNACMessage)(nil), nil).Once()
-				fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
-					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
 				return sessWithOSCAR(aimsid)
 			},
 			expectStatusCode: http.StatusOK,
-			expectResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+			expectResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
 		},
 		{
 			name:        "NotFound",
@@ -929,7 +808,7 @@ func TestBuddyListHandler_SetBuddyAttribute(t *testing.T) {
 				return sessWithOSCAR(aimsid)
 			},
 			expectStatusCode: http.StatusOK,
-			expectResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"notFound"}}}`,
+			expectResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"notFound"}}}`,
 		},
 	}
 
@@ -997,12 +876,10 @@ func TestBuddyListHandler_SetGroupAttribute(t *testing.T) {
 					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
 				fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
 					Return((*wire.SNACMessage)(nil), nil).Once()
-				fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
-					Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
 				return sessWithOSCAR(aimsid)
 			},
 			expectStatusCode: http.StatusOK,
-			expectResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"success"}}}`,
+			expectResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
 		},
 		{
 			name:        "NotFound",
@@ -1013,7 +890,7 @@ func TestBuddyListHandler_SetGroupAttribute(t *testing.T) {
 				return sessWithOSCAR(aimsid)
 			},
 			expectStatusCode: http.StatusOK,
-			expectResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"notFound"}}}`,
+			expectResponse:   `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"notFound"}}}`,
 		},
 	}
 
@@ -1038,3 +915,153 @@ func TestBuddyListHandler_SetGroupAttribute(t *testing.T) {
 		})
 	}
 }
+
+func TestBuddyListHandler_AddBuddy_PreAuthorized(t *testing.T) {
+	tests := []struct {
+		name          string
+		screenName    string
+		buddy         string
+		preAuthorized string
+		authMsg       string
+		grantErr      error
+		wantGrant     bool
+	}{
+		{
+			name:          "no grant without preAuthorized",
+			screenName:    "100002",
+			buddy:         "100001",
+			preAuthorized: "",
+			wantGrant:     false,
+		},
+		{
+			name:          "icq->icq grant carries the authorization message",
+			screenName:    "100002",
+			buddy:         "100001",
+			preAuthorized: "1",
+			authMsg:       "Hello! Please add me to your buddylist.",
+			wantGrant:     true,
+		},
+		{
+			// The grant is not gated on protocol: the store no-ops for a user who
+			// cannot hold one, so an aim->aim add is granted the same way.
+			name:          "aim->aim is granted too",
+			screenName:    "mike",
+			buddy:         "joemama",
+			preAuthorized: "1",
+			wantGrant:     true,
+		},
+		{
+			// The buddy is on the list either way. Reporting an error here would
+			// only make the client retry an add that already succeeded.
+			name:          "a failed grant does not fail the add",
+			screenName:    "100002",
+			buddy:         "100001",
+			preAuthorized: "1",
+			grantErr:      errors.New("record failed"),
+			wantGrant:     true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sm := newMockSessionResolver(t)
+			fs := newMockFeedbagService(t)
+			blmFs := newMockFeedbagService(t)
+
+			// The grant is recorded against the caller, so the instance has to
+			// carry their identity.
+			oscarSess := state.NewSession()
+			oscarSess.SetIdentScreenName(state.NewIdentScreenName(tt.screenName))
+
+			session := &Session{
+				AimSID:       "sid",
+				OSCARSession: oscarSess.AddInstance(),
+				ScreenName:   state.DisplayScreenName(tt.screenName),
+				EventQueue:   NewEventQueue(100),
+				LastAccessed: time.Now(),
+			}
+			sm.EXPECT().GetSession(mock.Anything, "sid").Return(session, nil)
+			sm.EXPECT().TouchSession(mock.Anything, "sid").Return(nil).Maybe()
+
+			items := []wire.FeedbagItem{
+				{GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
+			}
+			fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
+				Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
+
+			// The pending flag is what this used to set. Capture it to confirm it
+			// is gone, whatever preAuthorized says.
+			var sawPending, sawBuddyItem bool
+			fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+				RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, upserted []wire.FeedbagItem) (*wire.SNACMessage, error) {
+					for _, item := range upserted {
+						if item.ClassID != wire.FeedbagClassIdBuddy {
+							continue
+						}
+						sawBuddyItem = true
+						if item.HasTag(wire.FeedbagAttributesPending) {
+							sawPending = true
+						}
+						break
+					}
+					return nil, nil
+				})
+
+			// No expectation in the ungranted case: an unexpected call fails the
+			// test, which is the assertion.
+			var grants int
+			var gotGrantor state.IdentScreenName
+			var gotFrame wire.SNACFrame
+			var gotBody wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy
+			if tt.wantGrant {
+				fs.EXPECT().PreAuthorizeBuddy(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					RunAndReturn(func(_ context.Context, instance *state.SessionInstance, frame wire.SNACFrame, body wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy) (*wire.SNACMessage, error) {
+						grants++
+						gotGrantor = instance.IdentScreenName()
+						gotFrame = frame
+						gotBody = body
+						return nil, tt.grantErr
+					}).Once()
+			}
+
+			h := &BuddyListHandler{
+				FeedbagService: fs,
+				BuddyListManager: &BuddyListManager{
+					feedbagService: blmFs,
+					iconSource:     newTestIconSource(t),
+					logger:         slog.Default(),
+				},
+				Logger: slog.Default(),
+			}
+
+			query := "aimsid=sid&buddy=" + tt.buddy + "&group=Friends"
+			if tt.preAuthorized != "" {
+				query += "&preAuthorized=" + tt.preAuthorized
+			}
+			if tt.authMsg != "" {
+				query += "&authorizationMsg=" + url.QueryEscape(tt.authMsg)
+			}
+			req := httptest.NewRequest(http.MethodGet, "/buddylist/addBuddy?"+query, nil)
+			rr := httptest.NewRecorder()
+			requireSession(sm, h.AddBuddy).ServeHTTP(rr, req)
+
+			assert.Equal(t, http.StatusOK, rr.Code)
+			// The handler claims no outcome either way; whether the item was
+			// stored is conveyed by the async buddylist event, not this reply.
+			assert.JSONEq(t, `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`, rr.Body.String())
+			assert.True(t, sawBuddyItem, "expected a buddy item to be upserted")
+			assert.False(t, sawPending, "preAuthorized must not mark the stored item pending")
+
+			if !tt.wantGrant {
+				return
+			}
+			assert.Equal(t, 1, grants, "expected exactly one pre-authorization")
+			assert.Equal(t, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagPreAuthorizeBuddy}, gotFrame)
+			// The grant flows from the caller to the buddy they added: the buddy
+			// may now add the caller back without a prompt.
+			assert.Equal(t, state.NewIdentScreenName(tt.screenName), gotGrantor)
+			assert.Equal(t, tt.buddy, gotBody.ScreenName)
+			assert.Equal(t, tt.authMsg, gotBody.Message)
+		})
+	}
+}

+ 24 - 4
server/webapi/events.go

@@ -23,6 +23,7 @@ const (
 	EventTypeSessionEnded EventType = "sessionEnded"
 	EventTypeTyping       EventType = "typing"
 	EventTypePermitDeny   EventType = "permitDeny"
+	EventTypeClientError  EventType = "clientError"
 )
 
 // Event represents an event to be delivered to a web client.
@@ -49,15 +50,20 @@ type PresenceEvent struct {
 	BuddyIcon  string `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`   // Absolute icon URL; empty preserves the client's current icon, the placeholder URL clears it
 }
 
+// imfPlainText is the message-format tag put on delivered IMs; bodies are always
+// plain text.
+const imfPlainText = "plain"
+
 // IMEvent represents an instant message event.
 type IMEvent struct {
 	Source    UserInfo `json:"source" xml:"source"`
 	Message   string   `json:"message" xml:"message"`
 	MsgID     string   `json:"msgId,omitempty" xml:"msgId,omitempty"`
 	Timestamp int64    `json:"timestamp" xml:"timestamp"`
-	// AutoResp is always sent to AMF clients, false included, because the client
-	// reads it unconditionally when it builds the message.
-	AutoResp bool `json:"autoresponse,omitempty" xml:"autoresponse,omitempty" amf3:"autoresponse"`
+	// Imf is read strictly and then ignored, so its only job is to exist.
+	Imf string `json:"imf" xml:"imf"`
+	// AutoResp is always sent, false included: clients read it unconditionally.
+	AutoResp bool `json:"autoresponse" xml:"autoresponse" amf3:"autoresponse"`
 }
 
 // OfflineIMEvent represents a message that was stored while the user was signed
@@ -76,7 +82,9 @@ type OfflineIMEvent struct {
 	Message   string `json:"message" xml:"message"`
 	MsgID     string `json:"msgId,omitempty" xml:"msgId,omitempty"`
 	Timestamp int64  `json:"timestamp" xml:"timestamp"`
-	AutoResp  bool   `json:"autoresponse,omitempty" xml:"autoresponse,omitempty" amf3:"autoresponse"`
+	// Imf and AutoResp must be present, as on IMEvent.
+	Imf      string `json:"imf" xml:"imf"`
+	AutoResp bool   `json:"autoresponse" xml:"autoresponse" amf3:"autoresponse"`
 }
 
 // SentIMEvent represents a sent instant message event.
@@ -92,6 +100,18 @@ type SentIMEvent struct {
 	AutoResp  bool     `json:"autoResponse,omitempty" xml:"autoResponse,omitempty" amf3:"autoresponse"`
 }
 
+// ClientErrorEvent tells a sender that the recipient rejected a message the server
+// had already delivered. im/sendIM answers synchronously when the ICBM service
+// itself refuses a send; a rejection from the recipient's own client arrives here
+// instead. Channel "data" names the rendezvous channel.
+type ClientErrorEvent struct {
+	Source UserInfo `json:"source" xml:"source"`
+	// Cookie names the failed message by the msgId im/sendIM returned. It is empty
+	// when another instance of the account sent the message.
+	Cookie  string `json:"cookie" xml:"cookie"`
+	Channel string `json:"channel" xml:"channel"`
+}
+
 // UserInfo represents basic user information in events.
 // AimID is the normalized screen name the client keys users by. DisplayID is the
 // screen name as its owner formatted it. Friendly is the viewer's private alias for

+ 9 - 19
server/webapi/im_handler.go

@@ -22,43 +22,28 @@ type MessagingHandler struct {
 	Logger         *slog.Logger
 }
 
-// queryOrFormParam returns a request parameter from the query string or, for POST
-// requests, from application/x-www-form-urlencoded body fields. The Web AIM client
-// sends t/offlineIM/etc. on the query string and puts message in the POST body.
-func queryOrFormParam(r *http.Request, key string) string {
-	if v := r.URL.Query().Get(key); v != "" {
-		return v
-	}
-	if r.Method == http.MethodPost {
-		if err := r.ParseForm(); err == nil {
-			return r.FormValue(key)
-		}
-	}
-	return ""
-}
-
 // SendIM handles the /im/sendIM endpoint for sending instant messages
 func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *Session) {
 	ctx := r.Context()
 
 	// Parse parameters
-	recipient := queryOrFormParam(r, "t")
+	recipient := param(r, "t")
 	if recipient == "" {
 		SendError(w, r, http.StatusBadRequest, "missing required parameter: t (recipient)")
 		return
 	}
 
-	message := queryOrFormParam(r, "message")
+	message := param(r, "message")
 	if message == "" {
 		SendError(w, r, http.StatusBadRequest, "missing required parameter: message")
 		return
 	}
 
 	// Parse optional parameters
-	autoResponse := queryOrFormParam(r, "autoResponse") == "1"
+	autoResponse := param(r, "autoResponse") == "1"
 	// The client sets offlineIM once it believes the recipient is offline and
 	// storable; it sends the literal "true" rather than "1".
-	offlineIM := queryOrFormParam(r, "offlineIM") == "true" || queryOrFormParam(r, "offlineIM") == "1"
+	offlineIM := param(r, "offlineIM") == "true" || param(r, "offlineIM") == "1"
 
 	// Generate message cookie
 	var cookie [8]byte
@@ -115,6 +100,11 @@ func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *
 		SubGroup:  wire.ICBMChannelMsgToHost,
 		RequestID: wire.ReqIDFromServer,
 	}
+	// Recorded before the send: ChannelMsgToHost delivers synchronously, so an
+	// ICBMClientErr can reach the pump before it returns. A mapping left by a failed
+	// send ages out under sentIMCookieLimit.
+	sess.RecordSentIM(cookieUint64, messageID)
+
 	resp, err := h.ICBMService.ChannelMsgToHost(r.Context(), sess.OSCARSession, frame, clientIM)
 
 	if err != nil {

+ 59 - 0
server/webapi/im_handler_test.go

@@ -503,3 +503,62 @@ func TestMessagingHandler_SetTyping_MissingAimsid(t *testing.T) {
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
 }
+
+// ChannelMsgToHost delivers before it returns, so an ICBMClientErr can reach the pump
+// while SendIM is still inside that call. Recording the cookie->msgId mapping after
+// the send loses that race and emits a clientError naming no message. The interleaving
+// is driven deterministically by handling the error SNAC from inside the mock.
+func TestMessagingHandler_SendIM_ClientErrorDuringSendNamesTheMessage(t *testing.T) {
+	oscarInstance := state.NewSession().AddInstance()
+	icbmService := newMockICBMService(t)
+
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+	sess, err := sessionMgr.GetSession(context.Background(), aimsid)
+	require.NoError(t, err)
+
+	icbmService.EXPECT().
+		ChannelMsgToHost(mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNACFrame"), mock.AnythingOfType("wire.SNAC_0x04_0x06_ICBMChannelMsgToHost")).
+		Run(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) {
+			sess.handleSNACMessage(wire.SNACMessage{
+				Frame: wire.SNACFrame{FoodGroup: wire.ICBM, SubGroup: wire.ICBMClientErr},
+				Body: wire.SNAC_0x04_0x0B_ICBMClientErr{
+					Cookie:     inBody.Cookie,
+					ChannelID:  wire.ICBMChannelIM,
+					ScreenName: "Recipient",
+				},
+			})
+		}).
+		Return(nil, nil)
+
+	handler := &MessagingHandler{
+		ICBMService:    icbmService,
+		LocateService:  stubLocateService(t, ""),
+		FeedbagService: stubFeedbagService(t, "someone", ""),
+		Logger:         slog.Default(),
+	}
+
+	req, reqErr := http.NewRequest("GET", "/im/sendIM?aimsid="+aimsid+"&t=recipient&message=hello", nil)
+	require.NoError(t, reqErr)
+	rr := httptest.NewRecorder()
+	requireSession(sessionMgr, handler.SendIM).ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+
+	var sentMsgID string
+	var clientErr ClientErrorEvent
+	for _, event := range sess.EventQueue.GetAllEvents() {
+		switch event.Type {
+		case EventTypeSentIM:
+			if e, ok := event.Data.(SentIMEvent); ok {
+				sentMsgID = e.MsgID
+			}
+		case EventTypeClientError:
+			clientErr, _ = event.Data.(ClientErrorEvent)
+		}
+	}
+
+	// The cookie the client is told about must be the msgId it was handed for the
+	// very message that failed.
+	require.NotEmpty(t, sentMsgID)
+	assert.Equal(t, sentMsgID, clientErr.Cookie)
+	assert.Equal(t, "recipient", clientErr.Source.AimID)
+}

+ 135 - 41
server/webapi/memberdir_handler.go

@@ -1,9 +1,11 @@
 package webapi
 
 import (
+	"context"
 	"fmt"
 	"log/slog"
 	"net/http"
+	"net/url"
 	"strconv"
 	"strings"
 
@@ -67,7 +69,16 @@ type MemberDirInfo struct {
 
 // MemberDirResults wraps a directory search result set.
 type MemberDirResults struct {
-	Results MemberDirInfoArray `json:"results" xml:"results"`
+	Results MemberDirSearchResults `json:"results" xml:"results"`
+}
+
+// MemberDirSearchResults is the matched profile list plus its counters. All three
+// counters are required: clients read them strictly.
+type MemberDirSearchResults struct {
+	NTotal    int             `json:"nTotal" xml:"nTotal"`
+	NSkipped  int             `json:"nSkipped" xml:"nSkipped"`
+	NProfiles int             `json:"nProfiles" xml:"nProfiles"`
+	InfoArray []MemberDirInfo `json:"infoArray" xml:"infoArray>info"`
 }
 
 // MemberDirInfoArray is the list of matched profiles.
@@ -83,21 +94,42 @@ func (h *MemberDirHandler) Search(w http.ResponseWriter, r *http.Request, sessio
 	ctx := r.Context()
 
 	fields := parseMatch(r.URL.Query().Get("match"))
-	inBody := buildDirInfoQuery(fields)
+	self := session.ScreenName.IdentScreenName()
 
-	reply, err := h.DirSearchService.InfoQuery(ctx, wire.SNACFrame{}, inBody)
-	if err != nil {
-		h.Logger.ErrorContext(ctx, "memberDir search failed", "err", err.Error())
-		SendOK(w, r, &MemberDirResults{Results: MemberDirInfoArray{InfoArray: []MemberDirInfo{}}}, h.Logger)
-		return
+	// ODir matches interests, names and email but never the screen name, so an
+	// identity lookup runs alongside the directory search.
+	profiles := make([]MemberDirProfile, 0, 8)
+	seen := make(map[string]bool)
+	addProfile := func(profile MemberDirProfile) {
+		// Exclude the requesting user from their own search results.
+		if profile.AimID == "" || profile.AimID == self.String() || seen[profile.AimID] {
+			return
+		}
+		seen[profile.AimID] = true
+		profiles = append(profiles, profile)
 	}
 
-	body, ok := reply.Body.(wire.SNAC_0x0F_0x03_InfoReply)
-	if !ok || body.Status != wire.ODirSearchResponseOK {
-		// Missing/insufficient params or an empty directory: return no results
-		// rather than an error so the client simply shows an empty result set.
-		SendOK(w, r, &MemberDirResults{Results: MemberDirInfoArray{InfoArray: []MemberDirInfo{}}}, h.Logger)
-		return
+	if profile, found := h.exactScreenNameMatch(ctx, fields["keyword"]); found {
+		addProfile(profile)
+	}
+
+	// A failed directory search is not fatal: the screen-name match may still stand.
+	if reply, err := h.DirSearchService.InfoQuery(ctx, wire.SNACFrame{}, buildDirInfoQuery(fields)); err != nil {
+		h.Logger.ErrorContext(ctx, "memberDir search failed", "err", err.Error())
+	} else if body, ok := reply.Body.(wire.SNAC_0x0F_0x03_InfoReply); ok && body.Status == wire.ODirSearchResponseOK {
+		for _, result := range body.Results.List {
+			profile := MemberDirProfile{}
+			if sn, ok := result.String(wire.ODirTLVScreenName); ok && sn != "" {
+				profile.DisplayID = sn
+				profile.AimID = state.NewIdentScreenName(sn).String()
+			}
+			profile.FirstName, _ = result.String(wire.ODirTLVFirstName)
+			profile.LastName, _ = result.String(wire.ODirTLVLastName)
+			profile.State, _ = result.String(wire.ODirTLVState)
+			profile.City, _ = result.String(wire.ODirTLVCity)
+			profile.Country, _ = result.String(wire.ODirTLVCountry)
+			addProfile(profile)
+		}
 	}
 
 	limit := defaultMemberDirLimit
@@ -106,28 +138,25 @@ func (h *MemberDirHandler) Search(w http.ResponseWriter, r *http.Request, sessio
 			limit = n
 		}
 	}
-	self := session.ScreenName.IdentScreenName()
-
-	infoArray := make([]MemberDirInfo, 0, len(body.Results.List))
-	for _, result := range body.Results.List {
-		profile := MemberDirProfile{}
-		if sn, ok := result.String(wire.ODirTLVScreenName); ok && sn != "" {
-			profile.DisplayID = sn
-			profile.AimID = state.NewIdentScreenName(sn).String()
+	skip := 0
+	if v := r.URL.Query().Get("nToSkip"); v != "" {
+		if n, err := strconv.Atoi(v); err == nil && n > 0 {
+			skip = n
 		}
-		profile.FirstName, _ = result.String(wire.ODirTLVFirstName)
-		profile.LastName, _ = result.String(wire.ODirTLVLastName)
-		profile.State, _ = result.String(wire.ODirTLVState)
-		profile.City, _ = result.String(wire.ODirTLVCity)
-		profile.Country, _ = result.String(wire.ODirTLVCountry)
-		// Exclude the requesting user from their own search results.
-		if profile.AimID == self.String() {
+	}
+	// matched counts every profile the query matched; infoArray holds the page of
+	// them this response carries, after nToSkip and nToGet are applied.
+	matched := 0
+	infoArray := make([]MemberDirInfo, 0, len(profiles))
+	for _, profile := range profiles {
+		matched++
+		if matched <= skip {
 			continue
 		}
-		infoArray = append(infoArray, MemberDirInfo{Profile: profile})
 		if len(infoArray) >= limit {
-			break
+			continue
 		}
+		infoArray = append(infoArray, MemberDirInfo{Profile: profile})
 	}
 
 	h.Logger.DebugContext(ctx, "memberDir search",
@@ -136,7 +165,48 @@ func (h *MemberDirHandler) Search(w http.ResponseWriter, r *http.Request, sessio
 		"results", len(infoArray),
 	)
 
-	SendOK(w, r, &MemberDirResults{Results: MemberDirInfoArray{InfoArray: infoArray}}, h.Logger)
+	SendOK(w, r, &MemberDirResults{Results: MemberDirSearchResults{
+		NTotal:    matched,
+		NSkipped:  skip,
+		NProfiles: len(infoArray),
+		InfoArray: infoArray,
+	}}, h.Logger)
+}
+
+// exactScreenNameMatch resolves query as a screen name, reporting whether a user
+// by that name exists along with their directory profile.
+func (h *MemberDirHandler) exactScreenNameMatch(ctx context.Context, query string) (MemberDirProfile, bool) {
+	query = strings.TrimSpace(query)
+	if query == "" {
+		return MemberDirProfile{}, false
+	}
+
+	reply, err := h.LocateService.DirInfo(ctx, wire.SNACFrame{}, wire.SNAC_0x02_0x0B_LocateGetDirInfo{ScreenName: query})
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "memberDir search: screen name lookup failed",
+			"screenName", query, "err", err.Error())
+		return MemberDirProfile{}, false
+	}
+	body, ok := reply.Body.(wire.SNAC_0x02_0x0C_LocateGetDirReply)
+	if !ok {
+		return MemberDirProfile{}, false
+	}
+	// DirInfo answers for any name, appending directory TLVs only for a user that
+	// exists, so their presence — not their values — is the existence test.
+	if !body.HasTag(wire.ODirTLVFirstName) {
+		return MemberDirProfile{}, false
+	}
+
+	profile := MemberDirProfile{
+		AimID:     state.NewIdentScreenName(query).String(),
+		DisplayID: query,
+	}
+	profile.FirstName, _ = body.String(wire.ODirTLVFirstName)
+	profile.LastName, _ = body.String(wire.ODirTLVLastName)
+	profile.State, _ = body.String(wire.ODirTLVState)
+	profile.City, _ = body.String(wire.ODirTLVCity)
+	profile.Country, _ = body.String(wire.ODirTLVCountry)
+	return profile, true
 }
 
 // Get handles GET /memberDir/get. The "t" param names the screen names to look
@@ -188,17 +258,16 @@ func (h *MemberDirHandler) Get(w http.ResponseWriter, r *http.Request, session *
 	SendOK(w, r, &MemberDirInfoArray{InfoArray: infoArray}, h.Logger)
 }
 
-// Update handles GET /memberDir/update. The "Edit Your Name" form sends repeated
-// "set=key=value" params — always firstName and lastName, plus a hideLevel
-// web-search visibility flag. We persist first/last name into the user's OSCAR
-// directory info; hideLevel has no directory storage, so it is ignored.
+// Update handles /memberDir/update over GET and POST. Clients send repeated
+// "set=key=value" params; first and last name are persisted to the directory
+// record and every other field is ignored, having nowhere to be stored.
 //
 // SetDirectoryInfo replaces the whole directory record, so we read the current
 // info first and re-send every field, overlaying only what the form changed.
 func (h *MemberDirHandler) Update(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
-	sets := parseSet(r.URL.Query()["set"])
+	sets := memberDirSets(r)
 
 	// Seed from the current record so untouched fields survive the replace. A
 	// failed read must abort: writing a record we couldn't seed would blank
@@ -267,13 +336,20 @@ func buildDirInfoQuery(fields map[string]string) wire.SNAC_0x0F_0x02_InfoQuery {
 			inBody.Append(wire.NewTLVBE(wire.ODirTLVLastName, v))
 		}
 	case fields["keyword"] != "":
-		inBody.Append(wire.NewTLVBE(wire.ODirTLVInterest, fields["keyword"]))
+		// "keyword" carries either an interest or an identifier. An address can
+		// only be the latter, and ODir searches email directly.
+		if kw := fields["keyword"]; strings.Contains(kw, "@") {
+			inBody.Append(wire.NewTLVBE(wire.ODirTLVEmailAddress, kw))
+		} else {
+			inBody.Append(wire.NewTLVBE(wire.ODirTLVInterest, kw))
+		}
 	}
 	return inBody
 }
 
-// parseMatch splits the web client's "match" value ("key=value,key=value")
-// into a field map.
+// parseMatch splits a client's "match" value ("key=value,key=value") into a field
+// map. Values may carry a second layer of escaping, so they are unescaped
+// after the split, the separators never being escaped themselves.
 func parseMatch(match string) map[string]string {
 	fields := make(map[string]string)
 	for pair := range strings.SplitSeq(match, ",") {
@@ -281,9 +357,27 @@ func parseMatch(match string) map[string]string {
 		if !ok {
 			continue
 		}
-		if key = strings.TrimSpace(key); key != "" {
-			fields[key] = strings.TrimSpace(val)
+		if key = strings.TrimSpace(key); key == "" {
+			continue
+		}
+		if unescaped, err := url.PathUnescape(val); err == nil {
+			val = unescaped
+		}
+		fields[key] = strings.TrimSpace(val)
+	}
+	return fields
+}
+
+// memberDirSets reads the repeated "set" params into a field map. Body values arrive
+// doubly encoded and need a second unescape; query values are already decoded, and
+// unescaping those again would corrupt a literal '%'.
+func memberDirSets(r *http.Request) map[string]string {
+	fields := parseSet(r.URL.Query()["set"])
+	for key, val := range parseSet(bodyValues(r, "set")) {
+		if decoded, err := url.QueryUnescape(val); err == nil {
+			val = decoded
 		}
+		fields[key] = val
 	}
 	return fields
 }

+ 345 - 4
server/webapi/memberdir_handler_test.go

@@ -7,6 +7,7 @@ import (
 	"log/slog"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"strings"
 	"testing"
 
@@ -55,6 +56,18 @@ func decodeInfoArray(t *testing.T, body []byte, nested bool) []MemberDirInfo {
 	return envelope.Response.Data.InfoArray
 }
 
+// stubNoDirUser answers DirInfo with no directory TLVs, which is what the service
+// returns for a name that belongs to no user. Every search test needs one, since
+// Search does an identity lookup alongside the directory query.
+func stubNoDirUser(t *testing.T) *mockLocateService {
+	ls := newMockLocateService(t)
+	ls.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{
+			Status: wire.LocateGetDirReplyOK,
+		}}, nil).Maybe()
+	return ls
+}
+
 func TestMemberDirHandler_Search_Keyword(t *testing.T) {
 	dirSvc := newMockDirSearchService(t)
 	// keyword=haha must map to the ODir interest TLV.
@@ -63,7 +76,7 @@ func TestMemberDirHandler_Search_Keyword(t *testing.T) {
 		return ok && v == "haha"
 	})).Return(searchReply(wire.ODirSearchResponseOK, result("FoundUser", "Found", "User")), nil)
 
-	h := &MemberDirHandler{DirSearchService: dirSvc, Logger: slog.Default()}
+	h := &MemberDirHandler{DirSearchService: dirSvc, LocateService: stubNoDirUser(t), Logger: slog.Default()}
 	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
 
 	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dhaha&nToGet=200", nil)
@@ -88,7 +101,7 @@ func TestMemberDirHandler_Search_FirstLastName(t *testing.T) {
 		return hasFirst && first == "Bob" && hasLast && last == "Smith" && !hasInterest
 	})).Return(searchReply(wire.ODirSearchResponseOK, result("Bob", "Bob", "Smith")), nil)
 
-	h := &MemberDirHandler{DirSearchService: dirSvc, Logger: slog.Default()}
+	h := &MemberDirHandler{DirSearchService: dirSvc, LocateService: stubNoDirUser(t), Logger: slog.Default()}
 	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
 
 	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=firstName%3DBob%2ClastName%3DSmith", nil)
@@ -108,7 +121,7 @@ func TestMemberDirHandler_Search_ExcludesSelf(t *testing.T) {
 			result("Other", "", ""), // kept
 		), nil)
 
-	h := &MemberDirHandler{DirSearchService: dirSvc, Logger: slog.Default()}
+	h := &MemberDirHandler{DirSearchService: dirSvc, LocateService: stubNoDirUser(t), Logger: slog.Default()}
 	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("M E")}
 
 	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dx", nil)
@@ -125,7 +138,7 @@ func TestMemberDirHandler_Search_RespectsJSONPCallback(t *testing.T) {
 	dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.Anything).Return(
 		searchReply(wire.ODirSearchResponseOK), nil)
 
-	h := &MemberDirHandler{DirSearchService: dirSvc, Logger: slog.Default()}
+	h := &MemberDirHandler{DirSearchService: dirSvc, LocateService: stubNoDirUser(t), Logger: slog.Default()}
 	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
 
 	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dx&c=_callbacks_._abc", nil)
@@ -277,3 +290,331 @@ func TestMemberDirHandler_Update_AbortsWhenCurrentInfoUnreadable(t *testing.T) {
 	require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &envelope))
 	assert.Equal(t, http.StatusInternalServerError, envelope.Response.StatusCode)
 }
+
+func TestMemberDirHandler_Update_ReadsDoubleEncodedFormBody(t *testing.T) {
+	// A form body encodes each "set" value twice — once building the pair, once
+	// building the body — and arrives with no Content-Type, which is what
+	// parseBodyForm's defaulting exists to handle.
+	current := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
+	current.Append(wire.NewTLVBE(wire.ODirTLVCity, "Reno"))
+
+	locSvc := newMockLocateService(t)
+	locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: current}, nil)
+	locSvc.EXPECT().SetDirInfo(mock.Anything, mock.Anything, mock.Anything,
+		mock.MatchedBy(func(b wire.SNAC_0x02_0x09_LocateSetDirInfo) bool {
+			first, _ := b.String(wire.ODirTLVFirstName)
+			last, _ := b.String(wire.ODirTLVLastName)
+			city, _ := b.String(wire.ODirTLVCity)
+			// Without the second unescape these arrive as "Bob%20Smith" and
+			// "O%27Brien" and are stored verbatim.
+			return first == "Bob Smith" && last == "O'Brien" && city == "Reno"
+		})).Return(wire.SNACMessage{}, nil)
+
+	h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("mike")}
+
+	form := url.Values{}
+	form.Set("aimsid", "sid")
+	form.Set("f", "json")
+	form.Add("set", "firstName=Bob%20Smith")
+	form.Add("set", "lastName=O%27Brien")
+	form.Add("set", "gender=unknown")
+
+	req := httptest.NewRequest("POST", "/memberDir/update", strings.NewReader(form.Encode()))
+	rr := httptest.NewRecorder()
+	h.Update(rr, req, session)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+}
+
+func TestMemberDirHandler_Update_QueryValuesAreNotUnescapedTwice(t *testing.T) {
+	// Query values are encoded once and are final after the query decoder runs.
+	// Unescaping again would corrupt a name carrying a literal '%'.
+	current := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
+
+	locSvc := newMockLocateService(t)
+	locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: current}, nil)
+	locSvc.EXPECT().SetDirInfo(mock.Anything, mock.Anything, mock.Anything,
+		mock.MatchedBy(func(b wire.SNAC_0x02_0x09_LocateSetDirInfo) bool {
+			first, _ := b.String(wire.ODirTLVFirstName)
+			last, _ := b.String(wire.ODirTLVLastName)
+			return first == "100%" && last == "Smith"
+		})).Return(wire.SNACMessage{}, nil)
+
+	h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("mike")}
+
+	req := httptest.NewRequest("GET",
+		"/memberDir/update?aimsid=sid&set=firstName%3D100%25&set=lastName%3DSmith", nil)
+	rr := httptest.NewRecorder()
+	h.Update(rr, req, session)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+}
+
+func TestServer_MemberDirUpdateIsRoutedForGETAndPOST(t *testing.T) {
+	// Go 1.22 mux patterns are method-exact, so registering only GET sends a POST to
+	// the catch-all 404. Neither request below carries credentials, so a routed one
+	// is rejected by the auth middleware (400) and an unrouted one 404s.
+	srv := NewServer([]string{"127.0.0.1:0"}, slog.Default(), Handler{Logger: slog.Default()},
+		nil, NewSessionManager())
+	require.NotEmpty(t, srv.servers)
+	mux := srv.servers[0].Handler
+
+	for _, method := range []string{"GET", "POST"} {
+		t.Run(method, func(t *testing.T) {
+			req := httptest.NewRequest(method, "/memberDir/update", strings.NewReader(""))
+			rr := httptest.NewRecorder()
+			mux.ServeHTTP(rr, req)
+			assert.NotEqual(t, http.StatusNotFound, rr.Code,
+				"%s /memberDir/update is not registered", method)
+		})
+	}
+}
+
+// dirUser answers DirInfo as an existing user. The service appends directory TLVs
+// only for a user that exists, so their presence marks the name as found.
+func dirUser(t *testing.T, firstName, lastName string) *mockLocateService {
+	reply := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
+	reply.Append(wire.NewTLVBE(wire.ODirTLVFirstName, firstName))
+	reply.Append(wire.NewTLVBE(wire.ODirTLVLastName, lastName))
+	ls := newMockLocateService(t)
+	ls.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: reply}, nil).Maybe()
+	return ls
+}
+
+func TestMemberDirHandler_Search_MatchesScreenName(t *testing.T) {
+	// An add-contact box that takes an email or UIN sends the value as keyword, but
+	// ODir searches interests, names and email — never the screen name.
+	dirSvc := newMockDirSearchService(t)
+	dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.Anything).
+		Return(searchReply(wire.ODirSearchResponseOK), nil)
+
+	h := &MemberDirHandler{
+		DirSearchService: dirSvc,
+		LocateService:    dirUser(t, "Bob", "Smith"),
+		Logger:           slog.Default(),
+	}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
+
+	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3D100888", nil)
+	rr := httptest.NewRecorder()
+	h.Search(rr, req, session)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
+	require.Len(t, infoArray, 1)
+	assert.Equal(t, "100888", infoArray[0].Profile.AimID)
+	assert.Equal(t, "Bob", infoArray[0].Profile.FirstName)
+}
+
+func TestMemberDirHandler_Search_ScreenNameMatchIsFoundForBlankProfile(t *testing.T) {
+	// A freshly created account has no directory info at all, and must still be
+	// findable by name.
+	dirSvc := newMockDirSearchService(t)
+	dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.Anything).
+		Return(searchReply(wire.ODirSearchResponseOK), nil)
+
+	h := &MemberDirHandler{
+		DirSearchService: dirSvc,
+		LocateService:    dirUser(t, "", ""),
+		Logger:           slog.Default(),
+	}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
+
+	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3D100888", nil)
+	rr := httptest.NewRecorder()
+	h.Search(rr, req, session)
+
+	infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
+	require.Len(t, infoArray, 1)
+	assert.Equal(t, "100888", infoArray[0].Profile.AimID)
+}
+
+func TestMemberDirHandler_Search_UnknownScreenNameYieldsNothing(t *testing.T) {
+	// The identity lookup must not invent a profile for a name nobody holds.
+	dirSvc := newMockDirSearchService(t)
+	dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.Anything).
+		Return(searchReply(wire.ODirSearchResponseOK), nil)
+
+	h := &MemberDirHandler{
+		DirSearchService: dirSvc,
+		LocateService:    stubNoDirUser(t),
+		Logger:           slog.Default(),
+	}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
+
+	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dnobody", nil)
+	rr := httptest.NewRecorder()
+	h.Search(rr, req, session)
+
+	assert.Empty(t, decodeInfoArray(t, rr.Body.Bytes(), true))
+}
+
+func TestMemberDirHandler_Search_ScreenNameMatchDoesNotDisplaceInterestResults(t *testing.T) {
+	// A keyword that also happens to name a user must return both the interest
+	// matches and the identity hit, with any overlap appearing once.
+	dirSvc := newMockDirSearchService(t)
+	dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x0F_0x02_InfoQuery) bool {
+		v, ok := q.String(wire.ODirTLVInterest)
+		return ok && v == "music"
+	})).Return(searchReply(wire.ODirSearchResponseOK,
+		result("music", "Music", "Fan"), // same user the name lookup finds
+		result("OtherFan", "Other", "Fan"),
+	), nil)
+
+	h := &MemberDirHandler{
+		DirSearchService: dirSvc,
+		LocateService:    dirUser(t, "Music", "Fan"),
+		Logger:           slog.Default(),
+	}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
+
+	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dmusic", nil)
+	rr := httptest.NewRecorder()
+	h.Search(rr, req, session)
+
+	infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
+	require.Len(t, infoArray, 2)
+	assert.Equal(t, "music", infoArray[0].Profile.AimID)
+	assert.Equal(t, "otherfan", infoArray[1].Profile.AimID)
+}
+
+func TestMemberDirHandler_Search_ExcludesSelfByScreenName(t *testing.T) {
+	// Searching your own UIN must not offer you yourself as a contact.
+	dirSvc := newMockDirSearchService(t)
+	dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.Anything).
+		Return(searchReply(wire.ODirSearchResponseOK), nil)
+
+	h := &MemberDirHandler{
+		DirSearchService: dirSvc,
+		LocateService:    dirUser(t, "Me", "Myself"),
+		Logger:           slog.Default(),
+	}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("100777")}
+
+	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3D100777", nil)
+	rr := httptest.NewRecorder()
+	h.Search(rr, req, session)
+
+	assert.Empty(t, decodeInfoArray(t, rr.Body.Bytes(), true))
+}
+
+func TestMemberDirHandler_Search_EmailKeywordUsesEmailSearch(t *testing.T) {
+	// An address can only be an identifier, and ODir searches email directly, so it
+	// must not be sent as an interest.
+	dirSvc := newMockDirSearchService(t)
+	dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x0F_0x02_InfoQuery) bool {
+		v, ok := q.String(wire.ODirTLVEmailAddress)
+		_, isInterest := q.String(wire.ODirTLVInterest)
+		return ok && v == "bob@example.com" && !isInterest
+	})).Return(searchReply(wire.ODirSearchResponseOK, result("BobS", "Bob", "Smith")), nil)
+
+	h := &MemberDirHandler{
+		DirSearchService: dirSvc,
+		LocateService:    stubNoDirUser(t),
+		Logger:           slog.Default(),
+	}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
+
+	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dbob%40example.com", nil)
+	rr := httptest.NewRecorder()
+	h.Search(rr, req, session)
+
+	infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
+	require.Len(t, infoArray, 1)
+	assert.Equal(t, "bobs", infoArray[0].Profile.AimID)
+}
+
+// Mandarin escapes each match value before its request builder escapes the whole
+// parameter (IcqSearchOptionsBuilder.appendOption + HttpParamsBuilder.build), so
+// the query parser leaves one layer on. The web client escapes nothing. Both have
+// to arrive as the text the user typed.
+func TestParseMatch(t *testing.T) {
+	tests := []struct {
+		name  string
+		match string
+		want  map[string]string
+	}{
+		{
+			// What Mandarin sends: the query parser has already removed the outer
+			// layer, leaving the values escaped.
+			name:  "doubly escaped values are decoded",
+			match: "keyword=bob%40example.com,age=19-26,gender=female",
+			want:  map[string]string{"keyword": "bob@example.com", "age": "19-26", "gender": "female"},
+		},
+		{
+			// StringUtil.urlEncode writes a space as %20, never '+'.
+			name:  "escaped spaces survive",
+			match: "firstName=John,lastName=van%20Smith",
+			want:  map[string]string{"firstName": "John", "lastName": "van Smith"},
+		},
+		{
+			// An escaped separator must not split the pair, and must come back.
+			name:  "escaped separators are not delimiters",
+			match: "keyword=rock%2C%20paper",
+			want:  map[string]string{"keyword": "rock, paper"},
+		},
+		{
+			// What the web client sends: nothing is escaped, and one pass over an
+			// unescaped value changes nothing.
+			name:  "unescaped values are untouched",
+			match: "firstName=John,lastName=Smith",
+			want:  map[string]string{"firstName": "John", "lastName": "Smith"},
+		},
+		{
+			// A '+' is a literal here, not a space. QueryUnescape would eat it.
+			name:  "a literal plus is preserved",
+			match: "keyword=C++",
+			want:  map[string]string{"keyword": "C++"},
+		},
+		{
+			// Not valid escaping, so it is a literal '%' and stays one.
+			name:  "an unescapable value is kept verbatim",
+			match: "keyword=100% cotton",
+			want:  map[string]string{"keyword": "100% cotton"},
+		},
+		{
+			name:  "pairs without a separator are skipped",
+			match: "keyword=hi,garbage,=novalue",
+			want:  map[string]string{"keyword": "hi"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.want, parseMatch(tt.match))
+		})
+	}
+}
+
+// An email typed into Mandarin's search box arrives escaped twice. Unless the
+// second layer comes off, the keyword holds no literal '@', the email branch of
+// buildDirInfoQuery never fires, and the address is searched as an interest.
+func TestMemberDirHandler_Search_DoublyEscapedEmailUsesEmailSearch(t *testing.T) {
+	dirSvc := newMockDirSearchService(t)
+	dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x0F_0x02_InfoQuery) bool {
+		v, ok := q.String(wire.ODirTLVEmailAddress)
+		_, isInterest := q.String(wire.ODirTLVInterest)
+		return ok && v == "bob@example.com" && !isInterest
+	})).Return(searchReply(wire.ODirSearchResponseOK, result("BobS", "Bob", "Smith")), nil)
+
+	h := &MemberDirHandler{
+		DirSearchService: dirSvc,
+		LocateService:    stubNoDirUser(t),
+		Logger:           slog.Default(),
+	}
+	session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
+
+	// "keyword=bob%40example.com" with the whole parameter escaped once more.
+	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dbob%2540example.com", nil)
+	rr := httptest.NewRecorder()
+	h.Search(rr, req, session)
+
+	infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
+	require.Len(t, infoArray, 1)
+	assert.Equal(t, "bobs", infoArray[0].Profile.AimID)
+}

+ 9 - 17
server/webapi/middleware.go

@@ -145,7 +145,7 @@ func NewAuthMiddleware(validator APIKeyValidator, logger *slog.Logger) *AuthMidd
 // session.OSCARSession as non-nil.
 func (m *AuthMiddleware) RequireSession(sm SessionResolver, next func(http.ResponseWriter, *http.Request, *Session)) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		aimsid := r.URL.Query().Get("aimsid")
+		aimsid := param(r, "aimsid")
 		if aimsid == "" {
 			SendError(w, r, http.StatusBadRequest, "missing aimsid parameter")
 			return
@@ -164,11 +164,7 @@ func (m *AuthMiddleware) RequireSession(sm SessionResolver, next func(http.Respo
 func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		// Extract API key from 'k' parameter (query or form)
-		apiKey := r.URL.Query().Get("k")
-		if apiKey == "" {
-			// Try form value for POST requests
-			apiKey = r.FormValue("k")
-		}
+		apiKey := param(r, "k")
 
 		if apiKey == "" {
 			SendEnvelopeStatus(w, r, http.StatusBadRequest, "required parameter 'k' is missing", m.Logger)
@@ -331,7 +327,7 @@ func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
 
 		// Priority 1: Check for session-based auth (aimsid)
 		// According to the spec, when aimsid is provided, k is not required
-		if aimsid := r.URL.Query().Get("aimsid"); aimsid != "" {
+		if aimsid := param(r, "aimsid"); aimsid != "" {
 			// The handler itself will validate the aimsid
 			// We just need to pass the request through without requiring k
 			m.Logger.DebugContext(ctx, "using aimsid authentication", "aimsid", aimsid[:min(16, len(aimsid))]+"...")
@@ -340,11 +336,11 @@ func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
 		}
 
 		// Priority 2: AOL token auth — user identity is in the token; k is optional.
-		if token := r.URL.Query().Get("a"); token != "" {
-			key, r := m.resolveAPIKeyCached(r, r.URL.Query().Get("k"))
+		if token := param(r, "a"); token != "" {
+			key, r := m.resolveAPIKeyCached(r, param(r, "k"))
 			ctx := r.Context()
 			if key == nil {
-				devKey := r.URL.Query().Get("k")
+				devKey := param(r, "k")
 				key = &state.WebAPIKey{
 					DevID:     "aim_web",
 					DevKey:    devKey,
@@ -360,8 +356,8 @@ func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
 		}
 
 		// Priority 3: Check for signed request auth
-		if ts := r.URL.Query().Get("ts"); ts != "" {
-			if sig := r.URL.Query().Get("sig_sha256"); sig != "" {
+		if ts := param(r, "ts"); ts != "" {
+			if sig := param(r, "sig_sha256"); sig != "" {
 				// For now, signed requests still require 'k' parameter for API key validation
 				// The signature provides additional security on top of the API key
 				// When full signature validation is implemented, this can be made optional
@@ -371,11 +367,7 @@ func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
 		}
 
 		// Priority 4: Fall back to API key requirement
-		apiKey := r.URL.Query().Get("k")
-		if apiKey == "" {
-			// Try form value for POST requests
-			apiKey = r.FormValue("k")
-		}
+		apiKey := param(r, "k")
 
 		if apiKey == "" {
 			SendEnvelopeStatus(w, r, http.StatusBadRequest, "authentication required: provide aimsid or k parameter", m.Logger)

+ 45 - 0
server/webapi/middleware_test.go

@@ -208,6 +208,51 @@ func TestCORSMiddleware_DoesNotConsumePOSTBody(t *testing.T) {
 	assert.Equal(t, http.StatusOK, w.Code)
 }
 
+// Some clients POST the whole parameter set in the body, so an auth layer reading
+// only the query string sees no credential. Reading the body here does not cost the
+// handler its parameters: ParseForm caches onto the request.
+func TestAuthenticateFlexible_ReadsCredentialsFromPOSTBody(t *testing.T) {
+	tests := []struct {
+		name        string
+		body        string
+		contentType string
+	}{
+		{
+			name:        "declared form body",
+			body:        "aimsid=abc&message=hello",
+			contentType: "application/x-www-form-urlencoded",
+		},
+		{
+			// No Content-Type announced; the request is form data all the same.
+			name: "untyped form body",
+			body: "aimsid=abc&message=hello",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			m := newTestMiddleware(&stubValidator{})
+			reached := false
+			h := m.CORSMiddleware(m.AuthenticateFlexible(
+				http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+					reached = true
+					assert.Equal(t, "hello", param(r, "message"))
+					w.WriteHeader(http.StatusOK)
+				})))
+
+			r := httptest.NewRequest(http.MethodPost, "/im/sendIM", strings.NewReader(tt.body))
+			if tt.contentType != "" {
+				r.Header.Set("Content-Type", tt.contentType)
+			}
+			w := httptest.NewRecorder()
+			h.ServeHTTP(w, r)
+
+			assert.True(t, reached, "request was rejected before reaching the handler")
+			assert.Equal(t, http.StatusOK, w.Code)
+		})
+	}
+}
+
 // The auth layer's own rejections must be JSONP-wrapped too, otherwise a client
 // already in JSONP mode gets a script-tag syntax error instead of the reason.
 func TestAuthErrorsHonorJSONP(t *testing.T) {

+ 80 - 0
server/webapi/mock_feedbag_service_test.go

@@ -119,6 +119,86 @@ func (_c *mockFeedbagService_DeleteItem_Call) RunAndReturn(run func(ctx context.
 	return _c
 }
 
+// PreAuthorizeBuddy provides a mock function for the type mockFeedbagService
+func (_mock *mockFeedbagService) PreAuthorizeBuddy(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy) (*wire.SNACMessage, error) {
+	ret := _mock.Called(ctx, instance, inFrame, inBody)
+
+	if len(ret) == 0 {
+		panic("no return value specified for PreAuthorizeBuddy")
+	}
+
+	var r0 *wire.SNACMessage
+	var r1 error
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy) (*wire.SNACMessage, error)); ok {
+		return returnFunc(ctx, instance, inFrame, inBody)
+	}
+	if returnFunc, ok := ret.Get(0).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy) *wire.SNACMessage); ok {
+		r0 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.SNACMessage)
+		}
+	}
+	if returnFunc, ok := ret.Get(1).(func(context.Context, *state.SessionInstance, wire.SNACFrame, wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy) error); ok {
+		r1 = returnFunc(ctx, instance, inFrame, inBody)
+	} else {
+		r1 = ret.Error(1)
+	}
+	return r0, r1
+}
+
+// mockFeedbagService_PreAuthorizeBuddy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PreAuthorizeBuddy'
+type mockFeedbagService_PreAuthorizeBuddy_Call struct {
+	*mock.Call
+}
+
+// PreAuthorizeBuddy is a helper method to define mock.On call
+//   - ctx context.Context
+//   - instance *state.SessionInstance
+//   - inFrame wire.SNACFrame
+//   - inBody wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy
+func (_e *mockFeedbagService_Expecter) PreAuthorizeBuddy(ctx interface{}, instance interface{}, inFrame interface{}, inBody interface{}) *mockFeedbagService_PreAuthorizeBuddy_Call {
+	return &mockFeedbagService_PreAuthorizeBuddy_Call{Call: _e.mock.On("PreAuthorizeBuddy", ctx, instance, inFrame, inBody)}
+}
+
+func (_c *mockFeedbagService_PreAuthorizeBuddy_Call) Run(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy)) *mockFeedbagService_PreAuthorizeBuddy_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		var arg0 context.Context
+		if args[0] != nil {
+			arg0 = args[0].(context.Context)
+		}
+		var arg1 *state.SessionInstance
+		if args[1] != nil {
+			arg1 = args[1].(*state.SessionInstance)
+		}
+		var arg2 wire.SNACFrame
+		if args[2] != nil {
+			arg2 = args[2].(wire.SNACFrame)
+		}
+		var arg3 wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy
+		if args[3] != nil {
+			arg3 = args[3].(wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy)
+		}
+		run(
+			arg0,
+			arg1,
+			arg2,
+			arg3,
+		)
+	})
+	return _c
+}
+
+func (_c *mockFeedbagService_PreAuthorizeBuddy_Call) Return(sNACMessage *wire.SNACMessage, err error) *mockFeedbagService_PreAuthorizeBuddy_Call {
+	_c.Call.Return(sNACMessage, err)
+	return _c
+}
+
+func (_c *mockFeedbagService_PreAuthorizeBuddy_Call) RunAndReturn(run func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy) (*wire.SNACMessage, error)) *mockFeedbagService_PreAuthorizeBuddy_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // Query provides a mock function for the type mockFeedbagService
 func (_mock *mockFeedbagService) Query(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error) {
 	ret := _mock.Called(ctx, instance, inFrame)

+ 91 - 0
server/webapi/params.go

@@ -0,0 +1,91 @@
+package webapi
+
+import (
+	"context"
+	"mime"
+	"net/http"
+	"strings"
+)
+
+// The Web API is method-agnostic: parameters may arrive on the query string or in a
+// form-encoded body, varying by client and by endpoint. The helpers here read either
+// location so a handler never has to care.
+
+const formContentType = "application/x-www-form-urlencoded"
+
+// binaryBodyKey marks a request whose body is a payload rather than form fields.
+type binaryBodyKey struct{}
+
+// WithBinaryBody marks a request body as a payload the parameter helpers must not
+// consume. expressions/upload POSTs a raw untyped image, and without the marker a
+// lookup that missed on the query string would hand it to ParseForm, which reads it
+// to EOF.
+func WithBinaryBody(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), binaryBodyKey{}, true)))
+	})
+}
+
+func hasBinaryBody(r *http.Request) bool {
+	marked, _ := r.Context().Value(binaryBodyKey{}).(bool)
+	return marked
+}
+
+// parseBodyForm parses the request body as form fields, reporting whether the form
+// is available to read afterwards. It is safe to call repeatedly: ParseForm caches
+// its result on the request.
+func parseBodyForm(r *http.Request) bool {
+	if r.Method != http.MethodPost && r.Method != http.MethodPut {
+		return false
+	}
+	if hasBinaryBody(r) {
+		return false
+	}
+
+	if ct := r.Header.Get("Content-Type"); ct == "" {
+		// ParseForm ignores a body it cannot type, and form bodies often arrive
+		// unannounced. A genuinely non-form body is marked by WithBinaryBody.
+		r.Header.Set("Content-Type", formContentType)
+	} else if mediaType, _, err := mime.ParseMediaType(ct); err != nil || mediaType != formContentType {
+		return false
+	}
+
+	return r.ParseForm() == nil
+}
+
+// param returns a request parameter from the query string, falling back to the
+// form-encoded body.
+func param(r *http.Request, key string) string {
+	if v := r.URL.Query().Get(key); v != "" {
+		return v
+	}
+	if !parseBodyForm(r) {
+		return ""
+	}
+	return r.PostFormValue(key)
+}
+
+// paramValues returns every value sent for a repeated parameter, from the query
+// string and the form-encoded body both.
+func paramValues(r *http.Request, key string) []string {
+	return append(r.URL.Query()[key], bodyValues(r, key)...)
+}
+
+// bodyValues returns every value sent for a repeated parameter in the form-encoded
+// body, ignoring the query string. Most callers want paramValues instead.
+func bodyValues(r *http.Request, key string) []string {
+	if !parseBodyForm(r) {
+		return nil
+	}
+	return r.PostForm[key]
+}
+
+// isTrueParam reports whether a boolean-ish parameter is set. Clients spell these
+// inconsistently, so both "1" and "true" are accepted.
+func isTrueParam(v string) bool {
+	switch strings.ToLower(strings.TrimSpace(v)) {
+	case "1", "true", "yes":
+		return true
+	}
+	return false
+}

+ 180 - 0
server/webapi/params_test.go

@@ -0,0 +1,180 @@
+package webapi
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// formPost builds a POST carrying body as its payload, with contentType set only
+// when non-empty.
+func formPost(body, contentType string) *http.Request {
+	r := httptest.NewRequest(http.MethodPost, "/x", strings.NewReader(body))
+	if contentType != "" {
+		r.Header.Set("Content-Type", contentType)
+	}
+	return r
+}
+
+func TestParam(t *testing.T) {
+	tests := []struct {
+		name string
+		req  *http.Request
+		key  string
+		want string
+	}{
+		{
+			name: "from the query string",
+			req:  httptest.NewRequest(http.MethodGet, "/x?aimsid=sid", nil),
+			key:  "aimsid",
+			want: "sid",
+		},
+		{
+			name: "from a declared form body",
+			req:  formPost("s=mikekelly&pwd=hunter2", "application/x-www-form-urlencoded"),
+			key:  "s",
+			want: "mikekelly",
+		},
+		{
+			// A body may arrive unannounced, and ParseForm ignores one it cannot
+			// type.
+			name: "from an untyped form body",
+			req:  formPost("s=mikekelly&pwd=hunter2", ""),
+			key:  "pwd",
+			want: "hunter2",
+		},
+		{
+			name: "query wins over body",
+			req: func() *http.Request {
+				r := formPost("f=xml", "")
+				r.URL.RawQuery = "f=json"
+				return r
+			}(),
+			key:  "f",
+			want: "json",
+		},
+		{
+			name: "absent from both",
+			req:  formPost("s=mikekelly", ""),
+			key:  "nope",
+			want: "",
+		},
+		{
+			name: "body is not read on a GET",
+			req:  httptest.NewRequest(http.MethodGet, "/x", strings.NewReader("s=mikekelly")),
+			key:  "s",
+			want: "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.want, param(tt.req, tt.key))
+		})
+	}
+}
+
+func TestParamLeavesBinaryBodyIntact(t *testing.T) {
+	// expressions/upload POSTs a raw image with its parameters on the query string,
+	// arriving untyped. A parameter lookup that misses must not hand that body to
+	// ParseForm, which would read it to EOF.
+	image := "\xff\xd8\xff\xe0 not form data"
+
+	var body string
+	var missing string
+	handler := WithBinaryBody(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+		missing = param(r, "absent")
+		read := make([]byte, len(image))
+		n, _ := r.Body.Read(read)
+		body = string(read[:n])
+	}))
+
+	req := formPost(image, "")
+	req.URL.RawQuery = "type=buddyIcon"
+	handler.ServeHTTP(httptest.NewRecorder(), req)
+
+	assert.Empty(t, missing)
+	assert.Equal(t, image, body, "the image body was consumed by form parsing")
+}
+
+func TestParamValues(t *testing.T) {
+	tests := []struct {
+		name string
+		req  *http.Request
+		want []string
+	}{
+		{
+			name: "repeated in the query string",
+			req:  httptest.NewRequest(http.MethodGet, "/x?t=alice&t=bob", nil),
+			want: []string{"alice", "bob"},
+		},
+		{
+			name: "repeated in an untyped body",
+			req:  formPost("t=alice&t=bob", ""),
+			want: []string{"alice", "bob"},
+		},
+		{
+			name: "absent",
+			req:  httptest.NewRequest(http.MethodGet, "/x", nil),
+			want: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.want, paramValues(tt.req, "t"))
+		})
+	}
+}
+
+func TestPresenceTargets(t *testing.T) {
+	tests := []struct {
+		name string
+		url  string
+		want []string
+	}{
+		{
+			// One repetition per value.
+			name: "repeated parameter",
+			url:  "/presence/get?t=alice&t=bob&t=carol",
+			want: []string{"alice", "bob", "carol"},
+		},
+		{
+			// Comma-separated in a single parameter.
+			name: "comma separated",
+			url:  "/presence/get?t=alice,bob,carol",
+			want: []string{"alice", "bob", "carol"},
+		},
+		{
+			name: "both, with padding and empties discarded",
+			url:  "/presence/get?t=alice,+bob&t=&t=carol",
+			want: []string{"alice", "bob", "carol"},
+		},
+		{
+			name: "no targets",
+			url:  "/presence/get?bl=1",
+			want: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := presenceTargets(httptest.NewRequest(http.MethodGet, tt.url, nil))
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}
+
+func TestRequestIDFromFormBody(t *testing.T) {
+	// A client may POST its correlation id in the body and read response.requestId
+	// strictly off the reply, so a query-only read would leave the field empty.
+	req := formPost("t=bob&message=hi&r=cookie-42", "")
+	assert.Equal(t, "cookie-42", requestIDFromRequest(req))
+
+	rr := httptest.NewRecorder()
+	SendOK(rr, req, &SendIMData{MsgID: "msg-1", State: "delivered"}, nil)
+	assert.Contains(t, rr.Body.String(), `"requestId":"cookie-42"`)
+}

+ 124 - 24
server/webapi/presence_handler.go

@@ -22,9 +22,7 @@ type PresenceHandler struct {
 	Logger           *slog.Logger
 }
 
-// maxPresenceTargets caps how many screen names a single presence/get request
-// may query in target-list ("t=") mode.
-const maxPresenceTargets = 10
+const maxPresenceTargets = 32
 
 // ProfileData is the getProfile payload.
 type ProfileData struct {
@@ -44,10 +42,29 @@ type SetStateData struct {
 	OnlineTime int64  `json:"onlineTime" xml:"onlineTime"`
 }
 
-// PresenceData contains presence information.
+// presenceTargets returns the screen names a presence/get request asks about.
+// Both spellings of the list are accepted and combined: one t carrying comma-
+// separated names, and t repeated once per name.
+func presenceTargets(r *http.Request) []string {
+	var targets []string
+	for _, value := range paramValues(r, "t") {
+		for _, name := range strings.Split(value, ",") {
+			if name = strings.TrimSpace(name); name != "" {
+				targets = append(targets, name)
+			}
+		}
+	}
+	return targets
+}
+
+// PresenceData contains presence information. Each query fills in one field and
+// leaves the other nil.
+//
+// omitzero, not omitempty: a query matching nothing must still render its key as an
+// empty array, since clients read data.groups and data.users strictly.
 type PresenceData struct {
-	Groups []BuddyGroupInfo    `json:"groups,omitempty" xml:"groups>group,omitempty"`
-	Users  []BuddyPresenceInfo `json:"users,omitempty" xml:"users>user,omitempty"`
+	Groups []BuddyGroupInfo    `json:"groups,omitzero" xml:"groups>group,omitempty"`
+	Users  []BuddyPresenceInfo `json:"users,omitzero" xml:"users>user,omitempty"`
 }
 
 // BuddyGroupInfo represents a buddy group with its members.
@@ -73,6 +90,25 @@ type BuddyPresenceInfo struct {
 	OnlineTime int64  `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"`
 	UserType   string `json:"userType" xml:"userType"` // "aim", "icq", "admin"
 	BuddyIcon  string `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`
+	// Profile carries member-directory fields, present only under mdir=1. It must be
+	// non-nil even when empty: clients treat a missing profile as "not a user".
+	Profile *BuddyProfileInfo `json:"profile,omitempty" xml:"profile,omitempty"`
+}
+
+// BuddyProfileInfo is the nested "profile" object carried under mdir=1. Gender and
+// birth date are absent because the directory record has nowhere to store them.
+type BuddyProfileInfo struct {
+	FriendlyName string             `json:"friendlyName,omitempty" xml:"friendlyName,omitempty"`
+	FirstName    string             `json:"firstName,omitempty" xml:"firstName,omitempty"`
+	LastName     string             `json:"lastName,omitempty" xml:"lastName,omitempty"`
+	HomeAddress  []BuddyAddressInfo `json:"homeAddress,omitempty" xml:"homeAddress,omitempty"`
+}
+
+// BuddyAddressInfo is one entry of a profile's homeAddress array.
+type BuddyAddressInfo struct {
+	City    string `json:"city,omitempty" xml:"city,omitempty"`
+	State   string `json:"state,omitempty" xml:"state,omitempty"`
+	Country string `json:"country,omitempty" xml:"country,omitempty"`
 }
 
 // GetPresence handles GET /presence/get requests.
@@ -83,9 +119,10 @@ func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request, se
 	// Check if buddy list is requested
 	getBuddyList := r.URL.Query().Get("bl") == "1"
 	wantProfileMsg := r.URL.Query().Get("profileMsg") == "1"
+	// mdir asks for member-directory fields alongside presence.
+	wantDirInfo := isTrueParam(r.URL.Query().Get("mdir"))
 
-	// Get target users if specified
-	targetUsers := r.URL.Query().Get("t")
+	targetUsers := presenceTargets(r)
 
 	// Create PresenceData struct to hold the response data
 	presenceData := PresenceData{}
@@ -99,33 +136,36 @@ func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request, se
 			groups = []BuddyGroupInfo{}
 		}
 		presenceData.Groups = groups
-	} else if targetUsers != "" {
+	} else if len(targetUsers) > 0 {
 		// Get presence for specific users
-		users := strings.Split(targetUsers, ",")
-		if len(users) > maxPresenceTargets {
-			SendError(w, r, http.StatusBadRequest, fmt.Sprintf("too many screen names requested (max %d)", maxPresenceTargets))
-			return
+		if len(targetUsers) > maxPresenceTargets {
+			// truncate rather than reject
+			h.Logger.WarnContext(ctx, "presence get: truncating oversized target list",
+				"aimsid", session.AimSID,
+				"requested", len(targetUsers),
+				"cap", maxPresenceTargets,
+			)
+			targetUsers = targetUsers[:maxPresenceTargets]
 		}
-		presenceList := make([]BuddyPresenceInfo, 0, len(users))
+		presenceList := make([]BuddyPresenceInfo, 0, len(targetUsers))
 
 		// The client's user-object merge deletes any alias it holds, so every
 		// presence payload has to carry friendly for aliased buddies.
 		aliases := session.Aliases(ctx)
 
-		for _, user := range users {
-			user = strings.TrimSpace(user)
-			if user == "" {
-				continue
-			}
+		for _, user := range targetUsers {
 			info := h.getUserPresence(ctx, session.OSCARSession, session.BaseURL, state.DisplayScreenName(user), wantProfileMsg)
 			info.Friendly = aliases[info.AimID]
+			if wantDirInfo {
+				info.Profile = h.directoryProfile(ctx, user)
+			}
 			presenceList = append(presenceList, info)
 		}
 
 		presenceData.Users = presenceList
 	} else {
-		// No specific request, return empty data
 		presenceData.Groups = []BuddyGroupInfo{}
+		presenceData.Users = []BuddyPresenceInfo{}
 	}
 
 	// Send response in requested format
@@ -138,6 +178,37 @@ func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request, se
 	)
 }
 
+// directoryProfile reads a user's member-directory record for the mdir=1 profile
+// object. It never returns nil: a user with no directory record must still appear
+// as an empty profile rather than be dropped.
+func (h *PresenceHandler) directoryProfile(ctx context.Context, screenName string) *BuddyProfileInfo {
+	profile := &BuddyProfileInfo{}
+
+	reply, err := h.LocateService.DirInfo(ctx, wire.SNACFrame{}, wire.SNAC_0x02_0x0B_LocateGetDirInfo{ScreenName: screenName})
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "presence: directory lookup failed",
+			"screenName", screenName, "err", err.Error())
+		return profile
+	}
+	body, ok := reply.Body.(wire.SNAC_0x02_0x0C_LocateGetDirReply)
+	if !ok {
+		return profile
+	}
+
+	profile.FirstName, _ = body.String(wire.ODirTLVFirstName)
+	profile.LastName, _ = body.String(wire.ODirTLVLastName)
+	profile.FriendlyName, _ = body.String(wire.ODirTLVNickName)
+
+	city, _ := body.String(wire.ODirTLVCity)
+	stateName, _ := body.String(wire.ODirTLVState)
+	country, _ := body.String(wire.ODirTLVCountry)
+	if city != "" || stateName != "" || country != "" {
+		profile.HomeAddress = []BuddyAddressInfo{{City: city, State: stateName, Country: country}}
+	}
+
+	return profile
+}
+
 // getBuddyListGroups retrieves the buddy list organized by groups.
 func (h *PresenceHandler) getBuddyListGroups(ctx context.Context, session *Session, wantProfileMsg bool) ([]BuddyGroupInfo, error) {
 	// Get feedbag items via the feedbag service
@@ -274,10 +345,10 @@ func (h *PresenceHandler) getUserPresence(ctx context.Context, instance *state.S
 		presence.OnlineTime = int64(tod)
 	}
 
-	if info.IsAway() {
+	if st := statusBitState(info.TLVUserInfo); st != "" {
+		presence.State = st
+	} else if info.IsAway() {
 		presence.State = "away"
-	} else if status, ok := info.Uint32BE(wire.OServiceUserInfoStatus); ok && status&wire.OServiceUserStatusDND != 0 {
-		presence.State = "dnd"
 	}
 
 	if idle, ok := info.Uint16BE(wire.OServiceUserInfoIdleTime); ok && idle > 0 {
@@ -343,6 +414,10 @@ func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request, sessi
 		statusBitmask = wire.OServiceUserStatusInvisible
 	case "dnd":
 		statusBitmask = wire.OServiceUserStatusDND
+	case "occupied":
+		// ICQ's Busy, a distinct status bit from DND.
+		statusBitmask = wire.OServiceUserStatusBusy
+		oscarSession.SetUserInfoFlag(wire.OServiceUserFlagUnavailable)
 	default:
 		SendError(w, r, http.StatusBadRequest, "invalid state parameter")
 		return
@@ -549,13 +624,38 @@ func (h *PresenceHandler) Icon(w http.ResponseWriter, r *http.Request) {
 	http.Redirect(w, r, iconURL, http.StatusFound)
 }
 
+// statusBitState reports the web state named by a user's ICQ status bits, or ""
+// when neither Busy nor DND is set. Callers must consult it before IsAway(): Busy
+// and DND also raise the unavailable flag, so an away-first test reports every busy
+// user as away.
+func statusBitState(info wire.TLVUserInfo) string {
+	status, ok := info.Uint32BE(wire.OServiceUserInfoStatus)
+	if !ok {
+		return ""
+	}
+	switch {
+	case status&wire.OServiceUserStatusBusy != 0:
+		return "occupied"
+	case status&wire.OServiceUserStatusDND != 0:
+		return "dnd"
+	}
+	return ""
+}
+
 // currentWebState maps an OSCAR session's presence flags to the web state string
-// the AIM client expects ("online", "away", "idle", "invisible").
+// the clients expect ("online", "away", "idle", "invisible", "occupied", "dnd").
 func currentWebState(instance *state.SessionInstance) string {
 	sess := instance.Session()
+	bitmask := instance.UserStatusBitmask()
 	switch {
 	case sess.Invisible():
 		return "invisible"
+	// Checked before Away: both set the unavailable flag, so an occupied user would
+	// otherwise report back as away on the next myInfo.
+	case bitmask&wire.OServiceUserStatusBusy != 0:
+		return "occupied"
+	case bitmask&wire.OServiceUserStatusDND != 0:
+		return "dnd"
 	case sess.Away():
 		return "away"
 	case instance.Idle():

+ 257 - 6
server/webapi/presence_handler_test.go

@@ -3,6 +3,7 @@ package webapi
 import (
 	"context"
 	"encoding/json"
+	"fmt"
 	"log/slog"
 	"net/http"
 	"net/http/httptest"
@@ -114,13 +115,23 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 			},
 		},
 		{
-			name:        "Error_TooManyTargets",
-			queryParams: "t=u1,u2,u3,u4,u5,u6,u7,u8,u9,u10,u11",
-			// No UserInfoQuery should be issued; the request is rejected up front.
-			setupMocks:         func(fr *mockFeedbagService, ls *mockLocateService) {},
-			expectedStatusCode: http.StatusBadRequest,
+			// A full search page: memberDir/search returns 20 profiles and the
+			// client asks about every one, plus the keyword when it is a UIN.
+			// This is the largest list a real client sends, and it must be served
+			// whole — anything but a 200 costs the user the entire page.
+			name:        "Success_FullSearchPage",
+			queryParams: "t=" + strings.Join(searchPageTargets(21), "&t="),
+			setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
+				ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, body wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
+						return onlineUserInfoReply(body.ScreenName, 0), nil
+					}).Times(21)
+			},
+			expectedStatusCode: http.StatusOK,
 			checkResponse: func(t *testing.T, body string) {
-				assert.Contains(t, body, "too many screen names requested")
+				assert.Contains(t, body, `"statusCode":200`)
+				assert.Contains(t, body, `"user0"`)
+				assert.Contains(t, body, `"user20"`)
 			},
 		},
 	}
@@ -635,3 +646,243 @@ func queuedMyInfo(session *Session) *MyInfo {
 	}
 	return myInfo
 }
+
+func TestPresenceHandler_SetState_Occupied(t *testing.T) {
+	// ICQ's Busy, which is a selectable connect state and must be accepted.
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+
+	broadcaster := newMockBuddyBroadcaster(t)
+	broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
+
+	handler := &PresenceHandler{
+		SessionManager:   sessionMgr,
+		BuddyBroadcaster: broadcaster,
+		Logger:           slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&view=occupied&away=", nil)
+	assert.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+
+	assert.Equal(t, wire.OServiceUserStatusBusy, oscarInstance.UserStatusBitmask())
+
+	// The state must survive the round trip: a later myInfo push reads it back
+	// through currentWebState, and reporting "away" or "online" there would undo
+	// the change in the user's own UI.
+	assert.Equal(t, "occupied", currentWebState(oscarInstance))
+
+	session, err := sessionMgr.GetSession(context.Background(), aimsid)
+	assert.NoError(t, err)
+	myInfo := queuedMyInfo(session)
+	assert.NotNil(t, myInfo, "expected a myInfo event to be queued")
+	assert.Equal(t, "occupied", myInfo.State)
+}
+
+func TestPresenceHandler_GetPresence_MdirAttachesProfile(t *testing.T) {
+	// Search results are populated from this call, and a user carrying no nested
+	// "profile" object is dropped by clients, so a found user would not render.
+	ctx := context.Background()
+
+	dirReply := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
+	dirReply.Append(wire.NewTLVBE(wire.ODirTLVFirstName, "Bob"))
+	dirReply.Append(wire.NewTLVBE(wire.ODirTLVLastName, "Smith"))
+	dirReply.Append(wire.NewTLVBE(wire.ODirTLVCity, "Reno"))
+
+	feedbagService := newMockFeedbagService(t)
+	feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
+
+	locateService := newMockLocateService(t)
+	locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		Return(onlineUserInfoReply("founduser", 0), nil)
+	locateService.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: dirReply}, nil)
+
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+	_, err := sessionMgr.GetSession(ctx, aimsid)
+	require.NoError(t, err)
+
+	handler := &PresenceHandler{
+		SessionManager: sessionMgr,
+		FeedbagService: feedbagService,
+		LocateService:  locateService,
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&mdir=1&t=founduser", nil)
+	require.NoError(t, err)
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+
+	var got struct {
+		Response struct {
+			Data struct {
+				Users []struct {
+					AimID   string            `json:"aimId"`
+					Profile *BuddyProfileInfo `json:"profile"`
+				} `json:"users"`
+			} `json:"data"`
+		} `json:"response"`
+	}
+	require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
+	require.Len(t, got.Response.Data.Users, 1)
+
+	profile := got.Response.Data.Users[0].Profile
+	require.NotNil(t, profile, "mdir=1 must carry a profile object")
+	assert.Equal(t, "Bob", profile.FirstName)
+	assert.Equal(t, "Smith", profile.LastName)
+	require.Len(t, profile.HomeAddress, 1)
+	assert.Equal(t, "Reno", profile.HomeAddress[0].City)
+}
+
+func TestPresenceHandler_GetPresence_MdirProfileIsEmptyNotAbsent(t *testing.T) {
+	// A user with no directory record must still render, or a freshly created
+	// account can never be found.
+	ctx := context.Background()
+
+	feedbagService := newMockFeedbagService(t)
+	feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
+
+	locateService := newMockLocateService(t)
+	locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		Return(onlineUserInfoReply("blankuser", 0), nil)
+	locateService.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}}, nil)
+
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+	_, err := sessionMgr.GetSession(ctx, aimsid)
+	require.NoError(t, err)
+
+	handler := &PresenceHandler{
+		SessionManager: sessionMgr,
+		FeedbagService: feedbagService,
+		LocateService:  locateService,
+		Logger:         slog.Default(),
+	}
+
+	req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&mdir=1&t=blankuser", nil)
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
+
+	// The key must be present and non-null, which is what the client tests for.
+	assert.Contains(t, rr.Body.String(), `"profile":{}`)
+}
+
+func TestPresenceHandler_GetPresence_NoMdirOmitsProfile(t *testing.T) {
+	// Without mdir the directory is not consulted at all — the mock asserts that by
+	// having no DirInfo expectation.
+	ctx := context.Background()
+
+	feedbagService := newMockFeedbagService(t)
+	feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
+
+	locateService := newMockLocateService(t)
+	locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		Return(onlineUserInfoReply("someuser", 0), nil)
+
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+	_, err := sessionMgr.GetSession(ctx, aimsid)
+	require.NoError(t, err)
+
+	handler := &PresenceHandler{
+		SessionManager: sessionMgr,
+		FeedbagService: feedbagService,
+		LocateService:  locateService,
+		Logger:         slog.Default(),
+	}
+
+	req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=someuser", nil)
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
+
+	assert.NotContains(t, rr.Body.String(), `"profile"`)
+}
+
+func TestPresenceHandler_GetPresence_EmptyQueryRendersBothArrays(t *testing.T) {
+	ctx := context.Background()
+
+	// No expectations on either service: naming no list must cost no lookups.
+	feedbagService := newMockFeedbagService(t)
+	locateService := newMockLocateService(t)
+
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+	_, err := sessionMgr.GetSession(ctx, aimsid)
+	require.NoError(t, err)
+
+	handler := &PresenceHandler{
+		SessionManager: sessionMgr,
+		FeedbagService: feedbagService,
+		LocateService:  locateService,
+		Logger:         slog.Default(),
+	}
+
+	req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&f=json&mdir=1", nil)
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), `"users":[]`)
+	assert.Contains(t, rr.Body.String(), `"groups":[]`)
+}
+
+func TestPresenceHandler_GetPresence_TruncatesOversizedTargetList(t *testing.T) {
+	ctx := context.Background()
+
+	feedbagService := newMockFeedbagService(t)
+
+	var queried int
+	locateService := newMockLocateService(t)
+	locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, body wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
+			queried++
+			return onlineUserInfoReply(body.ScreenName, 0), nil
+		})
+
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+	_, err := sessionMgr.GetSession(ctx, aimsid)
+	require.NoError(t, err)
+
+	handler := &PresenceHandler{
+		SessionManager: sessionMgr,
+		FeedbagService: feedbagService,
+		LocateService:  locateService,
+		Logger:         slog.Default(),
+	}
+
+	query := "/presence/get?aimsid=" + aimsid + "&f=json"
+	for i := 0; i < maxPresenceTargets+8; i++ {
+		query += fmt.Sprintf("&t=user%d", i)
+	}
+	req, _ := http.NewRequest("GET", query, nil)
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), `"statusCode":200`)
+	assert.Equal(t, maxPresenceTargets, queried, "the list is cut to the cap, not refused")
+
+	// A full search page has to fit: 20 profiles plus the searched-for UIN.
+	assert.GreaterOrEqual(t, maxPresenceTargets, 21)
+}
+
+// searchPageTargets builds n distinct screen names, standing in for the hits of a
+// member-directory search page.
+func searchPageTargets(n int) []string {
+	names := make([]string, 0, n)
+	for i := 0; i < n; i++ {
+		names = append(names, fmt.Sprintf("user%d", i))
+	}
+	return names
+}

+ 6 - 10
server/webapi/response.go

@@ -113,21 +113,17 @@ func newErrorResponseDetail(statusCode, detailCode int, message string) ErrorRes
 // requestFormat returns the format the client asked for. A POST sends "f" in
 // its body, as clientLogin does.
 func requestFormat(r *http.Request) string {
-	format := strings.ToLower(r.URL.Query().Get("f"))
-	if format == "" && r.Method == http.MethodPost {
-		_ = r.ParseForm()
-		format = strings.ToLower(r.FormValue("f"))
-	}
-	return format
+	return strings.ToLower(param(r, "f"))
 }
 
-// requestIDFromRequest returns the Web AIM client request correlation id from the
-// "r" query parameter. JSONP callbacks require this echoed in response.requestId.
+// requestIDFromRequest returns the client's correlation id from the "r" parameter,
+// echoed in response.requestId on every path. It may arrive in the POST body, so it
+// is read from either location.
 func requestIDFromRequest(r *http.Request) string {
 	if r == nil {
 		return ""
 	}
-	return r.URL.Query().Get("r")
+	return param(r, "r")
 }
 
 // normalizeEnvelope fills in the envelope fields a handler does not set itself:
@@ -212,7 +208,7 @@ func SendErrorDetail(w http.ResponseWriter, r *http.Request, httpStatus, statusC
 func SendOK(w http.ResponseWriter, r *http.Request, data interface{}, logger *slog.Logger) {
 	resp := BaseResponse{}
 	resp.Response.StatusCode = 200
-	resp.Response.StatusText = "OK"
+	resp.Response.StatusText = "Ok"
 	resp.Response.Data = data
 	SendResponse(w, r, resp, logger)
 }

+ 2 - 2
server/webapi/response_test.go

@@ -63,7 +63,7 @@ func TestSendResponseIncludesRequestID(t *testing.T) {
 	resp := BaseResponse{
 		Response: ResponseBody{
 			StatusCode: 200,
-			StatusText: "OK",
+			StatusText: "Ok",
 			Data:       map[string]string{"resultCode": "success"},
 		},
 	}
@@ -71,7 +71,7 @@ func TestSendResponseIncludesRequestID(t *testing.T) {
 
 	assert.Equal(t, http.StatusOK, rr.Code)
 	body := strings.TrimSpace(rr.Body.String())
-	assert.Equal(t, `{"response":{"statusCode":200,"statusText":"OK","requestId":"req-42","data":{"resultCode":"success"}}}`, body)
+	assert.Equal(t, `{"response":{"statusCode":200,"statusText":"Ok","requestId":"req-42","data":{"resultCode":"success"}}}`, body)
 }
 
 // A JSONP error must arrive as an executable callback, not as bare JSON in a

+ 33 - 7
server/webapi/server.go

@@ -147,6 +147,24 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 			w.WriteHeader(http.StatusNoContent)
 		})
 
+		// No SSO cookie is involved, so this sits outside the session middleware.
+		// Both methods, since clients differ on which they use.
+		getInfo := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Access-Control-Allow-Origin", "*")
+			w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+			w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+			authHandler.GetInfo(w, r)
+		})
+		mux.Handle("GET /auth/getInfo", getInfo)
+		mux.Handle("POST /auth/getInfo", getInfo)
+
+		mux.HandleFunc("OPTIONS /auth/getInfo", func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Access-Control-Allow-Origin", "*")
+			w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+			w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+			w.WriteHeader(http.StatusNoContent)
+		})
+
 		// Web AIM navigates the browser here on File > Logout; clear SSO state
 		// and redirect to the login screen.
 		mux.Handle("GET /auth/logout", http.HandlerFunc(authHandler.Logout))
@@ -160,10 +178,13 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 
 		// Authenticated Web AIM API endpoints
 		// SessionInstance management - supports multiple auth methods (k, a, ts+sig_sha256).
-		mux.Handle("GET /aim/startSession",
-			authMiddleware.CORSMiddleware(
-				authMiddleware.AuthenticateFlexible(
-					http.HandlerFunc(aimHandler.StartSession))))
+		//
+		// Both methods: Go 1.22 patterns are method-exact and clients differ.
+		startSession := authMiddleware.CORSMiddleware(
+			authMiddleware.AuthenticateFlexible(
+				http.HandlerFunc(aimHandler.StartSession)))
+		mux.Handle("GET /aim/startSession", startSession)
+		mux.Handle("POST /aim/startSession", startSession)
 
 		// End session - uses aimsid for auth, no k required
 		mux.Handle("GET /aim/endSession", sessionRoute(aimHandler.EndSession))
@@ -234,7 +255,10 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		// aimsid-based auth, so we use flexible auth.
 		mux.Handle("GET /memberDir/search", oscarRoute(wire.ODir, wire.ODirInfoQuery, memberDirHandler.Search))
 		mux.Handle("GET /memberDir/get", oscarRoute(wire.Locate, wire.LocateGetDirInfo, memberDirHandler.Get))
-		mux.Handle("GET /memberDir/update", oscarRoute(wire.Locate, wire.LocateSetDirInfo, memberDirHandler.Update))
+		// Both methods: Go 1.22 patterns are method-exact and clients differ.
+		memberDirUpdate := oscarRoute(wire.Locate, wire.LocateSetDirInfo, memberDirHandler.Update)
+		mux.Handle("GET /memberDir/update", memberDirUpdate)
+		mux.Handle("POST /memberDir/update", memberDirUpdate)
 
 		// These endpoints support aimsid-based auth, so we use a flexible auth approach
 		mux.Handle("GET /preference/set", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, preferenceHandler.SetPreferences))
@@ -254,8 +278,10 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		mux.Handle("GET /expressions/get",
 			authMiddleware.CORSMiddleware(
 				http.HandlerFunc(expressionsHandler.Get)))
+		// WithBinaryBody: the body is the raw image, and a missed parameter lookup
+		// would otherwise feed it to ParseForm and consume it.
 		mux.Handle("POST /expressions/upload",
-			oscarRoute(wire.BART, wire.BARTUploadQuery, expressionsHandler.Upload))
+			WithBinaryBody(oscarRoute(wire.BART, wire.BARTUploadQuery, expressionsHandler.Upload)))
 
 		// Web AIM calls lifestream/* on the API host (e.g. /lifestream/getUserDetails).
 		lifestreamStub := &UserInfoStubHandler{Logger: logger}
@@ -465,7 +491,7 @@ func (h Handler) GetHelloWorldHandler(w http.ResponseWriter, r *http.Request) {
 	resp := map[string]interface{}{
 		"response": map[string]interface{}{
 			"statusCode": 200,
-			"statusText": "OK",
+			"statusText": "Ok",
 			"data":       map[string]interface{}{},
 		},
 	}

+ 121 - 10
server/webapi/session.go

@@ -84,6 +84,9 @@ type Session struct {
 	aliasMu      sync.Mutex
 	imLog        map[string][]WebAPIStoredIM
 	imLogMu      sync.Mutex
+	sentIMs      map[uint64]string // OSCAR message cookie -> the msgId given to the client
+	sentIMOrder  []uint64          // insertion order of sentIMs, oldest first
+	sentIMMu     sync.Mutex
 	// IMRateClassID is the rate class that sending an IM spends. The web client
 	// renders any rate limit event as the IM banner, so only this class's updates
 	// may reach it. Zero disables the alert.
@@ -318,6 +321,8 @@ func (s *Session) handleICBMMessage(msg wire.SNACMessage) {
 		s.handleIncomingIM(msg)
 	case wire.ICBMClientEvent:
 		s.handleTypingNotification(msg)
+	case wire.ICBMClientErr:
+		s.handleClientError(msg)
 	}
 }
 
@@ -388,6 +393,7 @@ func (s *Session) handleIncomingIM(msg wire.SNACMessage) {
 			Message:   messageText,
 			MsgID:     msgID,
 			Timestamp: timestamp,
+			Imf:       imfPlainText,
 			AutoResp:  autoResponse,
 		})
 		s.logger.Debug("delivered offline instant message",
@@ -406,6 +412,7 @@ func (s *Session) handleIncomingIM(msg wire.SNACMessage) {
 			Message:   messageText,
 			MsgID:     msgID,
 			Timestamp: timestamp,
+			Imf:       imfPlainText,
 			AutoResp:  autoResponse,
 		})
 		s.logger.Debug("delivered instant message",
@@ -434,6 +441,40 @@ func (s *Session) handleIncomingIM(msg wire.SNACMessage) {
 	}
 }
 
+// handleClientError translates ICBMClientErr — the recipient reporting that it
+// could not handle a message already delivered to it — into a clientError event
+// for the sender. Only OSCAR clients raise this SNAC.
+func (s *Session) handleClientError(msg wire.SNACMessage) {
+	if !s.IsSubscribedTo("im") {
+		return
+	}
+
+	body, ok := msg.Body.(wire.SNAC_0x04_0x0B_ICBMClientErr)
+	if !ok {
+		return
+	}
+
+	// The SNAC names the erroring party by their own formatting, so both the
+	// normalized aimId and displayId are sent, along with the viewer's alias.
+	sender := state.NewIdentScreenName(body.ScreenName)
+
+	channel := "im"
+	if body.ChannelID == wire.ICBMChannelRendezvous {
+		channel = "data"
+	}
+
+	s.EventQueue.Push(EventTypeClientError, ClientErrorEvent{
+		Source: UserInfo{
+			AimID:     sender.String(),
+			DisplayID: body.ScreenName,
+			Friendly:  s.aliasFor(sender),
+			UserType:  "aim",
+		},
+		Cookie:  s.msgIDForCookie(body.Cookie),
+		Channel: channel,
+	})
+}
+
 // handleTypingNotification handles typing notifications.
 func (s *Session) handleTypingNotification(msg wire.SNACMessage) {
 	if !s.IsSubscribedTo("typing") {
@@ -491,6 +532,8 @@ func (s *Session) handleBuddyArrived(msg wire.SNACMessage) {
 	// with updated user flags/status bits, not BuddyDeparted.
 	if body.IsInvisible() {
 		stateStr = "offline"
+	} else if st := statusBitState(body.TLVUserInfo); st != "" {
+		stateStr = st
 	} else if body.IsAway() {
 		stateStr = "away"
 	} else if mask, ok := body.Uint32BE(wire.OServiceUserInfoStatus); ok {
@@ -554,20 +597,50 @@ func (s *Session) handleBuddyDeparted(msg wire.SNACMessage) {
 	s.EventQueue.Push(EventTypePresence, presenceEvent)
 }
 
+// feedbagResultAuthRequired is the per-item feedbag result meaning the target's ICQ
+// settings require authorization, so the item was not stored.
+const feedbagResultAuthRequired = uint16(0x000E)
+
+// refreshBuddyList re-reads the roster and pushes it to the client. Runs on the SNAC
+// listener goroutine, so it uses the session context rather than a request context.
+func (s *Session) refreshBuddyList() {
+	// A buddy item carries its alias, so any feedbag write can change the map.
+	s.InvalidateAliases()
+
+	if s.BuddyListRefresher == nil {
+		return
+	}
+	payload, err := s.BuddyListRefresher(s.ctx)
+	if err != nil {
+		s.logger.Error("failed to refresh buddy list after feedbag change", "err", err)
+		return
+	}
+	s.EventQueue.Push(EventTypeBuddyList, payload)
+}
+
 func (s *Session) handleFeedbagMessage(msg wire.SNACMessage) {
 	switch msg.Frame.SubGroup {
-	case wire.FeedbagInsertItem, wire.FeedbagUpdateItem, wire.FeedbagDeleteItem:
-		// A buddy item carries its alias, so any feedbag write can change the map.
-		s.InvalidateAliases()
-
-		if s.BuddyListRefresher != nil {
-			payload, err := s.BuddyListRefresher(s.ctx)
-			if err != nil {
-				s.logger.Error("failed to refresh buddy list after feedbag change", "err", err)
-			} else {
-				s.EventQueue.Push(EventTypeBuddyList, payload)
+	case wire.FeedbagStatus:
+		// Insert/update/delete below reach only a user's *other* instances, so this
+		// is the one notification a session gets for its own feedbag write.
+		if !s.IsSubscribedTo(string(EventTypeBuddyList)) {
+			return
+		}
+		if body, ok := msg.Body.(wire.SNAC_0x13_0x0E_FeedbagStatus); ok {
+			// A buddy declined for authorization is not stored, and is simply
+			// absent from the refreshed roster.
+			for _, result := range body.Results {
+				if result == feedbagResultAuthRequired {
+					s.logger.Info("feedbag item declined pending authorization")
+					break
+				}
 			}
 		}
+		s.refreshBuddyList()
+
+	case wire.FeedbagInsertItem, wire.FeedbagUpdateItem, wire.FeedbagDeleteItem:
+		s.refreshBuddyList()
+
 		if s.PermitDenyRefresher != nil {
 			// An insert and an update both relay an UpdateItem body; only a
 			// delete carries a DeleteItem body.
@@ -834,6 +907,44 @@ func generateSessionID() (string, error) {
 	return hex.EncodeToString(bytes), nil
 }
 
+// sentIMCookieLimit bounds the cookie->msgId map. An error arrives within seconds
+// of the send, so a small window suffices; the oldest entry is evicted once full.
+const sentIMCookieLimit = 256
+
+// RecordSentIM remembers the msgId handed out for an outgoing message, keyed by the
+// OSCAR cookie that message carries on the wire. The two are unrelated by
+// construction, so a clientError — which names its message by cookie — could not
+// otherwise say which message it refers to.
+func (s *Session) RecordSentIM(cookie uint64, msgID string) {
+	if s == nil || msgID == "" {
+		return
+	}
+	s.sentIMMu.Lock()
+	defer s.sentIMMu.Unlock()
+	if s.sentIMs == nil {
+		s.sentIMs = make(map[uint64]string)
+	}
+	if _, seen := s.sentIMs[cookie]; !seen {
+		s.sentIMOrder = append(s.sentIMOrder, cookie)
+	}
+	s.sentIMs[cookie] = msgID
+	if len(s.sentIMOrder) > sentIMCookieLimit {
+		delete(s.sentIMs, s.sentIMOrder[0])
+		s.sentIMOrder = s.sentIMOrder[1:]
+	}
+}
+
+// msgIDForCookie resolves an OSCAR message cookie back to the msgId handed out for
+// it, or "" when the message is not one this session sent.
+func (s *Session) msgIDForCookie(cookie uint64) string {
+	if s == nil {
+		return ""
+	}
+	s.sentIMMu.Lock()
+	defer s.sentIMMu.Unlock()
+	return s.sentIMs[cookie]
+}
+
 // WebAPIStoredIM is one message in a Web AIM session's in-memory IM log.
 // The Web AIM client expects fetchStoredIMs entries with sender, message, msgId, and date.
 type WebAPIStoredIM struct {

+ 197 - 0
server/webapi/session_test.go

@@ -3,6 +3,7 @@ package webapi
 import (
 	"context"
 	"encoding/hex"
+	"fmt"
 	"io"
 	"log/slog"
 	"testing"
@@ -1252,3 +1253,199 @@ func TestSession_GetStoredIMs_NormalizesPartner(t *testing.T) {
 	require.Len(t, msgs, 1)
 	assert.Equal(t, "msg-1", msgs[0].MsgID)
 }
+
+// A session gets no insert/update/delete SNAC for a feedbag change it made itself —
+// those reach only a user's *other* instances. FeedbagStatus is the one notification
+// it does receive, so it drives the roster event for the client's own edits.
+func TestSession_FeedbagStatusRefreshesBuddyList(t *testing.T) {
+	tests := []struct {
+		name      string
+		events    []string
+		results   []uint16
+		body      any
+		wantEvent bool
+	}{
+		{
+			name:      "stored item refreshes the roster",
+			events:    []string{"buddylist"},
+			results:   []uint16{0x0000},
+			wantEvent: true,
+		},
+		{
+			// The declined item is still worth a refresh: the roster is how the
+			// client discovers the buddy was not stored, since it is absent from it.
+			name:      "declined item still refreshes the roster",
+			events:    []string{"buddylist"},
+			results:   []uint16{feedbagResultAuthRequired},
+			wantEvent: true,
+		},
+		{
+			name:      "no event when not subscribed",
+			events:    []string{"presence"},
+			results:   []uint16{0x0000},
+			wantEvent: false,
+		},
+		{
+			name:      "a body of the wrong type still refreshes",
+			events:    []string{"buddylist"},
+			body:      wire.SNAC_0x13_0x06_FeedbagReply{},
+			wantEvent: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			refreshed := 0
+			sess := &Session{
+				ScreenName: state.DisplayScreenName("me"),
+				Events:     tt.events,
+				EventQueue: NewEventQueue(10),
+				logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+				BuddyListRefresher: func(_ context.Context) (interface{}, error) {
+					refreshed++
+					return &BuddyListData{Groups: []BuddyGroup{}}, nil
+				},
+			}
+
+			body := tt.body
+			if body == nil {
+				body = wire.SNAC_0x13_0x0E_FeedbagStatus{Results: tt.results}
+			}
+			sess.handleFeedbagMessage(wire.SNACMessage{
+				Frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagStatus},
+				Body:  body,
+			})
+
+			var got int
+			for _, event := range sess.EventQueue.GetAllEvents() {
+				if event.Type == EventTypeBuddyList {
+					got++
+				}
+			}
+			if tt.wantEvent {
+				assert.Equal(t, 1, got)
+				assert.Equal(t, 1, refreshed)
+			} else {
+				assert.Zero(t, got)
+				assert.Zero(t, refreshed, "an unsubscribed session should not even query the roster")
+			}
+		})
+	}
+}
+
+func TestSession_HandleClientError(t *testing.T) {
+	const (
+		cookie = uint64(0xDEADBEEFCAFEF00D)
+		msgID  = "11112222-3333-4444-8000-555566667777"
+	)
+
+	tests := []struct {
+		name string
+		// events is what the session subscribed to at startSession.
+		events []string
+		// record seeds the cookie->msgId map the way a prior im/sendIM would.
+		record    bool
+		channelID uint16
+		wantEvent bool
+		wantCooki string
+		wantChan  string
+	}{
+		{
+			name:      "im channel names the message this session sent",
+			events:    []string{"im"},
+			record:    true,
+			channelID: wire.ICBMChannelIM,
+			wantEvent: true,
+			wantCooki: msgID,
+			wantChan:  "im",
+		},
+		{
+			name:      "rendezvous channel is reported as data",
+			events:    []string{"im"},
+			record:    true,
+			channelID: wire.ICBMChannelRendezvous,
+			wantEvent: true,
+			wantCooki: msgID,
+			wantChan:  "data",
+		},
+		{
+			// Another instance of the account sent the message, so this session
+			// has no msgId for it and must not invent one.
+			name:      "unknown cookie yields an empty msgId",
+			events:    []string{"im"},
+			record:    false,
+			channelID: wire.ICBMChannelIM,
+			wantEvent: true,
+			wantCooki: "",
+			wantChan:  "im",
+		},
+		{
+			name:      "not subscribed to im",
+			events:    []string{"presence"},
+			record:    true,
+			channelID: wire.ICBMChannelIM,
+			wantEvent: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sess := &Session{
+				Events:     tt.events,
+				EventQueue: NewEventQueue(10),
+			}
+			if tt.record {
+				sess.RecordSentIM(cookie, msgID)
+			}
+
+			sess.handleSNACMessage(wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMClientErr,
+				},
+				Body: wire.SNAC_0x04_0x0B_ICBMClientErr{
+					Cookie:     cookie,
+					ChannelID:  tt.channelID,
+					ScreenName: "Mike Kelly",
+					Code:       0x0004,
+				},
+			})
+
+			events := sess.EventQueue.GetAllEvents()
+			if !tt.wantEvent {
+				assert.Empty(t, events)
+				return
+			}
+
+			require.Len(t, events, 1)
+			assert.Equal(t, EventTypeClientError, events[0].Type)
+			got := events[0].Data.(ClientErrorEvent)
+			assert.Equal(t, tt.wantCooki, got.Cookie)
+			assert.Equal(t, tt.wantChan, got.Channel)
+			// The client keys users by the normalized id and renders the sender's
+			// own formatting, so both forms have to survive the translation.
+			assert.Equal(t, "mikekelly", got.Source.AimID)
+			assert.Equal(t, "Mike Kelly", got.Source.DisplayID)
+		})
+	}
+}
+
+func TestSession_RecordSentIMEvictsOldestCookie(t *testing.T) {
+	sess := &Session{}
+
+	for i := 0; i <= sentIMCookieLimit; i++ {
+		sess.RecordSentIM(uint64(i), fmt.Sprintf("msg-%d", i))
+	}
+
+	// The map is capped, so the oldest send is forgotten while the newest and the
+	// one that pushed the map over its limit are both still resolvable.
+	assert.Equal(t, "", sess.msgIDForCookie(0))
+	assert.Equal(t, "msg-1", sess.msgIDForCookie(1))
+	assert.Equal(t, fmt.Sprintf("msg-%d", sentIMCookieLimit), sess.msgIDForCookie(uint64(sentIMCookieLimit)))
+	assert.Len(t, sess.sentIMs, sentIMCookieLimit)
+
+	// A repeat cookie updates in place rather than consuming another slot.
+	sess.RecordSentIM(1, "msg-1-again")
+	assert.Equal(t, "msg-1-again", sess.msgIDForCookie(1))
+	assert.Len(t, sess.sentIMs, sentIMCookieLimit)
+}

+ 106 - 0
server/webapi/strict_keys_test.go

@@ -0,0 +1,106 @@
+package webapi
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// Some clients read these response keys strictly, throwing on an absent key rather
+// than treating it as empty, and nothing surfaces that failure: an event parse is
+// dropped and a queued request retried forever. Each key below must therefore be
+// present even when its value is empty, zero or false — presence is the contract, not
+// the value. These tests keep a future `omitempty` from reintroducing the failure.
+
+// renderJSON sends payload through the default JSON path and returns the body.
+func renderJSON(t *testing.T, data any) string {
+	t.Helper()
+	rr := httptest.NewRecorder()
+	SendOK(rr, httptest.NewRequest(http.MethodGet, "/x?f=json", nil), data, nil)
+	return rr.Body.String()
+}
+
+func TestStrictKeys_IMEvents(t *testing.T) {
+	t.Run("im carries imf and autoresponse when false", func(t *testing.T) {
+		body := renderJSON(t, IMEvent{
+			Source:  UserInfo{AimID: "chattingchuck"},
+			Message: "hi",
+			Imf:     imfPlainText,
+		})
+
+		assert.Contains(t, body, `"imf":"plain"`)
+		assert.Contains(t, body, `"autoresponse":false`)
+	})
+
+	t.Run("offlineIM carries imf and autoresponse when false", func(t *testing.T) {
+		body := renderJSON(t, OfflineIMEvent{
+			AimID:   "chattingchuck",
+			Message: "hi",
+			Imf:     imfPlainText,
+		})
+
+		assert.Contains(t, body, `"imf":"plain"`)
+		assert.Contains(t, body, `"autoresponse":false`)
+	})
+}
+
+func TestStrictKeys_BuddyGroupID(t *testing.T) {
+	// The zero id is the interesting case: omitempty here would drop the key for
+	// the group the client is most likely to have, and cost it the whole roster.
+	body := renderJSON(t, BuddyListData{Groups: []BuddyGroup{{
+		Name: "Buddies", ID: 0, Buddies: []BuddyInfo{},
+	}}})
+
+	assert.Contains(t, body, `"id":0`)
+}
+
+func TestStrictKeys_PresenceUsers(t *testing.T) {
+	// Each query fills in one field, and a match of none must still render that
+	// field as an empty array rather than drop it: a client reading data.users or
+	// data.groups strictly cannot tell an absent key from a failed request.
+	populatedGroups := []BuddyGroupInfo{{Name: "Buddies", Buddies: []BuddyPresenceInfo{}}}
+
+	t.Run("empty user result still renders the array", func(t *testing.T) {
+		body := renderJSON(t, PresenceData{Users: []BuddyPresenceInfo{}})
+
+		assert.Contains(t, body, `"users":[]`)
+	})
+
+	t.Run("empty group result still renders the array", func(t *testing.T) {
+		body := renderJSON(t, PresenceData{Groups: []BuddyGroupInfo{}})
+
+		assert.Contains(t, body, `"groups":[]`)
+	})
+
+	t.Run("buddy list query omits users", func(t *testing.T) {
+		body := renderJSON(t, PresenceData{Groups: populatedGroups})
+
+		assert.NotContains(t, body, `"users"`)
+	})
+
+	t.Run("presence query omits groups", func(t *testing.T) {
+		body := renderJSON(t, PresenceData{Users: []BuddyPresenceInfo{}})
+
+		assert.NotContains(t, body, `"groups"`)
+	})
+}
+
+func TestStrictKeys_FetchEventsArray(t *testing.T) {
+	// A nil slice renders as null, which throws exactly as an absent key does — and
+	// a poll that throws is retried every 5s forever.
+	body := renderJSON(t, &FetchEventsData{Events: []Event{}})
+
+	assert.Contains(t, body, `"events":[]`)
+	assert.NotContains(t, body, `"events":null`)
+}
+
+func TestStrictKeys_GetInfoUserData(t *testing.T) {
+	// The web client reads loginId and displayName off userData unconditionally, so
+	// both keys must survive a blank name.
+	body := renderJSON(t, GetInfoData{})
+
+	assert.Contains(t, body, `"loginId":""`)
+	assert.Contains(t, body, `"displayName":""`)
+}

+ 1 - 0
server/webapi/types.go

@@ -48,6 +48,7 @@ type DirSearchService interface {
 // FeedbagService reads and edits the server-stored buddy list.
 type FeedbagService interface {
 	DeleteItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x0A_FeedbagDeleteItem) (*wire.SNACMessage, error)
+	PreAuthorizeBuddy(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy) (*wire.SNACMessage, error)
 	Query(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame) (wire.SNACMessage, error)
 	UpsertItem(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) (*wire.SNACMessage, error)
 	Use(ctx context.Context, instance *state.SessionInstance) error

+ 4 - 0
state/cookie.go

@@ -31,6 +31,10 @@ type ServerCookie struct {
 	// KerberosAuth indicates whether the client used Kerberos for authentication.
 	KerberosAuth uint8
 	SessionNum   uint8
+	// TokenTTL is the lifetime in seconds this cookie was granted. Subtracted from
+	// the expiry, it gives the instant the cookie was issued — for a login cookie,
+	// when its owner authenticated. Zero means unspecified.
+	TokenTTL uint32
 }
 
 func NewHMACCookieBaker() (HMACCookieBaker, error) {

+ 26 - 5
wire/amf3.go

@@ -158,7 +158,7 @@ func amf3Fields(v reflect.Value, out map[string]any) error {
 	t := v.Type()
 	for i := 0; i < t.NumField(); i++ {
 		f := t.Field(i)
-		name, omitEmpty, ok := amf3FieldKey(f)
+		name, omitEmpty, omitZero, ok := amf3FieldKey(f)
 		if !ok {
 			continue
 		}
@@ -182,13 +182,16 @@ func amf3Fields(v reflect.Value, out map[string]any) error {
 		if omitEmpty && isEmptyValue(fv) {
 			continue
 		}
+		if omitZero && isZeroValue(fv) {
+			continue
+		}
 
 		val, err := amf3Value(fv)
 		if err != nil {
 			return fmt.Errorf("%s.%s: %w", t.Name(), f.Name, err)
 		}
 		if val == nil {
-			if omitEmpty {
+			if omitEmpty || omitZero {
 				continue
 			}
 			// A field the client dereferences unconditionally, such as a
@@ -204,16 +207,16 @@ func amf3Fields(v reflect.Value, out map[string]any) error {
 // amf3FieldKey returns the AMF3 name for f and whether it is written at all. An
 // amf3 tag replaces the json tag outright, so a field that must always be present
 // in AMF but is omitempty in JSON just names itself in amf3.
-func amf3FieldKey(f reflect.StructField) (name string, omitEmpty, ok bool) {
+func amf3FieldKey(f reflect.StructField) (name string, omitEmpty, omitZero, ok bool) {
 	tag, tagged := f.Tag.Lookup("amf3")
 	if !tagged {
 		tag = f.Tag.Get("json")
 	}
 	if tag == "-" {
-		return "", false, false
+		return "", false, false, false
 	}
 	name, opts, _ := strings.Cut(tag, ",")
-	return name, hasTagOption(opts, "omitempty"), true
+	return name, hasTagOption(opts, "omitempty"), hasTagOption(opts, "omitzero"), true
 }
 
 // hasTagOption reports whether the comma-separated tag options contain want.
@@ -228,6 +231,24 @@ func hasTagOption(opts, want string) bool {
 	return false
 }
 
+var zeroerType = reflect.TypeOf((*interface{ IsZero() bool })(nil)).Elem()
+
+// isZeroValue reports whether v is the zero value omitzero suppresses, mirroring
+// encoding/json including the IsZero override. Unlike omitempty, an empty-but-non-nil
+// slice or map is not zero.
+func isZeroValue(v reflect.Value) bool {
+	if v.Type().Implements(zeroerType) {
+		if v.Kind() == reflect.Pointer && v.IsNil() {
+			return true
+		}
+		return v.Interface().(interface{ IsZero() bool }).IsZero()
+	}
+	if v.CanAddr() && reflect.PointerTo(v.Type()).Implements(zeroerType) {
+		return v.Addr().Interface().(interface{ IsZero() bool }).IsZero()
+	}
+	return v.IsZero()
+}
+
 // isEmptyValue reports whether v is the zero value that omitempty suppresses.
 func isEmptyValue(v reflect.Value) bool {
 	switch v.Kind() {

+ 30 - 0
wire/amf3_test.go

@@ -211,3 +211,33 @@ func TestMarshalAMF3ByteSlice(t *testing.T) {
 	require.True(t, ok)
 	assert.Equal(t, []byte{1, 2, 3}, m["raw"])
 }
+
+// TestMarshalAMF3_OmitZero pins the omitzero option: unlike omitempty it suppresses
+// only the zero value, so an empty-but-non-nil slice still encodes.
+func TestMarshalAMF3_OmitZero(t *testing.T) {
+	type payload struct {
+		Users []string `json:"users,omitzero"`
+	}
+
+	tests := []struct {
+		name  string
+		value payload
+		want  string
+	}{
+		{name: "nil is omitted", value: payload{}, want: ""},
+		{name: "empty is kept", value: payload{Users: []string{}}, want: "users"},
+		{name: "populated is kept", value: payload{Users: []string{"bob"}}, want: "users"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			b, err := MarshalAMF3(tt.value)
+			assert.NoError(t, err)
+			if tt.want == "" {
+				assert.NotContains(t, string(b), "users")
+			} else {
+				assert.Contains(t, string(b), "users")
+			}
+		})
+	}
+}