4
0
Mike 2 өдөр өмнө
parent
commit
809a1b2e62

+ 1 - 0
cmd/server/factory.go

@@ -605,6 +605,7 @@ func WebAPI(deps Container) *webapi.Server {
 		FeedbagService:     deps.feedbagSvc,
 		DirSearchService:   foodgroup.NewODirService(logger, deps.sqLiteUserStore),
 		IconSource:         iconSource,
+		SNACRateLimits:     deps.snacRateLimits,
 	}
 	// Pass SQLiteUserStore as the API key validator (it implements middleware.APIKeyValidator)
 	return webapi.NewServer(deps.cfg.WebAPIListeners, logger, handler, deps.sqLiteUserStore, deps.webAPISessionManager)

+ 2 - 0
server/webapi/handler.go

@@ -9,6 +9,7 @@ import (
 
 	"github.com/mk6i/open-oscar-server/server/webapi/handlers"
 	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
 )
 
 type Handler struct {
@@ -29,6 +30,7 @@ type Handler struct {
 	FeedbagService     FeedbagService
 	DirSearchService   DirSearchService
 	IconSource         handlers.BuddyIconSource
+	SNACRateLimits     wire.SNACRateLimits
 }
 
 func (h Handler) GetHelloWorldHandler(w http.ResponseWriter, r *http.Request) {

+ 248 - 0
server/webapi/handlers/ratelimit.go

@@ -0,0 +1,248 @@
+package handlers
+
+import (
+	"fmt"
+	"log/slog"
+	"net/http"
+	"time"
+
+	"github.com/mk6i/open-oscar-server/server/webapi/types"
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+)
+
+// rateLimitStatusCode is the Web AIM API envelope code for a rate-limited
+// request. The AIM web client swallows 430 on the IM path so that the rateLimit
+// event owns the user-facing message instead of a generic send failure alert.
+const rateLimitStatusCode = 430
+
+// minRetryAfter floors the Retry-After hint sent with a rate-limited response.
+// The computed wait can round down to nothing when a class is barely over its
+// limit, and a hint of zero invites an immediate retry.
+const minRetryAfter = 1 * time.Second
+
+// SessionHandlerFunc is the session-aware handler shape that
+// AuthMiddleware.RequireSession invokes once it has resolved an aimsid.
+type SessionHandlerFunc = func(http.ResponseWriter, *http.Request, *state.WebAPISession)
+
+// RateLimitMiddleware enforces OSCAR rate limits on Web API routes that reach a
+// food group.
+//
+// Such routes are limited by OSCAR itself: OSCAR charges the session's shared
+// per-rate-class budget, the same budget a native OSCAR or TOC client spends, so
+// a user cannot dodge a limit by switching transports. Routes that reach no food
+// group are not limited here; edge rate limiting (a reverse proxy keyed by client
+// IP) is expected to cover the unauthenticated login/asset endpoints and the
+// authenticated bookkeeping ones.
+//
+// It lives alongside the handlers (rather than in the middleware package) so that
+// its rejection can be encoded through the same SendResponse path the handlers
+// use, honoring the request's JSON/JSONP/XML/AMF format.
+type RateLimitMiddleware struct {
+	snacRateLimits wire.SNACRateLimits
+	// alertRateClassID is the only rate class that pushes a rateLimit event; see
+	// syncRateAlert. Zero when the SNAC table has no mapping for sending an IM,
+	// which disables the push rather than indexing a rate class that isn't there.
+	alertRateClassID wire.RateLimitClassID
+	logger           *slog.Logger
+}
+
+// NewRateLimitMiddleware creates a RateLimitMiddleware. snacRateLimits is the
+// same SNAC-to-rate-class mapping the OSCAR and TOC servers use.
+func NewRateLimitMiddleware(snacRateLimits wire.SNACRateLimits, logger *slog.Logger) *RateLimitMiddleware {
+	alertRateClassID, ok := snacRateLimits.RateClassLookup(wire.ICBM, wire.ICBMChannelMsgToHost)
+	if !ok {
+		logger.Error("no rate class maps to sending an IM, rate limit events are disabled")
+	}
+
+	return &RateLimitMiddleware{
+		snacRateLimits:   snacRateLimits,
+		alertRateClassID: alertRateClassID,
+		logger:           logger,
+	}
+}
+
+// OSCAR returns middleware that charges one unit against the OSCAR rate class
+// mapped to (foodGroup, subGroup) before invoking the wrapped handler. It is the
+// HTTP counterpart of the TOC server's per-command rate check.
+//
+// A SNAC with no rate class mapping is allowed through, since refusing traffic
+// because the server's own table is incomplete would be worse than not limiting
+// it.
+func (l *RateLimitMiddleware) OSCAR(foodGroup uint16, subGroup uint16) func(SessionHandlerFunc) SessionHandlerFunc {
+	return func(next SessionHandlerFunc) SessionHandlerFunc {
+		return func(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+			ctx := r.Context()
+
+			rateClassID, ok := l.snacRateLimits.RateClassLookup(foodGroup, subGroup)
+			if !ok {
+				l.logger.ErrorContext(ctx, "rate limit not found, allowing request through",
+					"foodgroup", wire.FoodGroupName(foodGroup),
+					"subgroup", wire.SubGroupName(foodGroup, subGroup))
+				next(w, r, session)
+				return
+			}
+
+			sess := session.OSCARSession.Session()
+			status := sess.EvaluateRateLimit(time.Now(), rateClassID)
+			l.syncRateAlert(session, rateClassID, status)
+
+			// Disconnect is rejected alongside Limited: EvaluateRateLimit has
+			// already closed the account's OSCAR session by the time it returns,
+			// so there is nothing left for the handler to act on. That close also
+			// invalidates the aimsid (GetSession stops resolving a session whose
+			// OSCAR instance is closed), so every subsequent request is turned
+			// away at RequireSession rather than reaching here again.
+			if status == wire.RateLimitStatusLimited || status == wire.RateLimitStatusDisconnect {
+				l.logger.DebugContext(ctx, "(webapi) rate limit exceeded, dropping request",
+					"foodgroup", wire.FoodGroupName(foodGroup),
+					"subgroup", wire.SubGroupName(foodGroup, subGroup),
+					"status", rateLimitStatusName(status))
+
+				// A disconnected session has no aimsid left to retry with, so
+				// there is no wait to advertise.
+				var retryAfter time.Duration
+				if status == wire.RateLimitStatusLimited {
+					retryAfter = retryAfterFor(sess.RateLimitStates()[rateClassID-1])
+				}
+				l.sendRateLimited(w, r, retryAfter)
+				return
+			}
+
+			next(w, r, session)
+		}
+	}
+}
+
+// RecoverOnPoll re-evaluates rate limit recovery before serving a long poll and
+// pushes the "clear" that dismisses the client's alert once it recovers.
+//
+// OSCAR only recomputes rate limit status on a charged request (see OSCAR), so a
+// session that trips the limit and then goes idle would never learn it recovered,
+// and the client's alert is sticky until a "clear" arrives. fetchEvents is polled
+// continuously, so recovering here surfaces the clear without the user sending
+// anything; the push happens before next() so it rides out on this poll.
+//
+// Only a clear is pushed. A poll is not a request the user made, so it must not
+// raise an alert: the budget is account-wide, and syncing an idle tab up to a
+// limit another tab tripped would tell a user doing nothing to stop chatting. The
+// limit reaches a client on the request it actually rejects, in OSCAR.
+func (l *RateLimitMiddleware) RecoverOnPoll(next SessionHandlerFunc) SessionHandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+		if l.alertRateClassID == 0 {
+			next(w, r, session)
+			return
+		}
+
+		sess := session.OSCARSession.Session()
+		sess.RecoverRateLimits(time.Now())
+		if status := sess.RateLimitStates()[l.alertRateClassID-1].CurrentStatus; status == wire.RateLimitStatusClear {
+			l.syncRateAlert(session, l.alertRateClassID, status)
+		}
+
+		next(w, r, session)
+	}
+}
+
+// syncRateAlert notifies the client that its rate limit status changed so it can
+// show (or dismiss) the rate limit alert. The disconnect push is best-effort:
+// EvaluateRateLimit closes the session before the queue drains.
+//
+// Only alertRateClassID pushes. The client throws away the class id the event
+// carries and feeds the status into a single alert rendered inside a
+// conversation window ("You have been rate limited. Wait for a few moments until
+// you can chat again."), so the one class it can truthfully describe is the one
+// sending an IM spends. Pushing for the others would pop a chat alert for a
+// buddy list edit while IMs still work, and let a "clear" on an unrelated class
+// dismiss an alert the IM class raised. They stay enforced, silently.
+func (l *RateLimitMiddleware) syncRateAlert(session *state.WebAPISession, classID wire.RateLimitClassID, status wire.RateLimitStatus) {
+	if classID != l.alertRateClassID {
+		return
+	}
+	name := rateLimitStatusName(status)
+	if name == "" {
+		return
+	}
+	if !session.ObserveRateAlert(status) {
+		return
+	}
+
+	session.EventQueue.Push(types.EventTypeRateLimit, types.RateLimitEvent{
+		Classes: []types.RateLimitClass{
+			{
+				ID:     int(classID),
+				Status: name,
+			},
+		},
+	})
+}
+
+// retryAfterFor returns how long the client must wait for its next request on
+// this class to clear the limit.
+//
+// OSCAR's limiter has no fixed window: it tracks a moving average of the gap
+// between requests, and a request lifts the limit only once that average climbs
+// back to ClearLevel. Inverting CheckRateLimit's update for the elapsed time that
+// lands the new average exactly on ClearLevel gives
+//
+//	elapsed = ClearLevel*WindowSize - CurrentLevel*(WindowSize-1)
+//
+// A flat hint cannot work here, because a rejected request is still charged: a
+// client retrying on a fixed interval drives the average toward that interval, so
+// any hint below the class's ClearLevel holds the average just under the bar and
+// the client stays limited forever. The production ICBM class clears at 5100ms,
+// which a 5s hint would do exactly.
+func retryAfterFor(rcs state.RateClassState) time.Duration {
+	neededMs := int64(rcs.ClearLevel)*int64(rcs.WindowSize) - int64(rcs.CurrentLevel)*int64(rcs.WindowSize-1)
+
+	// Retry-After carries whole seconds, so round up: a hint that is short by a
+	// fraction of a second reproduces the same never-clears loop. A class barely
+	// over its limit can compute to no wait at all, hence the floor.
+	return max(time.Duration((neededMs+999)/1000)*time.Second, minRetryAfter)
+}
+
+// rateLimitStatusName maps an OSCAR rate limit status onto the status string the
+// web client switches on. It returns "" for a status the client does not know.
+func rateLimitStatusName(status wire.RateLimitStatus) string {
+	switch status {
+	case wire.RateLimitStatusClear:
+		return "clear"
+	case wire.RateLimitStatusAlert:
+		return "warn"
+	case wire.RateLimitStatusLimited:
+		return "limit"
+	case wire.RateLimitStatusDisconnect:
+		return "disconnect"
+	default:
+		return ""
+	}
+}
+
+// sendRateLimited writes a rate limit rejection. The transport status is 200 and
+// the rejection lives entirely in the Web AIM API envelope's own rate limit code.
+//
+// The transport status is deliberately not 429: the AIM client's WIM request layer
+// (XhrManager) and its Fetcher only parse the response body on a 2xx. A non-2xx is
+// routed to their error handlers, which synthesize a generic "request failed"
+// result and never look at the body, so the envelope's 430 — which the client
+// swallows on the IM path in favor of the rateLimit event — would go unread and the
+// user would see a generic send failure instead.
+//
+// The body is encoded via SendResponse, so it honors the request's format
+// (JSON/JSONP/XML/AMF) and echoes the request id into response.requestId — which
+// the JSONP fallback needs to correlate the reply, or its UI hangs — exactly as a
+// normal handler response would.
+//
+// A retryAfter of zero sends no Retry-After header, for the rejections that have
+// nothing to retry.
+func (l *RateLimitMiddleware) sendRateLimited(w http.ResponseWriter, r *http.Request, retryAfter time.Duration) {
+	if retryAfter > 0 {
+		w.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = rateLimitStatusCode
+	resp.Response.StatusText = "rate limit exceeded"
+
+	SendResponse(w, r, resp, l.logger)
+}

+ 514 - 0
server/webapi/handlers/ratelimit_test.go

@@ -0,0 +1,514 @@
+package handlers
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/mk6i/open-oscar-server/server/webapi/types"
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+)
+
+// tightRateLimitClasses returns rate classes scaled down so tests run fast.
+//
+// OSCAR's moving average tracks the interval between requests in milliseconds,
+// seeded at MaxLevel, and each back-to-back request halves it at WindowSize 2.
+// So from 200 the sequence is 100 (clear), 50 (limited), 25, 12, 6 — the second
+// request trips the limit, and none of the first five fall below the disconnect
+// threshold. Recovering past ClearLevel takes a ~150ms pause rather than the
+// several seconds the production classes would need.
+func tightRateLimitClasses() wire.RateLimitClasses {
+	var classes [5]wire.RateClass
+	for i := range classes {
+		classes[i] = wire.RateClass{
+			ID:              wire.RateLimitClassID(i + 1),
+			WindowSize:      2,
+			ClearLevel:      100,
+			AlertLevel:      80,
+			LimitLevel:      70,
+			DisconnectLevel: 2,
+			MaxLevel:        200,
+		}
+	}
+	return wire.NewRateLimitClasses(classes)
+}
+
+// newTestOSCARInstance builds an OSCAR session with rate limit state
+// initialized, mirroring what RegisterBOSSession does at startSession time.
+func newTestOSCARInstance(t *testing.T, classes wire.RateLimitClasses) *state.SessionInstance {
+	t.Helper()
+
+	instance := state.NewSession().AddInstance()
+	instance.Session().SetIdentScreenName(state.NewIdentScreenName("me"))
+	instance.Session().SetDisplayScreenName("me")
+	instance.Session().SetRateClasses(time.Now(), classes)
+
+	return instance
+}
+
+// newTestWebAPISessionOn builds a WebAPI session over an existing OSCAR
+// instance. Two of them model two browser tabs signed in as the same account:
+// each tab holds its own aimsid and its own WebAPISession, but the account has
+// one OSCAR session and therefore one set of rate limit states.
+func newTestWebAPISessionOn(aimsid string, instance *state.SessionInstance) *state.WebAPISession {
+	return &state.WebAPISession{
+		AimSID:       aimsid,
+		ScreenName:   "me",
+		OSCARSession: instance,
+		EventQueue:   types.NewEventQueue(10),
+	}
+}
+
+// newTestWebAPISession builds a WebAPI session backed by a real OSCAR session
+// with rate limit state initialized.
+func newTestWebAPISession(t *testing.T, classes wire.RateLimitClasses) *state.WebAPISession {
+	t.Helper()
+
+	return newTestWebAPISessionOn("aimsid-1", newTestOSCARInstance(t, classes))
+}
+
+func newTestRateLimitMiddleware() *RateLimitMiddleware {
+	return NewRateLimitMiddleware(wire.DefaultSNACRateLimits(), slog.New(slog.DiscardHandler))
+}
+
+// rateLimitEventStatuses returns the status string of every rateLimit event
+// queued on the session, in order.
+func rateLimitEventStatuses(t *testing.T, session *state.WebAPISession) []string {
+	t.Helper()
+
+	var statuses []string
+	for _, event := range session.EventQueue.GetAllEvents() {
+		if event.Type != types.EventTypeRateLimit {
+			continue
+		}
+		payload, ok := event.Data.(types.RateLimitEvent)
+		if !assert.True(t, ok, "rateLimit event carried %T", event.Data) {
+			continue
+		}
+		if assert.Len(t, payload.Classes, 1) {
+			statuses = append(statuses, payload.Classes[0].Status)
+		}
+	}
+	return statuses
+}
+
+// assertRateLimited checks that a response is the Web API's rate limit
+// rejection: HTTP 200 at the transport level so the client parses the body,
+// with envelope code 430 carrying the rejection inside. wantRetryAfter is the
+// expected Retry-After header, "" for a rejection that carries none.
+func assertRateLimited(t *testing.T, rec *httptest.ResponseRecorder, wantRetryAfter string) {
+	t.Helper()
+
+	assert.Equal(t, http.StatusOK, rec.Code)
+	assert.Equal(t, wantRetryAfter, rec.Header().Get("Retry-After"))
+
+	var envelope struct {
+		Response struct {
+			StatusCode int    `json:"statusCode"`
+			StatusText string `json:"statusText"`
+		} `json:"response"`
+	}
+	assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
+	assert.Equal(t, rateLimitStatusCode, envelope.Response.StatusCode)
+	assert.Equal(t, "rate limit exceeded", envelope.Response.StatusText)
+}
+
+func TestRateLimitMiddleware_OSCAR(t *testing.T) {
+	tests := []struct {
+		name string
+		// foodGroup/subGroup passed to the middleware
+		foodGroup uint16
+		subGroup  uint16
+		// requests is how many times the wrapped handler is invoked
+		requests int
+		// wantCalls is how many of those requests reach the handler
+		wantCalls int
+		// wantEvents are the rateLimit event statuses pushed to the session
+		wantEvents []string
+		// wantRetryAfter is the Retry-After header on the final rejection, "" for
+		// a rejection that carries none
+		wantRetryAfter string
+	}{
+		{
+			name:      "first request is allowed",
+			foodGroup: wire.ICBM,
+			subGroup:  wire.ICBMChannelMsgToHost,
+			requests:  1,
+			wantCalls: 1,
+		},
+		{
+			name:      "a burst trips the limit",
+			foodGroup: wire.ICBM,
+			subGroup:  wire.ICBMChannelMsgToHost,
+			requests:  5,
+			wantCalls: 1,
+			// clear -> limit on the second request; the rest stay limited and
+			// so push nothing further.
+			wantEvents: []string{"limit"},
+			// retryAfterFor rounds the sub-second wait these tight classes need
+			// up to the minRetryAfter floor.
+			wantRetryAfter: "1",
+		},
+		{
+			name:      "sustained abuse escalates to disconnect",
+			foodGroup: wire.ICBM,
+			subGroup:  wire.ICBMChannelMsgToHost,
+			// the 7th request drives the average below DisconnectLevel
+			requests:  7,
+			wantCalls: 1,
+			// EvaluateRateLimit closes the session on disconnect, so the event
+			// is best-effort; it is queued but the client may never fetch it.
+			wantEvents: []string{"limit", "disconnect"},
+			// a disconnected session has no aimsid left to retry with
+			wantRetryAfter: "",
+		},
+		{
+			// The client renders the rateLimit event as an IM alert inside a
+			// conversation window, so a class the IM path does not spend is
+			// enforced without one.
+			name:           "a class the alert cannot describe is limited silently",
+			foodGroup:      wire.Feedbag,
+			subGroup:       wire.FeedbagInsertItem,
+			requests:       5,
+			wantCalls:      1,
+			wantEvents:     nil,
+			wantRetryAfter: "1",
+		},
+		{
+			name: "unmapped SNAC fails open",
+			// 0xFFFF is not a food group, so no rate class maps to it
+			foodGroup: 0xFFFF,
+			subGroup:  0xFFFF,
+			requests:  5,
+			wantCalls: 5,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			session := newTestWebAPISession(t, tightRateLimitClasses())
+			middleware := newTestRateLimitMiddleware()
+
+			calls := 0
+			handler := middleware.OSCAR(tt.foodGroup, tt.subGroup)(
+				func(w http.ResponseWriter, r *http.Request, s *state.WebAPISession) {
+					calls++
+					w.WriteHeader(http.StatusOK)
+				})
+
+			var last *httptest.ResponseRecorder
+			for range tt.requests {
+				last = httptest.NewRecorder()
+				handler(last, httptest.NewRequest(http.MethodGet, "/im/sendIM", nil), session)
+			}
+
+			assert.Equal(t, tt.wantCalls, calls)
+			assert.Equal(t, tt.wantEvents, rateLimitEventStatuses(t, session))
+
+			if tt.wantCalls < tt.requests {
+				assertRateLimited(t, last, tt.wantRetryAfter)
+			} else {
+				assert.Equal(t, http.StatusOK, last.Code)
+			}
+		})
+	}
+}
+
+// The client only dismisses its rate limit alert when it receives a "clear"
+// event, and only pushes on a transition, so a recovering session must produce
+// exactly one clear.
+func TestRateLimitMiddleware_OSCAR_pushesClearOnRecovery(t *testing.T) {
+	session := newTestWebAPISession(t, tightRateLimitClasses())
+	middleware := newTestRateLimitMiddleware()
+
+	handler := middleware.OSCAR(wire.ICBM, wire.ICBMChannelMsgToHost)(
+		func(w http.ResponseWriter, r *http.Request, s *state.WebAPISession) {})
+
+	// Trip the limit.
+	for range 3 {
+		handler(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/im/sendIM", nil), session)
+	}
+	assert.Equal(t, []string{"limit"}, rateLimitEventStatuses(t, session))
+
+	// Let the moving average recover past ClearLevel. See tightRateLimitClasses
+	// for why this is a short wait rather than a stubbed clock.
+	time.Sleep(250 * time.Millisecond)
+
+	rec := httptest.NewRecorder()
+	handler(rec, httptest.NewRequest(http.MethodGet, "/im/sendIM", nil), session)
+
+	assert.Equal(t, http.StatusOK, rec.Code)
+	assert.Equal(t, []string{"limit", "clear"}, rateLimitEventStatuses(t, session))
+}
+
+// A session that trips the limit and then goes idle must still be told it
+// recovered: the fetchEvents poll carries the clear even though no charged
+// request arrives to recompute the status.
+func TestRateLimitMiddleware_RecoverOnPoll(t *testing.T) {
+	session := newTestWebAPISession(t, tightRateLimitClasses())
+	middleware := newTestRateLimitMiddleware()
+
+	oscar := middleware.OSCAR(wire.ICBM, wire.ICBMChannelMsgToHost)(
+		func(w http.ResponseWriter, r *http.Request, s *state.WebAPISession) {})
+
+	// Trip the limit.
+	for range 3 {
+		oscar(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/im/sendIM", nil), session)
+	}
+	assert.Equal(t, []string{"limit"}, rateLimitEventStatuses(t, session))
+
+	// Let the moving average recover past ClearLevel without sending anything.
+	// See tightRateLimitClasses for why this is a short wait, not a stubbed clock.
+	time.Sleep(250 * time.Millisecond)
+
+	calls := 0
+	poll := middleware.RecoverOnPoll(func(w http.ResponseWriter, r *http.Request, s *state.WebAPISession) {
+		calls++
+	})
+
+	// The poll surfaces the clear and still serves the handler.
+	poll(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/aim/fetchEvents", nil), session)
+	assert.Equal(t, 1, calls)
+	assert.Equal(t, []string{"limit", "clear"}, rateLimitEventStatuses(t, session))
+
+	// A second poll after recovery pushes nothing further.
+	poll(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/aim/fetchEvents", nil), session)
+	assert.Equal(t, 2, calls)
+	assert.Equal(t, []string{"limit", "clear"}, rateLimitEventStatuses(t, session))
+}
+
+// Two browser tabs on one account share a single set of rate limit states, so
+// whichever of them polls first observes the underlying limited -> clear
+// transition. The tab that never showed an alert must not be able to consume the
+// recovery the limited tab is waiting for.
+func TestRateLimitMiddleware_RecoverOnPoll_perClientTransitions(t *testing.T) {
+	instance := newTestOSCARInstance(t, tightRateLimitClasses())
+	tabA := newTestWebAPISessionOn("aimsid-a", instance)
+	tabB := newTestWebAPISessionOn("aimsid-b", instance)
+
+	middleware := newTestRateLimitMiddleware()
+	send := middleware.OSCAR(wire.ICBM, wire.ICBMChannelMsgToHost)(
+		func(w http.ResponseWriter, r *http.Request, s *state.WebAPISession) {})
+	poll := middleware.RecoverOnPoll(
+		func(w http.ResponseWriter, r *http.Request, s *state.WebAPISession) {})
+
+	// Tab A trips the limit and raises the alert.
+	for range 3 {
+		send(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/im/sendIM", nil), tabA)
+	}
+	assert.Equal(t, []string{"limit"}, rateLimitEventStatuses(t, tabA))
+
+	// Tab B polls while the account is still limited. The budget is shared, so
+	// tab B really cannot send either — but it is idle, and a poll is not a
+	// request it made, so it is not told to stop chatting.
+	poll(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/aim/fetchEvents", nil), tabB)
+	assert.Empty(t, rateLimitEventStatuses(t, tabB))
+
+	// Let the moving average recover past ClearLevel. See tightRateLimitClasses
+	// for why this is a short wait rather than a stubbed clock.
+	time.Sleep(250 * time.Millisecond)
+
+	// Tab B's idle poll observes the recovery first. It has no alert up, so there
+	// is still nothing to tell it.
+	poll(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/aim/fetchEvents", nil), tabB)
+	assert.Empty(t, rateLimitEventStatuses(t, tabB))
+
+	// Tab A still gets its clear on the next poll, even though tab B already
+	// consumed the transition it would otherwise have been derived from.
+	poll(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/aim/fetchEvents", nil), tabA)
+	assert.Equal(t, []string{"limit", "clear"}, rateLimitEventStatuses(t, tabA))
+}
+
+// Retry-After must name a wait that actually clears the limit. A rejected
+// request is still charged, so a client retrying on the advertised interval
+// drives the moving average toward that interval: a hint below the class's
+// ClearLevel holds the average under the bar and the client stays limited
+// forever.
+func TestRetryAfterFor_clearsTheLimit(t *testing.T) {
+	classes := wire.DefaultRateLimitClasses()
+
+	for _, class := range classes.All() {
+		t.Run(fmt.Sprintf("class %d", class.ID), func(t *testing.T) {
+			sess := state.NewSession()
+			sess.AddInstance()
+
+			now := time.Now()
+			sess.SetRateClasses(now, classes)
+
+			// Burst until the class trips.
+			var status wire.RateLimitStatus
+			for status != wire.RateLimitStatusLimited {
+				now = now.Add(100 * time.Millisecond)
+				status = sess.EvaluateRateLimit(now, class.ID)
+				require.NotEqual(t, wire.RateLimitStatusDisconnect, status, "burst escalated past limited")
+			}
+
+			// Wait exactly as long as the rejection advertised, then retry.
+			retryAfter := retryAfterFor(sess.RateLimitStates()[class.ID-1])
+			now = now.Add(retryAfter)
+
+			assert.Equal(t, wire.RateLimitStatusClear, sess.EvaluateRateLimit(now, class.ID),
+				"honoring a Retry-After of %s did not clear the limit", retryAfter)
+		})
+	}
+}
+
+// The rejection advertises the wait computed for the class it charged, not a
+// flat one.
+func TestRateLimitMiddleware_OSCAR_retryAfterMatchesClass(t *testing.T) {
+	session := newTestWebAPISession(t, wire.DefaultRateLimitClasses())
+	middleware := newTestRateLimitMiddleware()
+
+	handler := middleware.OSCAR(wire.ICBM, wire.ICBMChannelMsgToHost)(
+		func(w http.ResponseWriter, r *http.Request, s *state.WebAPISession) {})
+
+	classID, ok := wire.DefaultSNACRateLimits().RateClassLookup(wire.ICBM, wire.ICBMChannelMsgToHost)
+	require.True(t, ok)
+
+	// Back-to-back requests trip the limit; keep going until one is rejected.
+	var rec *httptest.ResponseRecorder
+	for range 20 {
+		rec = httptest.NewRecorder()
+		handler(rec, httptest.NewRequest(http.MethodGet, "/im/sendIM", nil), session)
+		if rec.Header().Get("Retry-After") != "" {
+			break
+		}
+	}
+
+	want := retryAfterFor(session.OSCARSession.Session().RateLimitStates()[classID-1])
+	assert.Equal(t, fmt.Sprintf("%d", int(want.Seconds())), rec.Header().Get("Retry-After"))
+	// The production IM class clears at 5100ms, so the wait is necessarily
+	// longer than the flat 5s hint this replaced.
+	assert.Greater(t, want, 5*time.Second)
+}
+
+// The rejection is encoded in whatever format the request negotiates, via the
+// same SendResponse path a normal handler uses.
+func TestRateLimitMiddleware_sendRateLimited(t *testing.T) {
+	tests := []struct {
+		name string
+		// query is appended to the request URL
+		query string
+		// wantCode is the transport status
+		wantCode int
+		// wantBody is the exact response body
+		wantBody string
+		// wantContentType is a substring of the Content-Type header
+		wantContentType string
+	}{
+		{
+			name:            "plain JSON",
+			query:           "",
+			wantCode:        http.StatusOK,
+			wantBody:        `{"response":{"statusCode":430,"statusText":"rate limit exceeded"}}`,
+			wantContentType: "application/json",
+		},
+		{
+			name:            "JSONP callback",
+			query:           "?c=myCallback",
+			wantCode:        http.StatusOK,
+			wantBody:        `myCallback({"response":{"statusCode":430,"statusText":"rate limit exceeded"}});`,
+			wantContentType: "application/javascript",
+		},
+		{
+			// The client correlates a JSONP reply solely by response.requestId,
+			// so the request's "r" param must be echoed back or the request hangs.
+			name:            "JSONP callback echoes requestId",
+			query:           "?c=myCallback&r=42",
+			wantCode:        http.StatusOK,
+			wantBody:        `myCallback({"response":{"statusCode":430,"statusText":"rate limit exceeded","requestId":"42"}});`,
+			wantContentType: "application/javascript",
+		},
+		{
+			// Parens would let the callback name inject script; SendResponse
+			// rejects the malformed callback rather than reflecting it.
+			name:     "invalid JSONP callback is rejected",
+			query:    "?c=alert(1)",
+			wantCode: http.StatusBadRequest,
+			// sendJSONError encodes with json.Encoder, which appends a newline.
+			wantBody:        "{\"response\":{\"statusCode\":400,\"statusText\":\"invalid callback parameter\"}}\n",
+			wantContentType: "application/json",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			rec := httptest.NewRecorder()
+			newTestRateLimitMiddleware().sendRateLimited(rec, httptest.NewRequest(http.MethodGet, "/im/sendIM"+tt.query, nil), 5*time.Second)
+
+			body, err := io.ReadAll(rec.Body)
+			assert.NoError(t, err)
+
+			assert.Equal(t, tt.wantCode, rec.Code)
+			assert.Contains(t, rec.Header().Get("Content-Type"), tt.wantContentType)
+			assert.Equal(t, tt.wantBody, string(body))
+		})
+	}
+}
+
+// A client asking for XML or AMF (via f= or the Accept header) still gets a
+// parseable rejection envelope rather than JSON, since the rejection rides the
+// same SendResponse path as a normal handler.
+func TestRateLimitMiddleware_sendRateLimited_nonJSONFormats(t *testing.T) {
+	t.Run("f=xml", func(t *testing.T) {
+		rec := httptest.NewRecorder()
+		newTestRateLimitMiddleware().sendRateLimited(rec, httptest.NewRequest(http.MethodGet, "/im/sendIM?f=xml", nil), 5*time.Second)
+
+		assert.Equal(t, http.StatusOK, rec.Code)
+		assert.Equal(t, "5", rec.Header().Get("Retry-After"))
+		assert.Contains(t, rec.Header().Get("Content-Type"), "xml")
+		body := rec.Body.String()
+		assert.Contains(t, body, "<statusCode>430</statusCode>")
+		assert.Contains(t, body, "rate limit exceeded")
+	})
+
+	t.Run("f=amf", func(t *testing.T) {
+		rec := httptest.NewRecorder()
+		newTestRateLimitMiddleware().sendRateLimited(rec, httptest.NewRequest(http.MethodGet, "/im/sendIM?f=amf", nil), 5*time.Second)
+
+		assert.Equal(t, http.StatusOK, rec.Code)
+		assert.Equal(t, "5", rec.Header().Get("Retry-After"))
+		assert.Contains(t, rec.Header().Get("Content-Type"), "amf")
+		assert.NotEmpty(t, rec.Body.Bytes())
+	})
+
+	t.Run("Accept amf", func(t *testing.T) {
+		rec := httptest.NewRecorder()
+		req := httptest.NewRequest(http.MethodGet, "/im/sendIM", nil)
+		req.Header.Set("Accept", "application/x-amf")
+		newTestRateLimitMiddleware().sendRateLimited(rec, req, 5*time.Second)
+
+		assert.Equal(t, http.StatusOK, rec.Code)
+		assert.Contains(t, rec.Header().Get("Content-Type"), "amf")
+		assert.NotEmpty(t, rec.Body.Bytes())
+	})
+}
+
+func TestRateLimitStatusName(t *testing.T) {
+	tests := []struct {
+		name   string
+		status wire.RateLimitStatus
+		want   string
+	}{
+		{name: "clear", status: wire.RateLimitStatusClear, want: "clear"},
+		{name: "alert maps to the client's warn", status: wire.RateLimitStatusAlert, want: "warn"},
+		{name: "limited", status: wire.RateLimitStatusLimited, want: "limit"},
+		{name: "disconnect", status: wire.RateLimitStatusDisconnect, want: "disconnect"},
+		{name: "unknown status has no client equivalent", status: wire.RateLimitStatus(0), want: ""},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.want, rateLimitStatusName(tt.status))
+		})
+	}
+}

+ 91 - 114
server/webapi/server.go

@@ -13,12 +13,14 @@ import (
 	"github.com/mk6i/open-oscar-server/server/webapi/handlers"
 	"github.com/mk6i/open-oscar-server/server/webapi/middleware"
 	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
 )
 
 func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyValidator middleware.APIKeyValidator, sessionManager *state.WebAPISessionManager) *Server {
 	servers := make([]*http.Server, 0, len(listeners))
 
 	authMiddleware := middleware.NewAuthMiddleware(apiKeyValidator, logger)
+	rateLimiter := handlers.NewRateLimitMiddleware(handler.SNACRateLimits, logger)
 
 	authHandler := &handlers.AuthHandler{
 		AuthService: handler.AuthService,
@@ -89,20 +91,39 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 	for _, l := range listeners {
 		mux := http.NewServeMux()
 
+		// oscarRoute charges the request against the rate class for (foodGroup,
+		// subGroup) before the handler runs; sessionRoute and stubRoute reach no
+		// food group and so are not rate limited here.
+		oscarRoute := func(foodGroup uint16, subGroup uint16, h handlers.SessionHandlerFunc) http.Handler {
+			return authMiddleware.AuthenticateFlexible(
+				authMiddleware.CORSMiddleware(
+					authMiddleware.RequireSession(sessionManager,
+						rateLimiter.OSCAR(foodGroup, subGroup)(h))))
+		}
+		sessionRoute := func(h handlers.SessionHandlerFunc) http.Handler {
+			return authMiddleware.AuthenticateFlexible(
+				authMiddleware.CORSMiddleware(
+					authMiddleware.RequireSession(sessionManager, h)))
+		}
+		stubRoute := func(h http.HandlerFunc) http.Handler {
+			return authMiddleware.AuthenticateFlexible(
+				authMiddleware.CORSMiddleware(h))
+		}
+
 		// Exact root only. Pattern "GET /" matches every GET path in Go 1.22+ (prefix /), which
 		// would steal /getAggregated and other lifestream URLs before stubs/404.
-		mux.HandleFunc("GET /{$}", handler.GetHelloWorldHandler)
+		mux.Handle("GET /{$}", http.HandlerFunc(handler.GetHelloWorldHandler))
 
 		// Authentication endpoint (public - no API key required for user login)
-		// Using pattern with explicit method for Go 1.22+ routing
-		mux.HandleFunc("POST /auth/clientLogin", func(w http.ResponseWriter, r *http.Request) {
+		// Using pattern with explicit method for Go 1.22+ routing.
+		mux.Handle("POST /auth/clientLogin", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 			// Set CORS headers for public endpoint
 			w.Header().Set("Access-Control-Allow-Origin", "*")
 			w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
 			w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
 
 			authHandler.ClientLogin(w, r)
-		})
+		}))
 
 		// Handle OPTIONS for CORS preflight
 		mux.HandleFunc("OPTIONS /auth/clientLogin", func(w http.ResponseWriter, r *http.Request) {
@@ -112,12 +133,12 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 			w.WriteHeader(http.StatusNoContent)
 		})
 
-		mux.HandleFunc("GET /auth/getToken", func(w http.ResponseWriter, r *http.Request) {
+		mux.Handle("GET /auth/getToken", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 			w.Header().Set("Access-Control-Allow-Origin", "*")
 			w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
 			w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
 			authHandler.GetToken(w, r)
-		})
+		}))
 
 		mux.HandleFunc("OPTIONS /auth/getToken", func(w http.ResponseWriter, r *http.Request) {
 			w.Header().Set("Access-Control-Allow-Origin", "*")
@@ -128,140 +149,99 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 
 		// Web AIM navigates the browser here on File > Logout; clear SSO state
 		// and redirect to the login screen.
-		mux.HandleFunc("GET /auth/logout", authHandler.Logout)
+		mux.Handle("GET /auth/logout", http.HandlerFunc(authHandler.Logout))
 
-		mux.HandleFunc("GET /_cqr/login/login.psp", authHandler.LoginPSP)
-		mux.HandleFunc("POST /_cqr/login/login.psp", authHandler.LoginPSP)
+		mux.Handle("GET /_cqr/login/login.psp", http.HandlerFunc(authHandler.LoginPSP))
+		mux.Handle("POST /_cqr/login/login.psp", http.HandlerFunc(authHandler.LoginPSP))
 
 		// Authenticated Web AIM API endpoints
-		// SessionInstance management - supports multiple auth methods (k, a, ts+sig_sha256)
-		mux.Handle("GET /aim/startSession", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(sessionHandler.StartSession))))
+		// SessionInstance management - supports multiple auth methods (k, a, ts+sig_sha256).
+		mux.Handle("GET /aim/startSession",
+			authMiddleware.AuthenticateFlexible(
+				authMiddleware.CORSMiddleware(
+					http.HandlerFunc(sessionHandler.StartSession))))
 
 		// End session - uses aimsid for auth, no k required
-		mux.Handle("GET /aim/endSession", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, sessionHandler.EndSession))))
+		mux.Handle("GET /aim/endSession", sessionRoute(sessionHandler.EndSession))
 
-		// Event fetching - uses aimsid for auth, no k required
+		// Event fetching - uses aimsid for auth, no k required. This is the
+		// long-poll loop the client runs continuously; RecoverOnPoll uses it to
+		// surface a rate limit "clear" to an idle session that already recovered.
 		mux.Handle("GET /aim/fetchEvents", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, eventsHandler.FetchEvents))))
+				authMiddleware.RequireSession(sessionManager,
+					rateLimiter.RecoverOnPoll(eventsHandler.FetchEvents)))))
 
-		// Add temp buddy - uses aimsid for auth
-		mux.Handle("GET /aim/addTempBuddy", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, buddyListHandler.AddTempBuddy))))
-
-		mux.Handle("GET /aim/removeTempBuddy", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, buddyListHandler.RemoveTempBuddy))))
+		// Temp buddies are session-local rather than feedbag-backed, but they
+		// are the Web API's equivalent of the BUDDY temp buddy SNACs and are
+		// charged as such.
+		mux.Handle("GET /aim/addTempBuddy", oscarRoute(wire.Buddy, wire.BuddyAddTempBuddies, buddyListHandler.AddTempBuddy))
+		mux.Handle("GET /aim/removeTempBuddy", oscarRoute(wire.Buddy, wire.BuddyDelTempBuddies, buddyListHandler.RemoveTempBuddy))
 
 		aimStub := &handlers.AimStubHandler{Logger: logger}
-		aimRoute := func(h http.HandlerFunc) http.Handler {
-			return authMiddleware.AuthenticateFlexible(
-				authMiddleware.CORSMiddleware(http.HandlerFunc(h)))
-		}
-		mux.Handle("GET /aim/setForwardDomain", aimRoute(aimStub.SetForwardDomain))
-		mux.Handle("GET /aim/getData", aimRoute(aimStub.GetData))
+		mux.Handle("GET /aim/setForwardDomain", stubRoute(aimStub.SetForwardDomain))
+		mux.Handle("GET /aim/getData", stubRoute(aimStub.GetData))
 
 		conversationStub := &handlers.ConversationStubHandler{
 			SessionManager: sessionManager,
 			Logger:         logger,
 		}
-		mux.Handle("GET /conversation/update", aimRoute(conversationStub.Update))
-		mux.Handle("GET /conversation/close", aimRoute(conversationStub.Close))
-		mux.Handle("GET /imlog/markRead", aimRoute(conversationStub.MarkRead))
-		mux.Handle("GET /imlog/fetchStoredIMs", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, conversationStub.FetchStoredIMs))))
+		mux.Handle("GET /conversation/update", stubRoute(conversationStub.Update))
+		mux.Handle("GET /conversation/close", stubRoute(conversationStub.Close))
+		mux.Handle("GET /imlog/markRead", stubRoute(conversationStub.MarkRead))
+		mux.Handle("GET /imlog/fetchStoredIMs", sessionRoute(conversationStub.FetchStoredIMs))
 
 		// Presence and buddy list
 		// GetPresence supports aimsid-based auth, so we use flexible auth
-		mux.Handle("GET /presence/get", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, presenceHandler.GetPresence))))
+		mux.Handle("GET /presence/get", oscarRoute(wire.Feedbag, wire.FeedbagQuery, presenceHandler.GetPresence))
 
-		buddyListRoute := func(h func(http.ResponseWriter, *http.Request, *state.WebAPISession)) http.Handler {
-			return authMiddleware.AuthenticateFlexible(
-				authMiddleware.CORSMiddleware(
-					authMiddleware.RequireSession(sessionManager, h)))
-		}
-		mux.Handle("GET /buddylist/addBuddy", buddyListRoute(buddyListHandler.AddBuddy))
-		mux.Handle("GET /buddylist/addGroup", buddyListRoute(buddyListHandler.AddGroup))
-		mux.Handle("GET /buddylist/removeBuddy", buddyListRoute(buddyListHandler.RemoveBuddy))
-		mux.Handle("GET /buddylist/removeGroup", buddyListRoute(buddyListHandler.RemoveGroup))
-		mux.Handle("GET /buddylist/renameGroup", buddyListRoute(buddyListHandler.RenameGroup))
-		mux.Handle("GET /buddylist/moveBuddy", buddyListRoute(buddyListHandler.MoveBuddy))
-		mux.Handle("GET /buddylist/setBuddyAttribute", buddyListRoute(buddyListHandler.SetBuddyAttribute))
-		mux.Handle("GET /buddylist/setGroupAttribute", buddyListRoute(buddyListHandler.SetGroupAttribute))
+		mux.Handle("GET /buddylist/addBuddy", oscarRoute(wire.Feedbag, wire.FeedbagInsertItem, buddyListHandler.AddBuddy))
+		mux.Handle("GET /buddylist/addGroup", oscarRoute(wire.Feedbag, wire.FeedbagInsertItem, buddyListHandler.AddGroup))
+		mux.Handle("GET /buddylist/removeBuddy", oscarRoute(wire.Feedbag, wire.FeedbagDeleteItem, buddyListHandler.RemoveBuddy))
+		mux.Handle("GET /buddylist/removeGroup", oscarRoute(wire.Feedbag, wire.FeedbagDeleteItem, buddyListHandler.RemoveGroup))
+		mux.Handle("GET /buddylist/renameGroup", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.RenameGroup))
+		mux.Handle("GET /buddylist/moveBuddy", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.MoveBuddy))
+		mux.Handle("GET /buddylist/setBuddyAttribute", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.SetBuddyAttribute))
+		mux.Handle("GET /buddylist/setGroupAttribute", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.SetGroupAttribute))
 
 		// sendIM supports aimsid-based auth, so we use flexible auth.
 		// The Web AIM client POSTs the message body (non-IE browsers); IE uses GET.
-		sendIMHandler := authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, messagingHandler.SendIM)))
+		sendIMHandler := oscarRoute(wire.ICBM, wire.ICBMChannelMsgToHost, messagingHandler.SendIM)
 		mux.Handle("GET /im/sendIM", sendIMHandler)
 		mux.Handle("POST /im/sendIM", sendIMHandler)
 
-		mux.Handle("GET /im/setTyping", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, messagingHandler.SetTyping))))
+		mux.Handle("GET /im/setTyping", oscarRoute(wire.ICBM, wire.ICBMClientEvent, messagingHandler.SetTyping))
 
 		// SetState only requires aimsid, no k parameter needed
-		mux.Handle("GET /presence/setState", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, presenceHandler.SetState))))
+		mux.Handle("GET /presence/setState", oscarRoute(wire.OService, wire.OServiceSetUserInfoFields, presenceHandler.SetState))
 
 		// These presence endpoints support aimsid-based auth where k is not required
-		mux.Handle("GET /presence/setStatus", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, presenceHandler.SetStatus))))
+		mux.Handle("GET /presence/setStatus", oscarRoute(wire.OService, wire.OServiceSetUserInfoFields, presenceHandler.SetStatus))
+		mux.Handle("GET /presence/setProfile", oscarRoute(wire.Locate, wire.LocateSetInfo, presenceHandler.SetProfile))
+		mux.Handle("GET /presence/getProfile", oscarRoute(wire.Locate, wire.LocateUserInfoQuery, presenceHandler.GetProfile))
 
-		mux.Handle("GET /presence/setProfile", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, presenceHandler.SetProfile))))
-
-		mux.Handle("GET /presence/getProfile", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, presenceHandler.GetProfile))))
-
-		mux.HandleFunc("GET /presence/icon", presenceHandler.Icon)
+		// Unauthenticated, like /expressions/get below: buddy icons load as plain
+		// <img> sources that carry no aimsid.
+		mux.Handle("GET /presence/icon", http.HandlerFunc(presenceHandler.Icon))
 
 		// Member directory search and self directory-info retrieval. Both use
 		// aimsid-based auth, so we use flexible auth.
-		mux.Handle("GET /memberDir/search", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, memberDirHandler.Search))))
-		mux.Handle("GET /memberDir/get", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, memberDirHandler.Get))))
-		mux.Handle("GET /memberDir/update", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, memberDirHandler.Update))))
+		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))
 
 		// These endpoints support aimsid-based auth, so we use a flexible auth approach
-		mux.Handle("GET /preference/set", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, preferenceHandler.SetPreferences))))
-
-		mux.Handle("GET /preference/get", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, preferenceHandler.GetPreferences))))
-
-		mux.Handle("GET /preference/setPermitDeny", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, preferenceHandler.SetPermitDeny))))
-
-		mux.Handle("GET /preference/getPermitDeny", authMiddleware.AuthenticateFlexible(
-			authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, preferenceHandler.GetPermitDeny))))
-
-		// OSCAR Bridge endpoint
-		mux.Handle("GET /aim/startOSCARSession", authMiddleware.Authenticate(
-			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(oscarBridgeHandler.StartOSCARSession))))
+		mux.Handle("GET /preference/set", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, preferenceHandler.SetPreferences))
+		mux.Handle("GET /preference/get", oscarRoute(wire.Feedbag, wire.FeedbagQuery, preferenceHandler.GetPreferences))
+		mux.Handle("GET /preference/setPermitDeny", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, preferenceHandler.SetPermitDeny))
+		mux.Handle("GET /preference/getPermitDeny", oscarRoute(wire.Feedbag, wire.FeedbagQuery, preferenceHandler.GetPermitDeny))
+
+		// OSCAR Bridge endpoint. Hands off to a BOS session rather than reaching
+		// a food group, so there is no OSCAR budget to charge.
+		mux.Handle("GET /aim/startOSCARSession",
+			authMiddleware.Authenticate(
+				authMiddleware.CORSMiddleware(
+					http.HandlerFunc(oscarBridgeHandler.StartOSCARSession))))
 
 		// Expressions endpoint (for buddy icons, etc.).
 		//
@@ -271,26 +251,23 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		// instead would leak it into the DOM and defeat caching, since these URLs
 		// outlive the session that produced them. Buddy icons are public assets.
 		expressionsHandler := handlers.NewExpressionsHandler(handler.IconSource, logger)
-		mux.Handle("GET /expressions/get", authMiddleware.CORSMiddleware(
-			http.HandlerFunc(expressionsHandler.Get)))
+		mux.Handle("GET /expressions/get",
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(expressionsHandler.Get)))
 
 		// Web AIM calls lifestream/* on the API host (e.g. /lifestream/getUserDetails).
 		lifestreamStub := &handlers.UserInfoStubHandler{Logger: logger}
-		lifestreamRoute := func(h http.HandlerFunc) http.Handler {
-			return authMiddleware.AuthenticateFlexible(
-				authMiddleware.CORSMiddleware(http.HandlerFunc(h)))
-		}
 		// getUserDetails returns a minimal AIM identity. Every other lifestream/*
 		// method is an unimplemented social-feed feature; the subtree catch-all
 		// acknowledges them with an empty 200 so the client doesn't error.
-		mux.Handle("GET /lifestream/getUserDetails", lifestreamRoute(lifestreamStub.GetUserDetails))
-		mux.Handle("GET /lifestream/", lifestreamRoute(lifestreamStub.EmptyOK))
+		mux.Handle("GET /lifestream/getUserDetails", stubRoute(lifestreamStub.GetUserDetails))
+		mux.Handle("GET /lifestream/", stubRoute(lifestreamStub.EmptyOK))
 
 		// Unmatched paths (pattern "/" matches anything not covered by routes above).
-		mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 			logger.Debug("webapi 404", "method", r.Method, "path", r.URL.Path)
 			handlers.SendError(w, http.StatusNotFound, "not found")
-		})
+		}))
 
 		servers = append(servers, &http.Server{
 			Addr:    l,

+ 18 - 0
server/webapi/types/events.go

@@ -91,6 +91,24 @@ type TypingEvent struct {
 	TypingStatus string `json:"typingStatus"`
 }
 
+// RateLimitEvent tells the client that its rate limit status changed.
+//
+// The client reads classes[0] only: it takes the status string and feeds it
+// straight into a switch on "clear" | "warn" | "limit" | "disconnect" to render
+// the in-conversation alert (e.g. "You have been rate limited. Wait for a few
+// moments until you can chat again."). The "clear" alert is only shown if the
+// client's last recorded status was "limit", so this event must be pushed on
+// status transitions rather than on every rate-limited request.
+type RateLimitEvent struct {
+	Classes []RateLimitClass `json:"classes"`
+}
+
+// RateLimitClass is the per-rate-class state carried by a RateLimitEvent.
+type RateLimitClass struct {
+	ID     int    `json:"id"`
+	Status string `json:"status"` // "clear", "warn", "limit", or "disconnect"
+}
+
 // EventQueue manages a queue of events for a WebAPI session.
 type EventQueue struct {
 	events    []Event

+ 55 - 0
state/session.go

@@ -424,6 +424,50 @@ func (s *Session) EvaluateRateLimit(now time.Time, rateClassID wire.RateLimitCla
 	return status
 }
 
+// RecoverRateLimits recomputes, for each rate class the session is currently
+// limited on, whether enough time has elapsed since the last charged request for
+// the moving average to climb back above the clear threshold, and returns the
+// classes whose status changed (in practice, limited -> clear).
+//
+// Unlike EvaluateRateLimit it does not treat the call as a new request: LastTime
+// is preserved, so successive calls measure the full elapsed time since the last
+// real request rather than the (small) gap between calls. This mirrors how OSCAR's
+// ObserveRateChanges detects recovery on its ticker, and is what lets a repeated
+// poll surface the clear. Classes that are not currently limited are left
+// untouched, so a poll never perturbs a class the user is actively spending.
+func (s *Session) RecoverRateLimits(now time.Time) []RateClassState {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+
+	var changed []RateClassState
+	for i := range s.rateLimitStates {
+		rc := &s.rateLimitStates[i]
+		if !rc.LimitedNow {
+			continue
+		}
+
+		status, newLevel := wire.CheckRateLimit(rc.LastTime, now, rc.RateClass, rc.CurrentLevel, rc.LimitedNow)
+		// Recovery only. A poll must never escalate a limited class: a short
+		// elapsed gap can make CheckRateLimit report disconnect, but an idle
+		// session recovering is not abuse, and disconnect is enforced by
+		// EvaluateRateLimit on real requests instead. While limitedNow the only
+		// non-escalating outcome CheckRateLimit yields is clear, so that is the
+		// sole transition acted on; a still-limited class is left untouched (its
+		// CurrentLevel keeps recovering from the same base, as in
+		// ObserveRateChanges).
+		if status != wire.RateLimitStatusClear {
+			continue
+		}
+
+		rc.CurrentStatus = status
+		rc.CurrentLevel = newLevel
+		rc.LimitedNow = false
+		changed = append(changed, *rc)
+	}
+
+	return changed
+}
+
 // ObserveRateChanges updates rate limit states and returns changes.
 func (s *Session) ObserveRateChanges(now time.Time) (classDelta []RateClassState, stateDelta []RateClassState) {
 	s.mutex.Lock()
@@ -1119,6 +1163,17 @@ func (s *SessionInstance) Closed() <-chan struct{} {
 	return s.stopCh
 }
 
+// IsClosed reports whether the instance has been closed, without blocking. It is
+// the non-blocking counterpart of Closed.
+func (s *SessionInstance) IsClosed() bool {
+	select {
+	case <-s.stopCh:
+		return true
+	default:
+		return false
+	}
+}
+
 // CloseInstance shuts down the instance's ability to relay messages and removes it from the session.
 func (s *SessionInstance) CloseInstance() {
 	s.mutex.Lock()

+ 39 - 0
state/session_test.go

@@ -791,6 +791,45 @@ func TestSession_EvaluateRateLimit_ObserveRateChanges(t *testing.T) {
 			assert.Equal(t, wire.RateLimitStatusClear, have)
 		}
 	})
+
+	t.Run("RecoverRateLimits clears an idle limited class without counting as a request", func(t *testing.T) {
+		now := time.Now()
+
+		sess := NewSession()
+		sess.AddInstance()
+		sess.SetRateClasses(now, rateClasses)
+
+		rateClass := rateClasses.Get(3)
+
+		// Drive the class into the limited state.
+		var status wire.RateLimitStatus
+		for status != wire.RateLimitStatusLimited {
+			now = now.Add(1 * time.Second)
+			status = sess.EvaluateRateLimit(now, rateClass.ID)
+		}
+
+		// Shortly after, the class is still limited: RecoverRateLimits reports no
+		// transition and, crucially, does not advance LastTime (so it is not
+		// charged as a request).
+		now = now.Add(1 * time.Second)
+		assert.Empty(t, sess.RecoverRateLimits(now))
+		assert.Equal(t, wire.RateLimitStatusLimited, sess.RateLimitStates()[rateClass.ID-1].CurrentStatus)
+
+		// After a long idle gap the moving average clears; the transition is
+		// reported exactly once.
+		now = now.Add(1 * time.Hour)
+		changed := sess.RecoverRateLimits(now)
+		if assert.Len(t, changed, 1) {
+			assert.Equal(t, rateClass.ID, changed[0].ID)
+			assert.Equal(t, wire.RateLimitStatusClear, changed[0].CurrentStatus)
+			assert.False(t, changed[0].LimitedNow)
+		}
+
+		// Once cleared, further polls report nothing.
+		now = now.Add(1 * time.Second)
+		assert.Empty(t, sess.RecoverRateLimits(now))
+		assert.Equal(t, wire.RateLimitStatusClear, sess.RateLimitStates()[rateClass.ID-1].CurrentStatus)
+	})
 }
 
 func TestSession_SetAndGetFoodGroupVersions(t *testing.T) {

+ 50 - 4
state/webapi_session.go

@@ -86,8 +86,12 @@ type WebAPISession struct {
 	aliasMu      sync.Mutex
 	imLog        map[string][]WebAPIStoredIM
 	imLogMu      sync.Mutex
-	logger       *slog.Logger // Logger for debugging
-	listeners    sync.WaitGroup
+	// rateAlertStatus is the rate limit status this client was last told about,
+	// zero until it has been told anything. See ObserveRateAlert.
+	rateAlertStatus wire.RateLimitStatus
+	rateAlertMu     sync.Mutex
+	logger          *slog.Logger // Logger for debugging
+	listeners       sync.WaitGroup
 
 	ctx    context.Context
 	cancel context.CancelFunc
@@ -101,6 +105,35 @@ func (s *WebAPISession) IsExpired() bool {
 	return time.Now().After(s.ExpiresAt)
 }
 
+// ObserveRateAlert records status as the rate limit status this web client was
+// last told about, and reports whether it differs from the one before it.
+//
+// The client's rate limit alert is sticky — it dismisses only on a "clear" that
+// follows a "limit" — so the server has to push exactly the transitions, and a
+// transition has to be measured per web client rather than off the OSCAR rate
+// state. That state belongs to the account, not to this session: a second
+// browser tab holds its own aimsid and its own WebAPISession over the same
+// state.Session, and an OSCAR client's ObserveRateChanges ticker runs against it
+// too. Any of them can consume a limited -> clear transition before this client
+// hears about it, which would leave this client's alert stuck up forever.
+func (s *WebAPISession) ObserveRateAlert(status wire.RateLimitStatus) bool {
+	s.rateAlertMu.Lock()
+	defer s.rateAlertMu.Unlock()
+
+	prev := s.rateAlertStatus
+	if prev == 0 {
+		// A client that has been told nothing renders no alert, which is what
+		// clear means.
+		prev = wire.RateLimitStatusClear
+	}
+	if prev == status {
+		return false
+	}
+
+	s.rateAlertStatus = status
+	return true
+}
+
 // Aliases returns this session owner's private buddy aliases, keyed by normalized
 // screen name. Aliases live in the owner's feedbag, so the map is loaded once and
 // cached until a feedbag change invalidates it: a signon that brings a large buddy
@@ -595,6 +628,15 @@ func (m *WebAPISessionManager) GetSession(ctx context.Context, aimsid string) (*
 		return nil, ErrWebAPISessionExpired
 	}
 
+	// A rate-limit disconnect (EvaluateRateLimit -> Session.CloseSession) closes
+	// every instance for the account while this web session is still unexpired.
+	// The aimsid must stop resolving at that point, otherwise a client told to
+	// disconnect could keep issuing charged requests against a dead session (the
+	// reaper only removes it on time expiry, up to a TTL later).
+	if session.OSCARSession != nil && session.OSCARSession.IsClosed() {
+		return nil, ErrWebAPISessionExpired
+	}
+
 	return session, nil
 }
 
@@ -666,13 +708,17 @@ func (m *WebAPISessionManager) Run(ctx context.Context) {
 	}
 }
 
-// reapExpired removes every expired session and tears it down.
+// reapExpired removes every dead session and tears it down. A session is dead
+// once it has passed its expiry, or once its underlying OSCAR session has been
+// closed out from under it (e.g. by a rate-limit disconnect) — the latter is
+// already rejected by GetSession, and reaping it here frees the entry promptly
+// rather than leaving it until time expiry.
 func (m *WebAPISessionManager) reapExpired() {
 	m.mu.Lock()
 	now := time.Now()
 	var expired []*WebAPISession
 	for aimsid, session := range m.sessions {
-		if now.After(session.ExpiresAt) {
+		if now.After(session.ExpiresAt) || (session.OSCARSession != nil && session.OSCARSession.IsClosed()) {
 			delete(m.sessions, aimsid)
 			expired = append(expired, session)
 		}

+ 93 - 4
state/webapi_session_test.go

@@ -291,6 +291,93 @@ func TestWebAPISessionManager_CreateAfterShutdown(t *testing.T) {
 	assert.ErrorIs(t, err, ErrWebAPISessionManagerClosed)
 }
 
+// The rate limit alert is per web client, so the transition that drives it has
+// to be tracked per web client too: the OSCAR rate state these sessions sit on
+// is shared by the whole account, and whoever reads it first would otherwise
+// consume the transition the other one is waiting for.
+func TestWebAPISession_ObserveRateAlert(t *testing.T) {
+	t.Run("a client that has been told nothing is already clear", func(t *testing.T) {
+		sess := &WebAPISession{}
+		assert.False(t, sess.ObserveRateAlert(wire.RateLimitStatusClear))
+	})
+
+	t.Run("only changes are reported", func(t *testing.T) {
+		sess := &WebAPISession{}
+
+		assert.True(t, sess.ObserveRateAlert(wire.RateLimitStatusLimited))
+		assert.False(t, sess.ObserveRateAlert(wire.RateLimitStatusLimited))
+		assert.True(t, sess.ObserveRateAlert(wire.RateLimitStatusClear))
+		assert.False(t, sess.ObserveRateAlert(wire.RateLimitStatusClear))
+	})
+
+	t.Run("two sessions on one account track their alerts independently", func(t *testing.T) {
+		tabA, tabB := &WebAPISession{}, &WebAPISession{}
+
+		// Only tab A is told it is limited.
+		assert.True(t, tabA.ObserveRateAlert(wire.RateLimitStatusLimited))
+
+		// The account recovers. Tab B has no alert up, so the clear is not news
+		// to it — and consuming it must not cost tab A its own clear.
+		assert.False(t, tabB.ObserveRateAlert(wire.RateLimitStatusClear))
+		assert.True(t, tabA.ObserveRateAlert(wire.RateLimitStatusClear))
+	})
+}
+
+// A rate-limit disconnect closes the account's OSCAR session; the web session's
+// aimsid must then stop resolving. Before this fix GetSession only checked
+// time-based expiry, so a client told to disconnect could keep issuing charged
+// requests against a dead session (and, downstream, spam clear events on every
+// one of them). Once the aimsid is turned away at RequireSession, neither is
+// possible.
+func TestWebAPISessionManager_GetSession_rejectsAfterRateLimitDisconnect(t *testing.T) {
+	mgr := NewWebAPISessionManager()
+
+	// A rate class that escalates to disconnect after a short back-to-back burst.
+	var classes [5]wire.RateClass
+	for i := range classes {
+		classes[i] = wire.RateClass{
+			ID:              wire.RateLimitClassID(i + 1),
+			WindowSize:      2,
+			ClearLevel:      100,
+			AlertLevel:      80,
+			LimitLevel:      70,
+			DisconnectLevel: 2,
+			MaxLevel:        200,
+		}
+	}
+	inst := NewSession().AddInstance()
+	inst.Session().SetRateClasses(time.Now(), wire.NewRateLimitClasses(classes))
+
+	sess, err := mgr.CreateSession(DisplayScreenName("advbot"), "dev", []string{"presence"}, inst, "", slog.Default())
+	require.NoError(t, err)
+
+	// Healthy session resolves.
+	got, err := mgr.GetSession(context.Background(), sess.AimSID)
+	require.NoError(t, err)
+	assert.Same(t, sess, got)
+
+	// Burst until EvaluateRateLimit escalates to disconnect, which closes the
+	// account's OSCAR session.
+	var status wire.RateLimitStatus
+	now := time.Now()
+	for range 10 {
+		if status = inst.Session().EvaluateRateLimit(now, 1); status == wire.RateLimitStatusDisconnect {
+			break
+		}
+	}
+	require.Equal(t, wire.RateLimitStatusDisconnect, status)
+	require.True(t, inst.IsClosed(), "disconnect must close the OSCAR instance")
+
+	// The aimsid no longer resolves, even though ExpiresAt is far in the future.
+	_, err = mgr.GetSession(context.Background(), sess.AimSID)
+	assert.ErrorIs(t, err, ErrWebAPISessionExpired)
+	assert.False(t, sess.IsExpired(), "the guard must fire on OSCAR close, not on time expiry")
+
+	// The reaper frees the dead entry on its next sweep.
+	mgr.reapExpired()
+	assert.NotContains(t, mgr.sessions, sess.AimSID)
+}
+
 // TestWebAPISessionManager_ShutdownDrainsAndClosesSessions verifies that Shutdown
 // collects every live session and tears it down: it drains the maps and closes
 // each session's event queue and OSCAR instance.
@@ -306,7 +393,7 @@ func TestWebAPISessionManager_ShutdownDrainsAndClosesSessions(t *testing.T) {
 	s2, err := mgr.CreateSession(DisplayScreenName("bob"), "dev", []string{"presence"}, inst2, "", slog.Default())
 	assert.NoError(t, err)
 
-	mgr.Shutdown(context.Background())
+	assert.NoError(t, mgr.Shutdown(context.Background()))
 
 	// Maps drained: the collect loop ran over both sessions.
 	assert.Empty(t, mgr.sessions)
@@ -387,7 +474,8 @@ func TestWebAPISessionManager_ShutdownWithoutReaper(t *testing.T) {
 	done := make(chan struct{})
 	go func() {
 		defer close(done)
-		mgr.Shutdown(context.Background())
+		// This test is about Shutdown returning at all, not what it returns.
+		_ = mgr.Shutdown(context.Background())
 	}()
 
 	select {
@@ -414,7 +502,8 @@ func TestWebAPISessionManager_ShutdownJoinsReaper(t *testing.T) {
 	done := make(chan struct{})
 	go func() {
 		defer close(done)
-		mgr.Shutdown(context.Background())
+		// This test is about Shutdown returning at all, not what it returns.
+		_ = mgr.Shutdown(context.Background())
 	}()
 
 	select {
@@ -435,7 +524,7 @@ func TestWebAPISessionManager_ShutdownJoinsReaper(t *testing.T) {
 // with Shutdown never starts, so it cannot reap an already-drained manager.
 func TestWebAPISessionManager_RunAfterShutdown(t *testing.T) {
 	mgr := NewWebAPISessionManager()
-	mgr.Shutdown(context.Background())
+	assert.NoError(t, mgr.Shutdown(context.Background()))
 
 	done := make(chan struct{})
 	go func() {