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

Merge pull request #147 from ukozi/webapi

Webapi
Mike 9 месяцев назад
Родитель
Сommit
2c18c8cfb7
65 измененных файлов с 12479 добавлено и 29 удалено
  1. 1 0
      .gitignore
  2. 12 0
      Makefile
  3. 262 12
      api.yml
  4. 72 9
      cmd/server/factory.go
  5. 441 0
      cmd/webapi_keygen/main.go
  6. 0 1
      docs/open_api/webapi.yml
  7. 5 4
      foodgroup/buddy.go
  8. 3 1
      foodgroup/icbm.go
  9. 1 0
      go.mod
  10. 2 0
      go.sum
  11. 18 1
      server/http/mgmt_api.go
  12. 29 0
      server/http/types.go
  13. 255 0
      server/http/webapi_admin.go
  14. 196 0
      server/webapi/adapters.go
  15. 27 0
      server/webapi/handler.go
  16. 443 0
      server/webapi/handlers/amf_encoder.go
  17. 489 0
      server/webapi/handlers/amf_encoder_test.go
  18. 197 0
      server/webapi/handlers/auth.go
  19. 293 0
      server/webapi/handlers/buddy_list_manager.go
  20. 353 0
      server/webapi/handlers/buddyfeed.go
  21. 195 0
      server/webapi/handlers/buddylist.go
  22. 319 0
      server/webapi/handlers/chat.go
  23. 395 0
      server/webapi/handlers/common.go
  24. 196 0
      server/webapi/handlers/events.go
  25. 53 0
      server/webapi/handlers/expressions.go
  26. 232 0
      server/webapi/handlers/feed_converter.go
  27. 416 0
      server/webapi/handlers/messaging.go
  28. 351 0
      server/webapi/handlers/oscar_bridge.go
  29. 482 0
      server/webapi/handlers/preference.go
  30. 665 0
      server/webapi/handlers/presence.go
  31. 555 0
      server/webapi/handlers/session.go
  32. 323 0
      server/webapi/handlers/vanity.go
  33. 212 0
      server/webapi/handlers/webapi_event_converter.go
  34. 493 0
      server/webapi/middleware/auth.go
  35. 140 0
      server/webapi/oscar_config.go
  36. 209 1
      server/webapi/server.go
  37. 105 0
      server/webapi/types.go
  38. 255 0
      server/webapi/types/events.go
  39. 4 0
      state/migrations/0016_webapi_tokens.down.sql
  40. 13 0
      state/migrations/0016_webapi_tokens.up.sql
  41. 3 0
      state/migrations/0017_web_preferences.down.sql
  42. 33 0
      state/migrations/0017_web_preferences.up.sql
  43. 5 0
      state/migrations/0018_oscar_bridge_sessions.down.sql
  44. 36 0
      state/migrations/0018_oscar_bridge_sessions.up.sql
  45. 7 0
      state/migrations/0019_api_analytics.down.sql
  46. 61 0
      state/migrations/0019_api_analytics.up.sql
  47. 7 0
      state/migrations/0020_buddy_feeds.down.sql
  48. 58 0
      state/migrations/0020_buddy_feeds.up.sql
  49. 6 0
      state/migrations/0021_vanity_urls.down.sql
  50. 38 0
      state/migrations/0021_vanity_urls.up.sql
  51. 16 0
      state/migrations/0022_web_chat_rooms.down.sql
  52. 82 0
      state/migrations/0022_web_chat_rooms.up.sql
  53. 7 0
      state/migrations/0023_web_api_keys.down.sql
  54. 18 0
      state/migrations/0023_web_api_keys.up.sql
  55. 7 0
      state/session.go
  56. 1 0
      state/session_manager_test.go
  57. 351 0
      state/web_api_store.go
  58. 429 0
      state/webapi_analytics.go
  59. 114 0
      state/webapi_auth.go
  60. 324 0
      state/webapi_buddyfeed.go
  61. 711 0
      state/webapi_chat.go
  62. 383 0
      state/webapi_oscar_bridge.go
  63. 197 0
      state/webapi_preferences.go
  64. 454 0
      state/webapi_session.go
  65. 419 0
      state/webapi_vanity.go

+ 1 - 0
.gitignore

@@ -5,4 +5,5 @@ dist/
 *.DS_Store
 certs/
 .vscode/
+wapi-docs/
 .cursor/

+ 12 - 0
Makefile

@@ -72,3 +72,15 @@ docker-nss: ## Create NSS certificate database for AIM 6.x clients
 .PHONY: clean-certs
 clean-certs: ## Remove all generated certificates & NSS DB
 	rm -rf certs/*
+
+################################################################################
+# Web API Tools
+################################################################################
+
+.PHONY: webapi-keygen
+webapi-keygen: ## Build the Web API key generator tool
+	go build -o webapi_keygen ./cmd/webapi_keygen
+
+.PHONY: webapi-keygen-install
+webapi-keygen-install: ## Install the Web API key generator tool system-wide
+	go install ./cmd/webapi_keygen

+ 262 - 12
api.yml

@@ -135,15 +135,6 @@ paths:
             type: string
           description: User's AIM screen name or ICQ UIN.
           required: true
-      responses:
-        '204':
-          description: Successfully updated user account
-        '304':
-          description: Did not modify user account
-        '400':
-          description: Bad request when modifying user account
-        '404':
-          description: User not found
       requestBody:
         required: true
         content:
@@ -154,14 +145,24 @@ paths:
                 suspended_status:
                   type: string
                   nullable: true
-                  enum: [ deleted, expired, suspended, suspended_age ]
+                  enum: [deleted, expired, suspended, suspended_age]
                   description: The suspended status of the account
                 is_bot:
                   type: boolean
                   nullable: true
                   description: >
-                    Indicates whether the account is for a bot. Bots are exempt from rate limiting... make sure you 
+                    Indicates whether the account is for a bot. Bots are exempt from rate limiting... make sure you
                     trust the bot and bot owner before enabling this flag.
+      responses:
+        '204':
+          description: Successfully updated user account
+        '304':
+          description: Did not modify user account
+        '400':
+          description: Bad request when modifying user account
+        '404':
+          description: User not found
+
   /user/{screenname}/icon:
     get:
       summary: Get AIM buddy icon for a screen name
@@ -548,7 +549,7 @@ paths:
                 properties:
                   id:
                     type: integer
-                    description: The ID keyword category.
+                    description: The keyword category ID.
                   name:
                     type: string
                     description: The name of the keyword category.
@@ -754,6 +755,213 @@ paths:
                   message:
                     type: string
 
+  /admin/webapi/keys:
+    get:
+      summary: List all Web API keys
+      description: Retrieve a list of all Web API keys for the Web AIM API.
+      tags: [Web API Management]
+      responses:
+        '200':
+          description: Successful response containing a list of API keys.
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/WebAPIKey'
+        '500':
+          description: Internal server error.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+
+    post:
+      summary: Create a new Web API key
+      description: Create a new API key for Web AIM API authentication.
+      tags: [Web API Management]
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              required:
+                - app_name
+              properties:
+                app_name:
+                  type: string
+                  description: Name of the application using this API key.
+                  example: "My Web AIM Client"
+                allowed_origins:
+                  type: array
+                  items:
+                    type: string
+                  description: List of allowed CORS origins. Empty list allows all origins (useful for mobile apps).
+                  example: ["https://example.com", "https://app.example.com"]
+                rate_limit:
+                  type: integer
+                  description: Maximum requests per minute allowed for this key.
+                  default: 60
+                  example: 120
+                capabilities:
+                  type: array
+                  items:
+                    type: string
+                  description: List of capabilities/features enabled for this key. Empty list allows all capabilities.
+                  example: ["aim.session", "presence.get", "im.send"]
+      responses:
+        '201':
+          description: API key created successfully.
+          content:
+            application/json:
+              schema:
+                allOf:
+                  - $ref: '#/components/schemas/WebAPIKey'
+                  - type: object
+                    properties:
+                      dev_key:
+                        type: string
+                        description: The actual API key value. This is only shown once at creation time.
+                        example: "a1b2c3d4e5f6789012345678901234567890123456789012345678901234"
+        '400':
+          description: Bad request. Invalid input data.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '409':
+          description: Conflict. An API key with this ID already exists.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '500':
+          description: Internal server error.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+
+  /admin/webapi/keys/{id}:
+    get:
+      summary: Get a specific Web API key
+      description: Retrieve details of a specific Web API key by its developer ID.
+      tags: [Web API Management]
+      parameters:
+        - name: id
+          in: path
+          description: The developer ID of the API key.
+          required: true
+          schema:
+            type: string
+            example: "dev_550e8400-e29b-41d4-a716-446655440000"
+      responses:
+        '200':
+          description: Successful response containing the API key details.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/WebAPIKey'
+        '404':
+          description: API key not found.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '500':
+          description: Internal server error.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+
+    put:
+      summary: Update a Web API key
+      description: Update settings for an existing Web API key.
+      tags: [Web API Management]
+      parameters:
+        - name: id
+          in: path
+          description: The developer ID of the API key.
+          required: true
+          schema:
+            type: string
+            example: "dev_550e8400-e29b-41d4-a716-446655440000"
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                app_name:
+                  type: string
+                  description: New application name.
+                is_active:
+                  type: boolean
+                  description: Enable or disable the API key.
+                rate_limit:
+                  type: integer
+                  description: New rate limit (requests per minute).
+                allowed_origins:
+                  type: array
+                  items:
+                    type: string
+                  description: New list of allowed CORS origins.
+                capabilities:
+                  type: array
+                  items:
+                    type: string
+                  description: New list of enabled capabilities.
+      responses:
+        '200':
+          description: API key updated successfully.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/WebAPIKey'
+        '404':
+          description: API key not found.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '500':
+          description: Internal server error.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+
+    delete:
+      summary: Delete a Web API key
+      description: Permanently delete a Web API key.
+      tags: [Web API Management]
+      parameters:
+        - name: id
+          in: path
+          description: The developer ID of the API key.
+          required: true
+          schema:
+            type: string
+            example: "dev_550e8400-e29b-41d4-a716-446655440000"
+      responses:
+        '204':
+          description: API key deleted successfully.
+        '404':
+          description: API key not found.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '500':
+          description: Internal server error.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+
   /bart:
     get:
       summary: Get BART entries by type
@@ -949,6 +1157,7 @@ components:
           description: Response message describing the result or error.
       required:
         - message
+
     BARTType:
       type: integer
       enum: [0, 1, 2, 3, 4, 5, 6, 12, 13, 15, 96, 129, 131, 136, 137, 1024, 1026, 1027, 1028]
@@ -973,3 +1182,44 @@ components:
         - 1026: encr_cert_chain (Cert chain for encryption certs)
         - 1027: sign_cert_chain (Cert chain for signing certs)
         - 1028: gateway_cert (Cert for enterprise gateway)
+
+    WebAPIKey:
+      type: object
+      properties:
+        dev_id:
+          type: string
+          description: Unique developer/application identifier.
+          example: "dev_550e8400-e29b-41d4-a716-446655440000"
+        app_name:
+          type: string
+          description: Name of the application using this API key.
+          example: "My Web AIM Client"
+        created_at:
+          type: string
+          format: date-time
+          description: Timestamp when the key was created.
+        last_used:
+          type: string
+          format: date-time
+          nullable: true
+          description: Timestamp when the key was last used.
+        is_active:
+          type: boolean
+          description: Whether the API key is currently active.
+          default: true
+        rate_limit:
+          type: integer
+          description: Maximum requests per minute allowed.
+          example: 60
+        allowed_origins:
+          type: array
+          items:
+            type: string
+          description: List of allowed CORS origins. Empty list allows all origins.
+          example: ["https://example.com"]
+        capabilities:
+          type: array
+          items:
+            type: string
+          description: List of enabled features/endpoints. Empty list allows all capabilities.
+          example: ["aim.session", "presence.get"]

+ 72 - 9
cmd/server/factory.go

@@ -17,9 +17,10 @@ import (
 	"github.com/mk6i/retro-aim-server/server/http"
 	"github.com/mk6i/retro-aim-server/server/kerberos"
 	"github.com/mk6i/retro-aim-server/server/oscar"
-	"github.com/mk6i/retro-aim-server/server/oscar/middleware"
+	oscarmiddleware "github.com/mk6i/retro-aim-server/server/oscar/middleware"
 	"github.com/mk6i/retro-aim-server/server/toc"
 	"github.com/mk6i/retro-aim-server/server/webapi"
+	"github.com/mk6i/retro-aim-server/server/webapi/handlers"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
 )
@@ -35,6 +36,7 @@ type Container struct {
 	rateLimitClasses       wire.RateLimitClasses
 	snacRateLimits         wire.SNACRateLimits
 	sqLiteUserStore        *state.SQLiteUserStore
+	webAPISessionManager   *state.WebAPISessionManager
 	Listeners              []config.Listener
 }
 
@@ -70,14 +72,14 @@ func MakeCommonDeps() (Container, error) {
 		return c, fmt.Errorf("unable to create HMAC cookie baker: %s", err.Error())
 	}
 
-	c.logger = middleware.NewLogger(c.cfg)
+	c.logger = oscarmiddleware.NewLogger(c.cfg)
 	c.inMemorySessionManager = state.NewInMemorySessionManager(c.logger)
 	c.chatSessionManager = state.NewInMemoryChatSessionManager(c.logger)
+	c.webAPISessionManager = state.NewWebAPISessionManager()
 	c.rateLimitClasses = wire.DefaultRateLimitClasses()
 	c.snacRateLimits = wire.DefaultSNACRateLimits()
 
-	// ICBM svc is a common dep because OSCAR and TOC need to share convo
-	// history state.
+	// ICBM svc is a common dep because OSCAR and TOC need to share convo history state.
 	c.icbmSvc = foodgroup.NewICBMService(
 		c.sqLiteUserStore,
 		c.inMemorySessionManager,
@@ -316,7 +318,7 @@ func OSCAR(deps Container) *oscar.Server {
 			PermitDenyService: permitDenyService,
 			StatsService:      statsService,
 			UserLookupService: userLookupService,
-			RouteLogger: middleware.RouteLogger{
+			RouteLogger: oscarmiddleware.RouteLogger{
 				Logger: logger,
 			},
 		}.Handle,
@@ -344,9 +346,24 @@ func MgmtAPI(deps Container) *http.Server {
 		Date:    date,
 	}
 	logger := deps.logger.With("svc", "API")
-	return http.NewManagementAPI(bld, deps.cfg.APIListener, deps.sqLiteUserStore, deps.inMemorySessionManager, deps.sqLiteUserStore,
-		deps.sqLiteUserStore, deps.sqLiteUserStore, deps.chatSessionManager, deps.sqLiteUserStore, deps.inMemorySessionManager,
-		deps.sqLiteUserStore, deps.sqLiteUserStore, deps.sqLiteUserStore, deps.sqLiteUserStore, logger)
+	return http.NewManagementAPI(
+		bld,
+		deps.cfg.APIListener,
+		deps.sqLiteUserStore,        // userManager
+		deps.inMemorySessionManager, // sessionRetriever
+		deps.sqLiteUserStore,        // chatRoomRetriever
+		deps.sqLiteUserStore,        // chatRoomCreator
+		deps.sqLiteUserStore,        // chatRoomDeleter
+		deps.chatSessionManager,     // chatSessionRetriever
+		deps.sqLiteUserStore,        // directoryManager
+		deps.inMemorySessionManager, // messageRelayer
+		deps.sqLiteUserStore,        // bartAssetManager
+		deps.sqLiteUserStore,        // feedbagRetriever
+		deps.sqLiteUserStore,        // accountManager
+		deps.sqLiteUserStore,        // profileRetriever
+		deps.sqLiteUserStore,        // webAPIKeyManager
+		logger,
+	)
 }
 
 // TOC creates a TOC server.
@@ -430,6 +447,28 @@ func TOC(deps Container) *toc.Server {
 // WebAPI creates an HTTP server for the webapi protocol.
 func WebAPI(deps Container) *webapi.Server {
 	logger := deps.logger.With("svc", "webapi")
+
+	// Create feedbag adapter for WebAPI
+	feedbagAdapter := &webapi.FeedbagAdapter{
+		Store: deps.sqLiteUserStore,
+	}
+
+	// Create WebAPI buddy list manager (local to WebAPI)
+	buddyListManager := handlers.NewBuddyListManager(
+		feedbagAdapter,
+		deps.inMemorySessionManager,
+		logger,
+	)
+
+	// Create the OSCAR buddy broadcaster for WebAPI to use
+	oscarBuddyBroadcaster := foodgroup.NewBuddyService(
+		deps.inMemorySessionManager,
+		deps.sqLiteUserStore,
+		deps.sqLiteUserStore,
+		deps.inMemorySessionManager,
+		deps.sqLiteUserStore,
+	)
+
 	handler := webapi.Handler{
 		AdminService: foodgroup.NewAdminService(
 			deps.sqLiteUserStore,
@@ -493,6 +532,30 @@ func WebAPI(deps Container) *webapi.Server {
 		ChatService:    foodgroup.NewChatService(deps.chatSessionManager),
 		ChatNavService: foodgroup.NewChatNavService(logger, deps.sqLiteUserStore),
 		SNACRateLimits: deps.snacRateLimits,
+		// New fields for WebAPI handlers
+		SessionRetriever: deps.inMemorySessionManager,
+		FeedbagRetriever: feedbagAdapter,
+		FeedbagManager:   feedbagAdapter,
+		// Phase 2 additions
+		MessageRelayer:        deps.inMemorySessionManager,
+		OfflineMessageManager: deps.sqLiteUserStore,
+		BuddyBroadcaster:      oscarBuddyBroadcaster,
+		ProfileManager:        deps.sqLiteUserStore,
+		RelationshipFetcher:   deps.sqLiteUserStore,
+		// Authentication support
+		UserManager: deps.sqLiteUserStore,
+		TokenStore:  deps.sqLiteUserStore.NewWebAPITokenStore(),
+		// Phase 3 additions
+		PreferenceManager: deps.sqLiteUserStore.NewWebPreferenceManager(),
+		PermitDenyManager: deps.sqLiteUserStore.NewWebPermitDenyManager(),
+		// Phase 4 additions for OSCAR Bridge
+		OSCARBridgeStore: deps.sqLiteUserStore.NewOSCARBridgeStore(),
+		OSCARConfig:      webapi.NewOSCARConfigAdapter(deps.cfg),
+		// Phase 5 additions for buddy list and messaging
+		BuddyListManager: buddyListManager,
+		// Phase 5 additions for chat rooms
+		ChatManager: deps.sqLiteUserStore.NewWebAPIChatManager(logger, deps.webAPISessionManager),
 	}
-	return webapi.NewServer([]string{"0.0.0.0:8081"}, logger, handler)
+	// Pass SQLiteUserStore as the API key validator (it implements middleware.APIKeyValidator)
+	return webapi.NewServer([]string{"0.0.0.0:9000"}, logger, handler, deps.sqLiteUserStore, deps.webAPISessionManager)
 }

+ 441 - 0
cmd/webapi_keygen/main.go

@@ -0,0 +1,441 @@
+// webapi_keygen generates and manages Web API keys for the RAS Web AIM API.
+// Usage: go run ./cmd/webapi_keygen [command] [options]
+package main
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"os"
+	"strings"
+	"text/tabwriter"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/joho/godotenv"
+
+	"github.com/mk6i/retro-aim-server/state"
+)
+
+const (
+	keyLength = 32 // 256 bits of entropy
+)
+
+func main() {
+	// Load environment configuration
+	if err := godotenv.Load("config/settings.env"); err != nil {
+		fmt.Printf("Config file not found, using environment variables\n")
+	}
+
+	if len(os.Args) < 2 {
+		printUsage()
+		os.Exit(1)
+	}
+
+	command := os.Args[1]
+	args := os.Args[2:]
+
+	switch command {
+	case "generate", "gen":
+		handleGenerate(args)
+	case "list", "ls":
+		handleList(args)
+	case "revoke", "delete", "rm":
+		handleRevoke(args)
+	case "activate":
+		handleActivate(args)
+	case "update":
+		handleUpdate(args)
+	case "show":
+		handleShow(args)
+	case "help", "-h", "--help":
+		printUsage()
+	default:
+		fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", command)
+		printUsage()
+		os.Exit(1)
+	}
+}
+
+func printUsage() {
+	fmt.Println("Web API Key Generator for RAS")
+	fmt.Println("\nUsage: webapi_keygen <command> [options]")
+	fmt.Println("\nCommands:")
+	fmt.Println("  generate, gen     Generate a new API key")
+	fmt.Println("  list, ls          List all API keys")
+	fmt.Println("  show              Show details of a specific key")
+	fmt.Println("  revoke, delete    Deactivate an API key")
+	fmt.Println("  activate          Reactivate an API key")
+	fmt.Println("  update            Update API key settings")
+	fmt.Println("  help              Show this help message")
+	fmt.Println("\nGenerate Options:")
+	fmt.Println("  --app-name        Application name (required)")
+	fmt.Println("  --origins         Comma-separated list of allowed origins")
+	fmt.Println("  --rate-limit      Requests per minute (default: 60)")
+	fmt.Println("  --capabilities    Comma-separated list of capabilities")
+	fmt.Println("\nUpdate Options:")
+	fmt.Println("  --dev-id          Developer ID to update (required)")
+	fmt.Println("  --app-name        New application name")
+	fmt.Println("  --origins         New comma-separated list of allowed origins")
+	fmt.Println("  --rate-limit      New requests per minute limit")
+	fmt.Println("  --capabilities    New comma-separated list of capabilities")
+	fmt.Println("\nExamples:")
+	fmt.Println("  webapi_keygen generate --app-name \"My Web Client\" --origins \"https://example.com,https://app.example.com\"")
+	fmt.Println("  webapi_keygen list")
+	fmt.Println("  webapi_keygen show --dev-id dev_abc123")
+	fmt.Println("  webapi_keygen revoke --dev-id dev_abc123")
+	fmt.Println("  webapi_keygen update --dev-id dev_abc123 --rate-limit 120")
+}
+
+func handleGenerate(args []string) {
+	fs := flag.NewFlagSet("generate", flag.ExitOnError)
+	appName := fs.String("app-name", "", "Application name (required)")
+	originsStr := fs.String("origins", "", "Comma-separated list of allowed origins")
+	rateLimit := fs.Int("rate-limit", 60, "Requests per minute")
+	capabilitiesStr := fs.String("capabilities", "", "Comma-separated list of capabilities")
+
+	if err := fs.Parse(args); err != nil {
+		fmt.Fprintf(os.Stderr, "Error parsing arguments: %v\n", err)
+		os.Exit(1)
+	}
+
+	if *appName == "" {
+		fmt.Fprintln(os.Stderr, "Error: --app-name is required")
+		os.Exit(1)
+	}
+
+	// Parse origins and capabilities
+	var origins []string
+	if *originsStr != "" {
+		origins = parseCSV(*originsStr)
+	}
+
+	var capabilities []string
+	if *capabilitiesStr != "" {
+		capabilities = parseCSV(*capabilitiesStr)
+	}
+
+	// Generate secure random key
+	keyBytes := make([]byte, keyLength)
+	if _, err := rand.Read(keyBytes); err != nil {
+		fmt.Fprintf(os.Stderr, "Error generating key: %v\n", err)
+		os.Exit(1)
+	}
+	devKey := hex.EncodeToString(keyBytes)
+
+	// Generate dev_id
+	devID := fmt.Sprintf("dev_%s", uuid.New().String())
+
+	// Create the API key record
+	apiKey := state.WebAPIKey{
+		DevID:          devID,
+		DevKey:         devKey,
+		AppName:        *appName,
+		CreatedAt:      time.Now(),
+		IsActive:       true,
+		RateLimit:      *rateLimit,
+		AllowedOrigins: origins,
+		Capabilities:   capabilities,
+	}
+
+	// Connect to database and insert the key
+	store, err := connectToStore()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
+		os.Exit(1)
+	}
+
+	ctx := context.Background()
+	if err := store.CreateAPIKey(ctx, apiKey); err != nil {
+		fmt.Fprintf(os.Stderr, "Error creating API key: %v\n", err)
+		os.Exit(1)
+	}
+
+	// Output the generated key details
+	fmt.Println("Successfully generated Web API key:")
+	fmt.Println("=====================================")
+	fmt.Printf("Developer ID:  %s\n", devID)
+	fmt.Printf("API Key:       %s\n", devKey)
+	fmt.Printf("App Name:      %s\n", *appName)
+	fmt.Printf("Rate Limit:    %d requests/minute\n", *rateLimit)
+	if len(origins) > 0 {
+		fmt.Printf("Origins:       %s\n", strings.Join(origins, ", "))
+	}
+	if len(capabilities) > 0 {
+		fmt.Printf("Capabilities:  %s\n", strings.Join(capabilities, ", "))
+	}
+	fmt.Println("=====================================")
+	fmt.Println("\nIMPORTANT: Save the API key securely. It cannot be retrieved later.")
+}
+
+func handleList(args []string) {
+	store, err := connectToStore()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
+		os.Exit(1)
+	}
+
+	ctx := context.Background()
+	keys, err := store.ListAPIKeys(ctx)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Error listing API keys: %v\n", err)
+		os.Exit(1)
+	}
+
+	if len(keys) == 0 {
+		fmt.Println("No API keys found.")
+		return
+	}
+
+	// Create a tabwriter for formatted output
+	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+	fmt.Fprintln(w, "DEV ID\tAPP NAME\tACTIVE\tRATE LIMIT\tCREATED\tLAST USED")
+	fmt.Fprintln(w, "------\t--------\t------\t----------\t-------\t---------")
+
+	for _, key := range keys {
+		lastUsed := "Never"
+		if key.LastUsed != nil {
+			lastUsed = key.LastUsed.Format("2006-01-02 15:04")
+		}
+
+		fmt.Fprintf(w, "%s\t%s\t%v\t%d/min\t%s\t%s\n",
+			truncateString(key.DevID, 20),
+			truncateString(key.AppName, 20),
+			key.IsActive,
+			key.RateLimit,
+			key.CreatedAt.Format("2006-01-02"),
+			lastUsed,
+		)
+	}
+	w.Flush()
+}
+
+func handleShow(args []string) {
+	fs := flag.NewFlagSet("show", flag.ExitOnError)
+	devID := fs.String("dev-id", "", "Developer ID (required)")
+
+	if err := fs.Parse(args); err != nil {
+		fmt.Fprintf(os.Stderr, "Error parsing arguments: %v\n", err)
+		os.Exit(1)
+	}
+
+	if *devID == "" {
+		fmt.Fprintln(os.Stderr, "Error: --dev-id is required")
+		os.Exit(1)
+	}
+
+	store, err := connectToStore()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
+		os.Exit(1)
+	}
+
+	ctx := context.Background()
+	key, err := store.GetAPIKeyByDevID(ctx, *devID)
+	if err != nil {
+		if err == state.ErrNoAPIKey {
+			fmt.Fprintf(os.Stderr, "Error: API key not found for dev_id: %s\n", *devID)
+		} else {
+			fmt.Fprintf(os.Stderr, "Error retrieving API key: %v\n", err)
+		}
+		os.Exit(1)
+	}
+
+	// Output detailed key information
+	fmt.Println("Web API Key Details:")
+	fmt.Println("=====================================")
+	fmt.Printf("Developer ID:  %s\n", key.DevID)
+	fmt.Printf("App Name:      %s\n", key.AppName)
+	fmt.Printf("Active:        %v\n", key.IsActive)
+	fmt.Printf("Rate Limit:    %d requests/minute\n", key.RateLimit)
+	fmt.Printf("Created:       %s\n", key.CreatedAt.Format("2006-01-02 15:04:05"))
+	if key.LastUsed != nil {
+		fmt.Printf("Last Used:     %s\n", key.LastUsed.Format("2006-01-02 15:04:05"))
+	} else {
+		fmt.Println("Last Used:     Never")
+	}
+	if len(key.AllowedOrigins) > 0 {
+		fmt.Printf("Origins:       %s\n", strings.Join(key.AllowedOrigins, ", "))
+	} else {
+		fmt.Println("Origins:       All origins allowed")
+	}
+	if len(key.Capabilities) > 0 {
+		fmt.Printf("Capabilities:  %s\n", strings.Join(key.Capabilities, ", "))
+	} else {
+		fmt.Println("Capabilities:  All capabilities enabled")
+	}
+	fmt.Println("=====================================")
+}
+
+func handleRevoke(args []string) {
+	fs := flag.NewFlagSet("revoke", flag.ExitOnError)
+	devID := fs.String("dev-id", "", "Developer ID to revoke (required)")
+
+	if err := fs.Parse(args); err != nil {
+		fmt.Fprintf(os.Stderr, "Error parsing arguments: %v\n", err)
+		os.Exit(1)
+	}
+
+	if *devID == "" {
+		fmt.Fprintln(os.Stderr, "Error: --dev-id is required")
+		os.Exit(1)
+	}
+
+	store, err := connectToStore()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
+		os.Exit(1)
+	}
+
+	ctx := context.Background()
+	isActive := false
+	update := state.WebAPIKeyUpdate{
+		IsActive: &isActive,
+	}
+
+	if err := store.UpdateAPIKey(ctx, *devID, update); err != nil {
+		if err == state.ErrNoAPIKey {
+			fmt.Fprintf(os.Stderr, "Error: API key not found for dev_id: %s\n", *devID)
+		} else {
+			fmt.Fprintf(os.Stderr, "Error revoking API key: %v\n", err)
+		}
+		os.Exit(1)
+	}
+
+	fmt.Printf("Successfully revoked API key: %s\n", *devID)
+}
+
+func handleActivate(args []string) {
+	fs := flag.NewFlagSet("activate", flag.ExitOnError)
+	devID := fs.String("dev-id", "", "Developer ID to activate (required)")
+
+	if err := fs.Parse(args); err != nil {
+		fmt.Fprintf(os.Stderr, "Error parsing arguments: %v\n", err)
+		os.Exit(1)
+	}
+
+	if *devID == "" {
+		fmt.Fprintln(os.Stderr, "Error: --dev-id is required")
+		os.Exit(1)
+	}
+
+	store, err := connectToStore()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
+		os.Exit(1)
+	}
+
+	ctx := context.Background()
+	isActive := true
+	update := state.WebAPIKeyUpdate{
+		IsActive: &isActive,
+	}
+
+	if err := store.UpdateAPIKey(ctx, *devID, update); err != nil {
+		if err == state.ErrNoAPIKey {
+			fmt.Fprintf(os.Stderr, "Error: API key not found for dev_id: %s\n", *devID)
+		} else {
+			fmt.Fprintf(os.Stderr, "Error activating API key: %v\n", err)
+		}
+		os.Exit(1)
+	}
+
+	fmt.Printf("Successfully activated API key: %s\n", *devID)
+}
+
+func handleUpdate(args []string) {
+	fs := flag.NewFlagSet("update", flag.ExitOnError)
+	devID := fs.String("dev-id", "", "Developer ID to update (required)")
+	appName := fs.String("app-name", "", "New application name")
+	originsStr := fs.String("origins", "", "New comma-separated list of allowed origins")
+	rateLimit := fs.Int("rate-limit", -1, "New requests per minute limit")
+	capabilitiesStr := fs.String("capabilities", "", "New comma-separated list of capabilities")
+
+	if err := fs.Parse(args); err != nil {
+		fmt.Fprintf(os.Stderr, "Error parsing arguments: %v\n", err)
+		os.Exit(1)
+	}
+
+	if *devID == "" {
+		fmt.Fprintln(os.Stderr, "Error: --dev-id is required")
+		os.Exit(1)
+	}
+
+	update := state.WebAPIKeyUpdate{}
+
+	if *appName != "" {
+		update.AppName = appName
+	}
+
+	if *originsStr != "" {
+		origins := parseCSV(*originsStr)
+		update.AllowedOrigins = &origins
+	}
+
+	if *rateLimit > 0 {
+		update.RateLimit = rateLimit
+	}
+
+	if *capabilitiesStr != "" {
+		capabilities := parseCSV(*capabilitiesStr)
+		update.Capabilities = &capabilities
+	}
+
+	// Check if any updates were provided
+	updateJSON, _ := json.Marshal(update)
+	if string(updateJSON) == "{}" {
+		fmt.Fprintln(os.Stderr, "Error: No update fields provided")
+		os.Exit(1)
+	}
+
+	store, err := connectToStore()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
+		os.Exit(1)
+	}
+
+	ctx := context.Background()
+	if err := store.UpdateAPIKey(ctx, *devID, update); err != nil {
+		if err == state.ErrNoAPIKey {
+			fmt.Fprintf(os.Stderr, "Error: API key not found for dev_id: %s\n", *devID)
+		} else {
+			fmt.Fprintf(os.Stderr, "Error updating API key: %v\n", err)
+		}
+		os.Exit(1)
+	}
+
+	fmt.Printf("Successfully updated API key: %s\n", *devID)
+}
+
+func connectToStore() (*state.SQLiteUserStore, error) {
+	dbPath := os.Getenv("DB_PATH")
+	if dbPath == "" {
+		dbPath = "oscar.sqlite"
+	}
+	return state.NewSQLiteUserStore(dbPath)
+}
+
+func parseCSV(input string) []string {
+	if input == "" {
+		return []string{}
+	}
+	parts := strings.Split(input, ",")
+	result := make([]string, 0, len(parts))
+	for _, part := range parts {
+		trimmed := strings.TrimSpace(part)
+		if trimmed != "" {
+			result = append(result, trimmed)
+		}
+	}
+	return result
+}
+
+func truncateString(s string, maxLen int) string {
+	if len(s) <= maxLen {
+		return s
+	}
+	return s[:maxLen-3] + "..."
+}

+ 0 - 1
docs/open_api/webapi.yml

@@ -2093,7 +2093,6 @@ components:
         - json
         - xml
         - php
-        - amf0
         - amf3
     PresenceState:
       type: string

+ 5 - 4
foodgroup/buddy.go

@@ -100,14 +100,15 @@ func (s BuddyService) DelBuddies(ctx context.Context, sess *state.Session, inBod
 	return nil
 }
 
-func (s BuddyService) BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error {
-	return s.buddyBroadcaster.BroadcastBuddyDeparted(ctx, sess)
-}
-
+// BroadcastBuddyArrived broadcasts buddy arrival with custom user info (implements DepartureNotifier)
 func (s BuddyService) BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error {
 	return s.buddyBroadcaster.BroadcastBuddyArrived(ctx, screenName, userInfo)
 }
 
+func (s BuddyService) BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error {
+	return s.buddyBroadcaster.BroadcastBuddyDeparted(ctx, sess)
+}
+
 func newBuddyNotifier(
 	bartItemManager BARTItemManager,
 	relationshipFetcher RelationshipFetcher,

+ 3 - 1
foodgroup/icbm.go

@@ -236,7 +236,8 @@ func (s ICBMService) ClientEvent(ctx context.Context, sess *state.Session, inFra
 	case blocked.BlocksYou || blocked.YouBlock:
 		return nil
 	default:
-		s.messageRelayer.RelayToScreenName(ctx, state.NewIdentScreenName(inBody.ScreenName), wire.SNACMessage{
+		recipient := state.NewIdentScreenName(inBody.ScreenName)
+		s.messageRelayer.RelayToScreenName(ctx, recipient, wire.SNACMessage{
 			Frame: wire.SNACFrame{
 				FoodGroup: wire.ICBM,
 				SubGroup:  wire.ICBMClientEvent,
@@ -249,6 +250,7 @@ func (s ICBMService) ClientEvent(ctx context.Context, sess *state.Session, inFra
 				Event:      inBody.Event,
 			},
 		})
+
 		return nil
 	}
 }

+ 1 - 0
go.mod

@@ -3,6 +3,7 @@ module github.com/mk6i/retro-aim-server
 go 1.24.2
 
 require (
+	github.com/breign/goAMF3 v1.0.1-0.20250916173039-e43798221950
 	github.com/golang-migrate/migrate/v4 v4.18.3
 	github.com/google/uuid v1.6.0
 	github.com/joho/godotenv v1.5.1

+ 2 - 0
go.sum

@@ -1,3 +1,5 @@
+github.com/breign/goAMF3 v1.0.1-0.20250916173039-e43798221950 h1:4TpGDBqh7wsi7UvgoE85EknpgpYy2Yj5ZHahqwbF2LE=
+github.com/breign/goAMF3 v1.0.1-0.20250916173039-e43798221950/go.mod h1:ZN4htA6gGwnzqpHTfuRD+Ryt+Nj8sEIyecdQhFn4smY=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=

+ 18 - 1
server/http/mgmt_api.go

@@ -22,7 +22,7 @@ import (
 	"github.com/mk6i/retro-aim-server/wire"
 )
 
-func NewManagementAPI(bld config.Build, listener string, userManager UserManager, sessionRetriever SessionRetriever, chatRoomRetriever ChatRoomRetriever, chatRoomCreator ChatRoomCreator, chatRoomDeleter ChatRoomDeleter, chatSessionRetriever ChatSessionRetriever, directoryManager DirectoryManager, messageRelayer MessageRelayer, bartAssetManager BARTAssetManager, feedbagRetriever FeedBagRetriever, accountManager AccountManager, profileRetriever ProfileRetriever, logger *slog.Logger) *Server {
+func NewManagementAPI(bld config.Build, listener string, userManager UserManager, sessionRetriever SessionRetriever, chatRoomRetriever ChatRoomRetriever, chatRoomCreator ChatRoomCreator, chatRoomDeleter ChatRoomDeleter, chatSessionRetriever ChatSessionRetriever, directoryManager DirectoryManager, messageRelayer MessageRelayer, bartAssetManager BARTAssetManager, feedbagRetriever FeedBagRetriever, accountManager AccountManager, profileRetriever ProfileRetriever, webAPIKeyManager WebAPIKeyManager, logger *slog.Logger) *Server {
 	mux := http.NewServeMux()
 
 	// Handlers for '/user' route
@@ -98,6 +98,23 @@ func NewManagementAPI(bld config.Build, listener string, userManager UserManager
 		getVersionHandler(w, bld)
 	})
 
+	// Handlers for '/admin/webapi/keys' route - Web API key management
+	mux.HandleFunc("POST /admin/webapi/keys", func(w http.ResponseWriter, r *http.Request) {
+		postWebAPIKeyHandler(w, r, webAPIKeyManager, uuid.New, logger)
+	})
+	mux.HandleFunc("GET /admin/webapi/keys", func(w http.ResponseWriter, r *http.Request) {
+		getWebAPIKeysHandler(w, r, webAPIKeyManager, logger)
+	})
+	mux.HandleFunc("GET /admin/webapi/keys/{id}", func(w http.ResponseWriter, r *http.Request) {
+		getWebAPIKeyHandler(w, r, webAPIKeyManager, logger)
+	})
+	mux.HandleFunc("PUT /admin/webapi/keys/{id}", func(w http.ResponseWriter, r *http.Request) {
+		putWebAPIKeyHandler(w, r, webAPIKeyManager, logger)
+	})
+	mux.HandleFunc("DELETE /admin/webapi/keys/{id}", func(w http.ResponseWriter, r *http.Request) {
+		deleteWebAPIKeyHandler(w, r, webAPIKeyManager, logger)
+	})
+
 	// Handlers for '/directory/category' route
 	mux.HandleFunc("GET /directory/category", func(w http.ResponseWriter, r *http.Request) {
 		getDirectoryCategoryHandler(w, r, directoryManager, logger)

+ 29 - 0
server/http/types.go

@@ -239,3 +239,32 @@ type directoryKeywordCreate struct {
 type messageBody struct {
 	Message string `json:"message"`
 }
+
+// Web API key management types
+
+type createWebAPIKeyRequest struct {
+	AppName        string   `json:"app_name"`
+	AllowedOrigins []string `json:"allowed_origins,omitempty"`
+	RateLimit      int      `json:"rate_limit,omitempty"`
+	Capabilities   []string `json:"capabilities,omitempty"`
+}
+
+type webAPIKeyResponse struct {
+	DevID          string     `json:"dev_id"`
+	DevKey         string     `json:"dev_key,omitempty"` // Only shown on creation
+	AppName        string     `json:"app_name"`
+	CreatedAt      time.Time  `json:"created_at"`
+	LastUsed       *time.Time `json:"last_used,omitempty"`
+	IsActive       bool       `json:"is_active"`
+	RateLimit      int        `json:"rate_limit"`
+	AllowedOrigins []string   `json:"allowed_origins,omitempty"`
+	Capabilities   []string   `json:"capabilities,omitempty"`
+}
+
+type updateWebAPIKeyRequest struct {
+	AppName        *string   `json:"app_name,omitempty"`
+	IsActive       *bool     `json:"is_active,omitempty"`
+	RateLimit      *int      `json:"rate_limit,omitempty"`
+	AllowedOrigins *[]string `json:"allowed_origins,omitempty"`
+	Capabilities   *[]string `json:"capabilities,omitempty"`
+}

+ 255 - 0
server/http/webapi_admin.go

@@ -0,0 +1,255 @@
+package http
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"time"
+
+	"github.com/google/uuid"
+
+	"github.com/mk6i/retro-aim-server/state"
+)
+
+// WebAPIKeyManager defines methods for managing Web API authentication keys.
+type WebAPIKeyManager interface {
+	// CreateAPIKey creates a new Web API key.
+	CreateAPIKey(ctx context.Context, key state.WebAPIKey) error
+
+	// GetAPIKeyByDevID retrieves an API key by its developer ID.
+	GetAPIKeyByDevID(ctx context.Context, devID string) (*state.WebAPIKey, error)
+
+	// ListAPIKeys returns all Web API keys.
+	ListAPIKeys(ctx context.Context) ([]state.WebAPIKey, error)
+
+	// UpdateAPIKey updates an existing Web API key.
+	UpdateAPIKey(ctx context.Context, devID string, updates state.WebAPIKeyUpdate) error
+
+	// DeleteAPIKey removes a Web API key.
+	DeleteAPIKey(ctx context.Context, devID string) error
+}
+
+// postWebAPIKeyHandler handles POST /admin/webapi/keys requests.
+func postWebAPIKeyHandler(w http.ResponseWriter, r *http.Request, keyManager WebAPIKeyManager, newUUID func() uuid.UUID, logger *slog.Logger) {
+	var req createWebAPIKeyRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "malformed request body", http.StatusBadRequest)
+		return
+	}
+
+	// Validate required fields
+	if req.AppName == "" {
+		http.Error(w, "app_name is required", http.StatusBadRequest)
+		return
+	}
+
+	// Set defaults
+	if req.RateLimit <= 0 {
+		req.RateLimit = 60 // Default rate limit
+	}
+
+	// Generate secure API key
+	keyBytes := make([]byte, 32) // 256 bits
+	if _, err := rand.Read(keyBytes); err != nil {
+		logger.Error("failed to generate API key", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	devKey := hex.EncodeToString(keyBytes)
+
+	// Generate developer ID
+	devID := fmt.Sprintf("dev_%s", newUUID().String())
+
+	// Create the API key record
+	apiKey := state.WebAPIKey{
+		DevID:          devID,
+		DevKey:         devKey,
+		AppName:        req.AppName,
+		CreatedAt:      time.Now(),
+		IsActive:       true,
+		RateLimit:      req.RateLimit,
+		AllowedOrigins: req.AllowedOrigins,
+		Capabilities:   req.Capabilities,
+	}
+
+	// Save to database
+	if err := keyManager.CreateAPIKey(r.Context(), apiKey); err != nil {
+		if err == state.ErrDupAPIKey {
+			http.Error(w, "API key already exists", http.StatusConflict)
+			return
+		}
+		logger.Error("failed to create API key", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	// Return the created key (including the dev_key which is only shown once)
+	resp := webAPIKeyResponse{
+		DevID:          apiKey.DevID,
+		DevKey:         apiKey.DevKey, // Only shown on creation
+		AppName:        apiKey.AppName,
+		CreatedAt:      apiKey.CreatedAt,
+		IsActive:       apiKey.IsActive,
+		RateLimit:      apiKey.RateLimit,
+		AllowedOrigins: apiKey.AllowedOrigins,
+		Capabilities:   apiKey.Capabilities,
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusCreated)
+	if err := json.NewEncoder(w).Encode(resp); err != nil {
+		logger.Error("failed to encode response", "err", err.Error())
+	}
+}
+
+// getWebAPIKeysHandler handles GET /admin/webapi/keys requests.
+func getWebAPIKeysHandler(w http.ResponseWriter, r *http.Request, keyManager WebAPIKeyManager, logger *slog.Logger) {
+	keys, err := keyManager.ListAPIKeys(r.Context())
+	if err != nil {
+		logger.Error("failed to list API keys", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	// Convert to response format (without dev_key)
+	resp := make([]webAPIKeyResponse, 0, len(keys))
+	for _, key := range keys {
+		resp = append(resp, webAPIKeyResponse{
+			DevID:          key.DevID,
+			AppName:        key.AppName,
+			CreatedAt:      key.CreatedAt,
+			LastUsed:       key.LastUsed,
+			IsActive:       key.IsActive,
+			RateLimit:      key.RateLimit,
+			AllowedOrigins: key.AllowedOrigins,
+			Capabilities:   key.Capabilities,
+		})
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	if err := json.NewEncoder(w).Encode(resp); err != nil {
+		logger.Error("failed to encode response", "err", err.Error())
+	}
+}
+
+// getWebAPIKeyHandler handles GET /admin/webapi/keys/{id} requests.
+func getWebAPIKeyHandler(w http.ResponseWriter, r *http.Request, keyManager WebAPIKeyManager, logger *slog.Logger) {
+	devID := r.PathValue("id")
+	if devID == "" {
+		http.Error(w, "missing developer ID", http.StatusBadRequest)
+		return
+	}
+
+	key, err := keyManager.GetAPIKeyByDevID(r.Context(), devID)
+	if err != nil {
+		if err == state.ErrNoAPIKey {
+			http.Error(w, "API key not found", http.StatusNotFound)
+			return
+		}
+		logger.Error("failed to get API key", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	// Convert to response format (without dev_key)
+	resp := webAPIKeyResponse{
+		DevID:          key.DevID,
+		AppName:        key.AppName,
+		CreatedAt:      key.CreatedAt,
+		LastUsed:       key.LastUsed,
+		IsActive:       key.IsActive,
+		RateLimit:      key.RateLimit,
+		AllowedOrigins: key.AllowedOrigins,
+		Capabilities:   key.Capabilities,
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	if err := json.NewEncoder(w).Encode(resp); err != nil {
+		logger.Error("failed to encode response", "err", err.Error())
+	}
+}
+
+// putWebAPIKeyHandler handles PUT /admin/webapi/keys/{id} requests.
+func putWebAPIKeyHandler(w http.ResponseWriter, r *http.Request, keyManager WebAPIKeyManager, logger *slog.Logger) {
+	devID := r.PathValue("id")
+	if devID == "" {
+		http.Error(w, "missing developer ID", http.StatusBadRequest)
+		return
+	}
+
+	var req updateWebAPIKeyRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "malformed request body", http.StatusBadRequest)
+		return
+	}
+
+	// Convert request to update struct
+	updates := state.WebAPIKeyUpdate{
+		AppName:        req.AppName,
+		IsActive:       req.IsActive,
+		RateLimit:      req.RateLimit,
+		AllowedOrigins: req.AllowedOrigins,
+		Capabilities:   req.Capabilities,
+	}
+
+	// Update the key
+	if err := keyManager.UpdateAPIKey(r.Context(), devID, updates); err != nil {
+		if err == state.ErrNoAPIKey {
+			http.Error(w, "API key not found", http.StatusNotFound)
+			return
+		}
+		logger.Error("failed to update API key", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	// Retrieve the updated key to return
+	key, err := keyManager.GetAPIKeyByDevID(r.Context(), devID)
+	if err != nil {
+		logger.Error("failed to retrieve updated API key", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	// Convert to response format (without dev_key)
+	resp := webAPIKeyResponse{
+		DevID:          key.DevID,
+		AppName:        key.AppName,
+		CreatedAt:      key.CreatedAt,
+		LastUsed:       key.LastUsed,
+		IsActive:       key.IsActive,
+		RateLimit:      key.RateLimit,
+		AllowedOrigins: key.AllowedOrigins,
+		Capabilities:   key.Capabilities,
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	if err := json.NewEncoder(w).Encode(resp); err != nil {
+		logger.Error("failed to encode response", "err", err.Error())
+	}
+}
+
+// deleteWebAPIKeyHandler handles DELETE /admin/webapi/keys/{id} requests.
+func deleteWebAPIKeyHandler(w http.ResponseWriter, r *http.Request, keyManager WebAPIKeyManager, logger *slog.Logger) {
+	devID := r.PathValue("id")
+	if devID == "" {
+		http.Error(w, "missing developer ID", http.StatusBadRequest)
+		return
+	}
+
+	if err := keyManager.DeleteAPIKey(r.Context(), devID); err != nil {
+		if err == state.ErrNoAPIKey {
+			http.Error(w, "API key not found", http.StatusNotFound)
+			return
+		}
+		logger.Error("failed to delete API key", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	w.WriteHeader(http.StatusNoContent)
+}

+ 196 - 0
server/webapi/adapters.go

@@ -0,0 +1,196 @@
+package webapi
+
+import (
+	"bytes"
+	"context"
+	"crypto/rand"
+	"encoding/binary"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/server/webapi/types"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// FeedbagAdapter wraps SQLiteUserStore to implement FeedbagRetriever and FeedbagManager interfaces
+type FeedbagAdapter struct {
+	Store *state.SQLiteUserStore
+}
+
+// RetrieveFeedbag implements FeedbagRetriever interface
+func (f *FeedbagAdapter) RetrieveFeedbag(ctx context.Context, screenName state.IdentScreenName) ([]wire.FeedbagItem, error) {
+	return f.Store.Feedbag(ctx, screenName)
+}
+
+// RelationshipsByUser implements FeedbagRetriever interface
+// Returns the list of users who have this user in their buddy list
+func (f *FeedbagAdapter) RelationshipsByUser(ctx context.Context, screenName state.IdentScreenName) ([]state.IdentScreenName, error) {
+	// Get all relationships where this user is involved
+	relationships, err := f.Store.AllRelationships(ctx, screenName, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	// Extract unique screen names from relationships
+	uniqueUsers := make(map[state.IdentScreenName]bool)
+	for _, rel := range relationships {
+		// Add the user from the relationship
+		uniqueUsers[rel.User] = true
+	}
+
+	// Convert map to slice
+	users := make([]state.IdentScreenName, 0, len(uniqueUsers))
+	for user := range uniqueUsers {
+		users = append(users, user)
+	}
+
+	return users, nil
+}
+
+// InsertItem implements FeedbagManager interface
+func (f *FeedbagAdapter) InsertItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error {
+	// Use FeedbagUpsert to insert a new item
+	return f.Store.FeedbagUpsert(ctx, screenName, []wire.FeedbagItem{item})
+}
+
+// UpdateItem implements FeedbagManager interface
+func (f *FeedbagAdapter) UpdateItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error {
+	// Use FeedbagUpsert to update an existing item
+	return f.Store.FeedbagUpsert(ctx, screenName, []wire.FeedbagItem{item})
+}
+
+// DeleteItem implements FeedbagManager interface
+func (f *FeedbagAdapter) DeleteItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error {
+	// Use FeedbagDelete to remove an item
+	return f.Store.FeedbagDelete(ctx, screenName, []wire.FeedbagItem{item})
+}
+
+// Message Conversion Functions
+
+// WebAPIToICBM converts a Web API message to OSCAR ICBM format
+func WebAPIToICBM(sender state.IdentScreenName, recipient string, message string, autoResponse bool) (wire.SNAC_0x04_0x06_ICBMChannelMsgToHost, error) {
+	// Generate message cookie
+	var cookie [8]byte
+	if _, err := rand.Read(cookie[:]); err != nil {
+		return wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{}, err
+	}
+	cookieUint64 := binary.BigEndian.Uint64(cookie[:])
+
+	// Create ICBM fragment list for the message
+	frags, err := wire.ICBMFragmentList(message)
+	if err != nil {
+		return wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{}, err
+	}
+
+	// Marshal the fragments
+	buf := &bytes.Buffer{}
+	for _, frag := range frags {
+		if err := wire.MarshalBE(frag, buf); err != nil {
+			return wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{}, err
+		}
+	}
+
+	// Build ICBM message
+	icbmMsg := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+		Cookie:     cookieUint64,
+		ChannelID:  wire.ICBMChannelIM,
+		ScreenName: recipient,
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.ICBMTLVAOLIMData, buf.Bytes()),
+			},
+		},
+	}
+
+	// Add auto-response flag if applicable
+	if autoResponse {
+		icbmMsg.Append(wire.NewTLVBE(wire.ICBMTLVAutoResponse, []byte{}))
+	}
+
+	return icbmMsg, nil
+}
+
+// ICBMToWebAPIEvent converts an incoming ICBM message to a WebAPI event
+func ICBMToWebAPIEvent(icbm wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) (types.Event, error) {
+	// Extract message text
+	var messageText string
+	var autoResponse bool
+
+	// Check for AOL IM data
+	if msgData, hasMsg := icbm.Bytes(wire.ICBMTLVAOLIMData); hasMsg {
+		msgText, err := wire.UnmarshalICBMMessageText(msgData)
+		if err == nil {
+			messageText = msgText
+		}
+	}
+
+	// Check for auto-response flag
+	if _, hasAutoResp := icbm.Bytes(wire.ICBMTLVAutoResponse); hasAutoResp {
+		autoResponse = true
+	}
+
+	// Extract sender screen name from TLVUserInfo
+	senderScreenName := ""
+	if icbm.TLVUserInfo.ScreenName != "" {
+		senderScreenName = icbm.TLVUserInfo.ScreenName
+	}
+
+	// Create WebAPI event
+	event := types.Event{
+		Type:      types.EventTypeIM,
+		Timestamp: time.Now().Unix(),
+		Data: types.IMEvent{
+			From:      senderScreenName,
+			Message:   messageText,
+			Timestamp: float64(time.Now().Unix()),
+			AutoResp:  autoResponse,
+		},
+	}
+
+	return event, nil
+}
+
+// TypingNotificationToWebAPIEvent converts an OSCAR typing notification to a WebAPI event
+func TypingNotificationToWebAPIEvent(notification wire.SNAC_0x04_0x14_ICBMClientEvent) types.Event {
+	typing := false
+	switch notification.Event {
+	case 0x0002: // Typing started
+		typing = true
+	case 0x0001: // Typing stopped
+		typing = false
+	}
+
+	return types.Event{
+		Type:      types.EventTypeTyping,
+		Timestamp: time.Now().Unix(),
+		Data: types.TypingEvent{
+			From:   notification.ScreenName,
+			Typing: typing,
+		},
+	}
+}
+
+// PresenceUpdateToWebAPIEvent converts OSCAR buddy arrival/departure to WebAPI event
+func PresenceUpdateToWebAPIEvent(screenName string, online bool, awayMsg string, statusBitmask uint32) types.Event {
+	stateStr := "offline"
+	if online {
+		stateStr = "online"
+		if statusBitmask&wire.OServiceUserStatusAway != 0 {
+			stateStr = "away"
+		} else if statusBitmask&wire.OServiceUserStatusDND != 0 {
+			stateStr = "dnd"
+		} else if statusBitmask&wire.OServiceUserStatusInvisible != 0 {
+			stateStr = "invisible"
+		}
+	}
+
+	return types.Event{
+		Type:      types.EventTypePresence,
+		Timestamp: time.Now().Unix(),
+		Data: types.PresenceEvent{
+			AimID:   screenName,
+			State:   stateStr,
+			AwayMsg: awayMsg,
+		},
+	}
+}

+ 27 - 0
server/webapi/handler.go

@@ -1,10 +1,12 @@
 package webapi
 
 import (
+	"context"
 	"fmt"
 	"log/slog"
 	"net/http"
 
+	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
 )
 
@@ -24,6 +26,31 @@ type Handler struct {
 	PermitDenyService PermitDenyService
 	TOCConfigStore    TOCConfigStore
 	SNACRateLimits    wire.SNACRateLimits
+	// New fields for WebAPI handlers
+	SessionRetriever SessionRetriever
+	FeedbagRetriever FeedbagRetriever
+	FeedbagManager   FeedbagManager
+	// Phase 2 additions
+	MessageRelayer        MessageRelayer
+	OfflineMessageManager OfflineMessageManager
+	BuddyBroadcaster      BuddyBroadcaster
+	ProfileManager        ProfileManager
+	RelationshipFetcher   interface {
+		Relationship(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) (state.Relationship, error)
+	}
+	// Authentication support
+	UserManager UserManager
+	TokenStore  TokenStore
+	// Phase 3 additions
+	PreferenceManager PreferenceManager
+	PermitDenyManager PermitDenyManager
+	// Phase 4 additions for OSCAR Bridge
+	OSCARBridgeStore OSCARBridgeStore
+	OSCARConfig      OSCARConfig
+	// Phase 5 additions for buddy list and messaging
+	BuddyListManager interface{}
+	// Phase 5 additions for chat rooms
+	ChatManager *state.WebAPIChatManager
 }
 
 func (h Handler) GetHelloWorldHandler(w http.ResponseWriter, r *http.Request) {

+ 443 - 0
server/webapi/handlers/amf_encoder.go

@@ -0,0 +1,443 @@
+package handlers
+
+import (
+	"fmt"
+	"log/slog"
+	"net/http"
+	"reflect"
+	"strings"
+	"time"
+
+	goAMF3 "github.com/breign/goAMF3"
+	"github.com/mk6i/retro-aim-server/server/webapi/types"
+)
+
+// AMFVersion represents the AMF encoding version
+type AMFVersion int
+
+const (
+	AMF3 AMFVersion = 3
+)
+
+// AMFEncoder handles AMF encoding operations for WebAPI responses
+type AMFEncoder struct {
+	logger *slog.Logger
+}
+
+// NewAMFEncoder creates a new AMF encoder instance
+func NewAMFEncoder(logger *slog.Logger) *AMFEncoder {
+	return &AMFEncoder{logger: logger}
+}
+
+// EncodeAMF encodes data to AMF3 format (only supported version)
+func (e *AMFEncoder) EncodeAMF(data interface{}, version AMFVersion) ([]byte, error) {
+	// For AMF3, use goAMF3 which properly supports it
+	// Convert to a regular map structure (no ECMAArray needed)
+	amfData := e.toAMF3Compatible(data)
+	// goAMF3 panics on nil values, ensure we sanitize
+	sanitized := e.sanitizeForAMF3(amfData)
+	encoded := goAMF3.EncodeAMF3(sanitized)
+	return encoded, nil
+}
+
+// toAMF3Compatible converts Go types to AMF3-compatible format for goAMF3
+func (e *AMFEncoder) toAMF3Compatible(data interface{}) interface{} {
+	if data == nil {
+		return map[string]interface{}{}
+	}
+
+	// goAMF3 handles regular Go types well, just need to ensure maps are used
+	// Don't use ECMAArray for AMF3 - just regular maps
+	switch d := data.(type) {
+	case BaseResponse:
+		return e.baseResponseToMap(d)
+	case ResponseBody:
+		return e.responseBodyToMap(d)
+	case ErrorResponse:
+		return e.errorResponseToMap(d)
+	case StartSessionResponse:
+		// Special handling for StartSessionResponse
+		return map[string]interface{}{
+			"response": map[string]interface{}{
+				"statusCode": d.Response.StatusCode,
+				"statusText": d.Response.StatusText,
+				"data": map[string]interface{}{
+					"aimsid":          d.Response.Data.AimSID,
+					"fetchTimeout":    d.Response.Data.FetchTimeout,
+					"timeToNextFetch": d.Response.Data.TimeToNextFetch,
+					"fetchBaseURL":    d.Response.Data.FetchBaseURL, // Required for Gromit
+					"events":          d.Response.Data.Events,
+					"wellKnownUrls":   d.Response.Data.WellKnownUrls,
+				},
+			},
+		}
+	case FetchEventsResponse:
+		// Special handling for FetchEventsResponse
+		// goAMF3 can't handle uint64, must convert to int
+		return map[string]interface{}{
+			"response": map[string]interface{}{
+				"statusCode": d.Response.StatusCode,
+				"statusText": d.Response.StatusText,
+				"data": map[string]interface{}{
+					"events":          d.Response.Data.Events,
+					"lastSeqNum":      int(d.Response.Data.LastSeqNum), // Convert uint64 to int
+					"timeToNextFetch": d.Response.Data.TimeToNextFetch,
+					"fetchBaseURL":    d.Response.Data.FetchBaseURL,
+				},
+			},
+		}
+	case EndSessionResponse:
+		// Special handling for EndSessionResponse - Gromit expects flat structure
+		// Based on Gromit's MockServer, it expects:
+		// { "data": {}, "statusCode": 200, "statusText": "OK" }
+		return map[string]interface{}{
+			"data":       map[string]interface{}{}, // Empty data object
+			"statusCode": d.Response.StatusCode,
+			"statusText": d.Response.StatusText,
+		}
+	default:
+		// For other types, convert structs to maps
+		return e.convertToMap(data)
+	}
+}
+
+// sanitizeForAMF3 recursively removes nil values from the data structure
+// because goAMF3 panics when encountering nil values in maps
+func (e *AMFEncoder) sanitizeForAMF3(data interface{}) interface{} {
+	if data == nil {
+		return map[string]interface{}{}
+	}
+
+	switch v := data.(type) {
+	case uint64:
+		// goAMF3 can't handle uint64, convert to int
+		return int(v)
+	case uint32:
+		// Convert all unsigned to signed for safety
+		return int(v)
+	case uint16:
+		return int(v)
+	case uint8:
+		return int(v)
+	case uint:
+		return int(v)
+	case map[string]interface{}:
+		result := make(map[string]interface{})
+		for key, val := range v {
+			if val == nil {
+				// For fields like 'data', replace with empty map
+				// For other fields, skip them
+				if key == "data" {
+					result[key] = map[string]interface{}{}
+				}
+				continue
+			}
+			result[key] = e.sanitizeForAMF3(val)
+		}
+		return result
+	case []interface{}:
+		result := make([]interface{}, len(v))
+		for i, item := range v {
+			result[i] = e.sanitizeForAMF3(item)
+		}
+		return result
+	case []types.Event:
+		// Handle WebAPIEvent arrays specially
+		result := make([]interface{}, len(v))
+		for i, event := range v {
+			// AMF3 has a 29-bit limit for integers
+			// Keep seqNum small by using modulo
+			seqNum := int(event.SeqNum % (1 << 29))
+			// Convert timestamp to seconds ago to keep it small
+			timestampSec := int(time.Now().Unix() - event.Timestamp)
+			if timestampSec < 0 {
+				timestampSec = 0
+			}
+
+			result[i] = map[string]interface{}{
+				"type":      event.Type,
+				"seqNum":    seqNum,
+				"timestamp": timestampSec,
+				"data":      e.sanitizeForAMF3(event.Data),
+			}
+		}
+		return result
+	case types.Event:
+		// Handle single WebAPIEvent
+		// AMF3 has a 29-bit limit for integers
+		seqNum := int(v.SeqNum % (1 << 29))
+		// Convert timestamp to seconds ago to keep it small
+		timestampSec := int(time.Now().Unix() - v.Timestamp)
+		if timestampSec < 0 {
+			timestampSec = 0
+		}
+
+		return map[string]interface{}{
+			"type":      v.Type,
+			"seqNum":    seqNum,
+			"timestamp": timestampSec,
+			"data":      e.sanitizeForAMF3(v.Data),
+		}
+	default:
+		// For other types, use reflection to check if it's a struct
+		// and convert to map
+		rv := reflect.ValueOf(data)
+		if rv.Kind() == reflect.Struct {
+			return e.structToMap(rv)
+		}
+		return data
+	}
+}
+
+// toAMFCompatible converts Go types to AMF3-compatible types
+func (e *AMFEncoder) toAMFCompatible(data interface{}) interface{} {
+	return e.toAMF3Compatible(data)
+}
+
+// baseResponseToMap converts BaseResponse to AMF3-compatible map
+func (e *AMFEncoder) baseResponseToMap(resp BaseResponse) map[string]interface{} {
+	return map[string]interface{}{
+		"response": e.responseBodyToMap(resp.Response),
+	}
+}
+
+// responseBodyToMap converts ResponseBody to AMF3-compatible map
+func (e *AMFEncoder) responseBodyToMap(body ResponseBody) map[string]interface{} {
+	m := map[string]interface{}{
+		"statusCode": body.StatusCode,
+		"statusText": body.StatusText,
+	}
+	if body.Data != nil {
+		m["data"] = e.toAMF3Compatible(body.Data)
+	} else {
+		// For AMF3, always include data field even if empty to prevent truncation
+		m["data"] = map[string]interface{}{}
+	}
+	return m
+}
+
+// errorResponseToMap converts ErrorResponse to AMF3-compatible map
+func (e *AMFEncoder) errorResponseToMap(err ErrorResponse) map[string]interface{} {
+	return map[string]interface{}{
+		"response": map[string]interface{}{
+			"statusCode": err.Response.StatusCode,
+			"statusText": err.Response.StatusText,
+		},
+	}
+}
+
+// structToMap converts a struct to a map using JSON tags for AMF3
+func (e *AMFEncoder) structToMap(v reflect.Value) map[string]interface{} {
+	result := make(map[string]interface{})
+	t := v.Type()
+
+	for i := 0; i < v.NumField(); i++ {
+		field := t.Field(i)
+		fieldValue := v.Field(i)
+
+		// Skip unexported fields
+		if !fieldValue.CanInterface() {
+			continue
+		}
+
+		// Get JSON tag
+		jsonTag := field.Tag.Get("json")
+		if jsonTag == "-" {
+			continue
+		}
+
+		// Parse JSON tag
+		tagParts := strings.Split(jsonTag, ",")
+		fieldName := tagParts[0]
+		if fieldName == "" {
+			fieldName = field.Name
+		}
+
+		// Check for omitempty
+		omitEmpty := false
+		for _, part := range tagParts[1:] {
+			if part == "omitempty" {
+				omitEmpty = true
+				break
+			}
+		}
+
+		// Skip if omitempty and value is zero
+		if omitEmpty && e.isZeroValue(fieldValue) {
+			continue
+		}
+
+		// Get field value and convert recursively
+		fieldData := fieldValue.Interface()
+		result[fieldName] = e.toAMF3Compatible(fieldData)
+	}
+
+	return result
+}
+
+// sliceToArray converts a slice to an AMF3-compatible array
+func (e *AMFEncoder) sliceToArray(v reflect.Value) []interface{} {
+	length := v.Len()
+	result := make([]interface{}, length)
+
+	for i := 0; i < length; i++ {
+		elem := v.Index(i)
+		if elem.CanInterface() {
+			result[i] = e.toAMF3Compatible(elem.Interface())
+		} else {
+			result[i] = nil
+		}
+	}
+
+	return result
+}
+
+// mapToAMFMap converts a Go map to an AMF3-compatible map
+func (e *AMFEncoder) mapToAMFMap(v reflect.Value) map[string]interface{} {
+	result := make(map[string]interface{})
+
+	for _, key := range v.MapKeys() {
+		// Convert key to string (AMF only supports string keys)
+		keyStr := fmt.Sprintf("%v", key.Interface())
+		value := v.MapIndex(key)
+
+		if value.CanInterface() {
+			result[keyStr] = e.toAMF3Compatible(value.Interface())
+		}
+	}
+
+	return result
+}
+
+// convertToMap converts any data to a map structure for AMF3
+func (e *AMFEncoder) convertToMap(data interface{}) interface{} {
+	if data == nil {
+		// For AMF3, return empty map instead of nil to avoid truncation
+		return map[string]interface{}{}
+	}
+
+	// If already a map, return as-is (even if empty)
+	if m, ok := data.(map[string]interface{}); ok {
+		if m == nil {
+			return map[string]interface{}{}
+		}
+		return m
+	}
+
+	v := reflect.ValueOf(data)
+
+	// Handle pointers
+	if v.Kind() == reflect.Ptr {
+		if v.IsNil() {
+			return nil
+		}
+		v = v.Elem()
+		data = v.Interface()
+	}
+
+	// Handle different types
+	switch v.Kind() {
+	case reflect.Struct:
+		return e.structToMap(v)
+	case reflect.Map:
+		return e.mapToAMFMap(v)
+	case reflect.Slice, reflect.Array:
+		result := make([]interface{}, v.Len())
+		for i := 0; i < v.Len(); i++ {
+			elem := v.Index(i)
+			if elem.CanInterface() {
+				result[i] = e.convertToMap(elem.Interface())
+			}
+		}
+		return result
+	default:
+		// For basic types, return as-is
+		return data
+	}
+}
+
+// isZeroValue checks if a reflect.Value is a zero value
+func (e *AMFEncoder) isZeroValue(v reflect.Value) bool {
+	switch v.Kind() {
+	case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
+		return v.Len() == 0
+	case reflect.Bool:
+		return !v.Bool()
+	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+		return v.Int() == 0
+	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+		return v.Uint() == 0
+	case reflect.Float32, reflect.Float64:
+		return v.Float() == 0
+	case reflect.Interface, reflect.Ptr:
+		return v.IsNil()
+	case reflect.Struct:
+		// For time.Time, check if it's zero
+		if t, ok := v.Interface().(time.Time); ok {
+			return t.IsZero()
+		}
+		// For other structs, we can't easily determine zero value
+		return false
+	}
+	return false
+}
+
+// DetectAMFVersion determines which AMF version to use based on the request
+func DetectAMFVersion(r *http.Request) AMFVersion {
+	if r == nil {
+		return AMF3
+	}
+
+	// Check query parameter first (highest priority)
+	format := strings.ToLower(r.URL.Query().Get("f"))
+	switch format {
+	case "amf3":
+		return AMF3
+	case "amf":
+		// Default to AMF3 for modern clients (Gromit expects AMF3)
+		return AMF3
+	}
+
+	// Check Accept header for version hint
+	accept := r.Header.Get("Accept")
+	if strings.Contains(accept, "amf3") || strings.Contains(accept, "AMF3") {
+		return AMF3
+	}
+	if strings.Contains(accept, "amf") || strings.Contains(accept, "AMF") {
+		return AMF3 // Default to AMF3 for AMF requests
+	}
+
+	// Check Content-Type header (for POST requests)
+	contentType := r.Header.Get("Content-Type")
+	if strings.Contains(contentType, "amf3") || strings.Contains(contentType, "AMF3") {
+		return AMF3
+	}
+	if strings.Contains(contentType, "amf") || strings.Contains(contentType, "AMF") {
+		return AMF3 // Default to AMF3 for AMF requests
+	}
+
+	// Default to AMF3 for modern clients
+	return AMF3
+}
+
+// IsAMFRequest checks if the request is asking for AMF format
+func IsAMFRequest(r *http.Request) bool {
+	if r == nil {
+		return false
+	}
+
+	// Check query parameter
+	format := strings.ToLower(r.URL.Query().Get("f"))
+	if format == "amf" || format == "amf0" || format == "amf3" {
+		return true
+	}
+
+	// Check Accept header
+	accept := strings.ToLower(r.Header.Get("Accept"))
+	if strings.Contains(accept, "application/x-amf") ||
+		strings.Contains(accept, "application/amf") {
+		return true
+	}
+
+	return false
+}

+ 489 - 0
server/webapi/handlers/amf_encoder_test.go

@@ -0,0 +1,489 @@
+package handlers
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	goAMF3 "github.com/breign/goAMF3"
+)
+
+func TestAMFEncoderBasicTypes(t *testing.T) {
+	encoder := NewAMFEncoder(nil)
+
+	tests := []struct {
+		name    string
+		input   interface{}
+		version AMFVersion
+		wantErr bool
+	}{
+		{"String AMF3", "hello world", AMF3, false},
+		{"Number AMF3", 42, AMF3, false},
+		{"Float AMF3", 3.14159, AMF3, false},
+		{"Boolean AMF3", false, AMF3, false},
+		{"Null AMF3", nil, AMF3, false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			data, err := encoder.EncodeAMF(tt.input, tt.version)
+			if (err != nil) != tt.wantErr {
+				t.Fatalf("EncodeAMF() error = %v, wantErr %v", err, tt.wantErr)
+			}
+
+			if !tt.wantErr && len(data) == 0 {
+				t.Fatal("EncodeAMF() returned empty data")
+			}
+
+			// Try to decode the data to verify it's valid AMF3
+			if !tt.wantErr {
+				decoded := goAMF3.DecodeAMF3(data)
+				if decoded == nil {
+					t.Fatalf("Failed to decode AMF3 data: got nil result")
+				}
+			}
+		})
+	}
+}
+
+func TestAMFEncoderComplexTypes(t *testing.T) {
+	encoder := NewAMFEncoder(nil)
+
+	tests := []struct {
+		name    string
+		input   interface{}
+		version AMFVersion
+	}{
+		{
+			name: "Map",
+			input: map[string]interface{}{
+				"name":   "John Doe",
+				"age":    30,
+				"active": true,
+			},
+			version: AMF3,
+		},
+		{
+			name: "Array",
+			input: []interface{}{
+				"item1",
+				42,
+				true,
+				nil,
+			},
+			version: AMF3,
+		},
+		{
+			name: "BaseResponse",
+			input: BaseResponse{
+				Response: ResponseBody{
+					StatusCode: 200,
+					StatusText: "OK",
+					Data: map[string]interface{}{
+						"user":   "testuser",
+						"online": true,
+						"buddies": []interface{}{
+							"friend1",
+							"friend2",
+						},
+					},
+				},
+			},
+			version: AMF3,
+		},
+		{
+			name: "ErrorResponse",
+			input: ErrorResponse{
+				Response: struct {
+					StatusCode int    `json:"statusCode" xml:"statusCode"`
+					StatusText string `json:"statusText" xml:"statusText"`
+				}{
+					StatusCode: 404,
+					StatusText: "Not Found",
+				},
+			},
+			version: AMF3,
+		},
+		{
+			name: "Time",
+			input: map[string]interface{}{
+				"timestamp": time.Now(),
+				"name":      "Event",
+			},
+			version: AMF3,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			data, err := encoder.EncodeAMF(tt.input, tt.version)
+			if err != nil {
+				t.Fatalf("EncodeAMF() error = %v", err)
+			}
+
+			if len(data) == 0 {
+				t.Fatal("EncodeAMF() returned empty data")
+			}
+
+			// Verify the data is valid AMF
+			decoded := goAMF3.DecodeAMF3(data)
+
+			if decoded == nil {
+				t.Fatalf("Failed to decode AMF data: got nil result")
+			}
+
+			// Log the size for performance comparison
+			t.Logf("%s: %d bytes", tt.name, len(data))
+		})
+	}
+}
+
+func TestDetectAMFVersion(t *testing.T) {
+	tests := []struct {
+		name     string
+		request  *http.Request
+		expected AMFVersion
+	}{
+		{
+			name:     "Query parameter amf3",
+			request:  httptest.NewRequest("GET", "/?f=amf3", nil),
+			expected: AMF3,
+		},
+		{
+			name:     "Query parameter amf",
+			request:  httptest.NewRequest("GET", "/?f=amf", nil),
+			expected: AMF3,
+		},
+		{
+			name: "Accept header AMF3",
+			request: func() *http.Request {
+				req := httptest.NewRequest("GET", "/", nil)
+				req.Header.Set("Accept", "application/x-amf3")
+				return req
+			}(),
+			expected: AMF3,
+		},
+		{
+			name: "Accept header AMF",
+			request: func() *http.Request {
+				req := httptest.NewRequest("GET", "/", nil)
+				req.Header.Set("Accept", "application/x-amf")
+				return req
+			}(),
+			expected: AMF3,
+		},
+		{
+			name:     "No AMF indication",
+			request:  httptest.NewRequest("GET", "/", nil),
+			expected: AMF3,
+		},
+		{
+			name:     "Nil request",
+			request:  nil,
+			expected: AMF3,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			version := DetectAMFVersion(tt.request)
+			if version != tt.expected {
+				t.Errorf("DetectAMFVersion() = %v, want %v", version, tt.expected)
+			}
+		})
+	}
+}
+
+func TestIsAMFRequest(t *testing.T) {
+	tests := []struct {
+		name     string
+		request  *http.Request
+		expected bool
+	}{
+		{
+			name:     "Query parameter amf",
+			request:  httptest.NewRequest("GET", "/?f=amf", nil),
+			expected: true,
+		},
+		{
+			name:     "Query parameter amf3",
+			request:  httptest.NewRequest("GET", "/?f=amf3", nil),
+			expected: true,
+		},
+		{
+			name: "Accept header",
+			request: func() *http.Request {
+				req := httptest.NewRequest("GET", "/", nil)
+				req.Header.Set("Accept", "application/x-amf")
+				return req
+			}(),
+			expected: true,
+		},
+		{
+			name:     "JSON format",
+			request:  httptest.NewRequest("GET", "/?f=json", nil),
+			expected: false,
+		},
+		{
+			name:     "No format",
+			request:  httptest.NewRequest("GET", "/", nil),
+			expected: false,
+		},
+		{
+			name:     "Nil request",
+			request:  nil,
+			expected: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := IsAMFRequest(tt.request)
+			if result != tt.expected {
+				t.Errorf("IsAMFRequest() = %v, want %v", result, tt.expected)
+			}
+		})
+	}
+}
+
+func TestSendAMF(t *testing.T) {
+	tests := []struct {
+		name         string
+		request      *http.Request
+		data         interface{}
+		expectStatus int
+	}{
+		{
+			name:    "Simple response",
+			request: httptest.NewRequest("GET", "/?f=amf", nil),
+			data: BaseResponse{
+				Response: ResponseBody{
+					StatusCode: 200,
+					StatusText: "OK",
+					Data:       map[string]interface{}{"test": "value"},
+				},
+			},
+			expectStatus: http.StatusOK,
+		},
+		{
+			name:    "AMF3 response with array",
+			request: httptest.NewRequest("GET", "/?f=amf3", nil),
+			data: BaseResponse{
+				Response: ResponseBody{
+					StatusCode: 200,
+					StatusText: "OK",
+					Data:       []interface{}{"item1", "item2"},
+				},
+			},
+			expectStatus: http.StatusOK,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// First test if the encoder can handle the data
+			encoder := NewAMFEncoder(nil)
+			version := DetectAMFVersion(tt.request)
+			_, encodeErr := encoder.EncodeAMF(tt.data, version)
+			if encodeErr != nil {
+				t.Fatalf("Encoding failed: %v", encodeErr)
+			}
+
+			w := httptest.NewRecorder()
+			SendAMF(w, tt.request, tt.data, nil)
+
+			resp := w.Result()
+			if resp.StatusCode != tt.expectStatus {
+				t.Errorf("Expected status %d, got %d", tt.expectStatus, resp.StatusCode)
+				// Print response body for debugging
+				body := w.Body.String()
+				t.Logf("Response body: %s", body)
+			}
+
+			contentType := resp.Header.Get("Content-Type")
+			if contentType != "application/x-amf" {
+				t.Errorf("Expected Content-Type application/x-amf, got %s", contentType)
+			}
+
+			body := w.Body.Bytes()
+			if len(body) == 0 {
+				t.Error("Response body is empty")
+			}
+		})
+	}
+}
+
+func TestStructToMap(t *testing.T) {
+	encoder := NewAMFEncoder(nil)
+
+	type TestStruct struct {
+		Name     string `json:"name"`
+		Age      int    `json:"age"`
+		Active   bool   `json:"active"`
+		Hidden   string `json:"-"`
+		Optional string `json:"optional,omitempty"`
+		NoTag    string
+	}
+
+	testStruct := TestStruct{
+		Name:     "John",
+		Age:      30,
+		Active:   true,
+		Hidden:   "should not appear",
+		Optional: "", // should be omitted
+		NoTag:    "should appear with field name",
+	}
+
+	result := encoder.toAMFCompatible(testStruct)
+	resultMap, ok := result.(map[string]interface{})
+	if !ok {
+		t.Fatal("Expected map[string]interface{}")
+	}
+
+	// Check expected fields
+	if resultMap["name"] != "John" {
+		t.Errorf("Expected name=John, got %v", resultMap["name"])
+	}
+	if resultMap["age"] != 30 {
+		t.Errorf("Expected age=30, got %v", resultMap["age"])
+	}
+	if resultMap["active"] != true {
+		t.Errorf("Expected active=true, got %v", resultMap["active"])
+	}
+	if resultMap["NoTag"] != "should appear with field name" {
+		t.Errorf("Expected NoTag field, got %v", resultMap["NoTag"])
+	}
+
+	// Check omitted fields
+	if _, exists := resultMap["Hidden"]; exists {
+		t.Error("Hidden field should not appear")
+	}
+	if _, exists := resultMap["optional"]; exists {
+		t.Error("Optional empty field should be omitted")
+	}
+}
+
+func TestSliceToArray(t *testing.T) {
+	encoder := NewAMFEncoder(nil)
+
+	input := []interface{}{
+		"string",
+		42,
+		true,
+		nil,
+		map[string]interface{}{"nested": "value"},
+	}
+
+	result := encoder.toAMFCompatible(input)
+	resultArray, ok := result.([]interface{})
+	if !ok {
+		t.Fatal("Expected []interface{}")
+	}
+
+	if len(resultArray) != 5 {
+		t.Errorf("Expected 5 elements, got %d", len(resultArray))
+	}
+
+	if resultArray[0] != "string" {
+		t.Errorf("Expected first element to be 'string', got %v", resultArray[0])
+	}
+	if resultArray[1] != 42 {
+		t.Errorf("Expected second element to be 42, got %v", resultArray[1])
+	}
+	if resultArray[2] != true {
+		t.Errorf("Expected third element to be true, got %v", resultArray[2])
+	}
+	// For AMF3, nil values are converted to empty maps for compatibility
+	if resultArray[3] != nil {
+		emptyMap, ok := resultArray[3].(map[string]interface{})
+		if !ok || len(emptyMap) != 0 {
+			t.Errorf("Expected fourth element to be empty map, got %v", resultArray[3])
+		}
+	}
+
+	nested, ok := resultArray[4].(map[string]interface{})
+	if !ok {
+		t.Error("Expected fifth element to be map")
+	} else if nested["nested"] != "value" {
+		t.Errorf("Expected nested value, got %v", nested["nested"])
+	}
+}
+
+// Benchmark tests
+func BenchmarkAMFEncoding(b *testing.B) {
+	encoder := NewAMFEncoder(nil)
+	data := BaseResponse{
+		Response: ResponseBody{
+			StatusCode: 200,
+			StatusText: "OK",
+			Data: map[string]interface{}{
+				"users": []interface{}{
+					map[string]interface{}{"name": "user1", "online": true},
+					map[string]interface{}{"name": "user2", "online": false},
+					map[string]interface{}{"name": "user3", "online": true},
+				},
+				"timestamp": time.Now().Unix(),
+				"server":    "retro-aim-server",
+			},
+		},
+	}
+
+	b.Run("AMF3", func(b *testing.B) {
+		for i := 0; i < b.N; i++ {
+			_, _ = encoder.EncodeAMF(data, AMF3)
+		}
+	})
+}
+
+func TestZeroValueDetection(t *testing.T) {
+	encoder := NewAMFEncoder(nil)
+
+	type TestStruct struct {
+		EmptyString string    `json:"emptyString,omitempty"`
+		ZeroInt     int       `json:"zeroInt,omitempty"`
+		FalseValue  bool      `json:"falseValue,omitempty"`
+		ZeroTime    time.Time `json:"zeroTime,omitempty"`
+		ValidString string    `json:"validString,omitempty"`
+		ValidInt    int       `json:"validInt,omitempty"`
+		TrueValue   bool      `json:"trueValue,omitempty"`
+	}
+
+	testStruct := TestStruct{
+		EmptyString: "",
+		ZeroInt:     0,
+		FalseValue:  false,
+		ZeroTime:    time.Time{},
+		ValidString: "test",
+		ValidInt:    42,
+		TrueValue:   true,
+	}
+
+	result := encoder.toAMFCompatible(testStruct)
+	resultMap, ok := result.(map[string]interface{})
+	if !ok {
+		t.Fatal("Expected map[string]interface{}")
+	}
+
+	// Should be omitted (zero values)
+	omittedFields := []string{"emptyString", "zeroInt", "falseValue", "zeroTime"}
+	for _, field := range omittedFields {
+		if _, exists := resultMap[field]; exists {
+			t.Errorf("Field %s should be omitted (zero value)", field)
+		}
+	}
+
+	// Should be present (non-zero values)
+	presentFields := map[string]interface{}{
+		"validString": "test",
+		"validInt":    42,
+		"trueValue":   true,
+	}
+	for field, expected := range presentFields {
+		if actual, exists := resultMap[field]; !exists {
+			t.Errorf("Field %s should be present", field)
+		} else if actual != expected {
+			t.Errorf("Field %s: expected %v, got %v", field, expected, actual)
+		}
+	}
+}

+ 197 - 0
server/webapi/handlers/auth.go

@@ -0,0 +1,197 @@
+package handlers
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/base64"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/state"
+)
+
+// AuthHandler handles Web AIM API authentication endpoints.
+type AuthHandler struct {
+	UserManager UserManager
+	TokenStore  TokenStore
+	Logger      *slog.Logger
+	DisableAuth bool
+}
+
+// UserManager defines methods for user authentication.
+type UserManager interface {
+	// AuthenticateUser verifies username and password
+	AuthenticateUser(ctx context.Context, username, password string) (*state.User, error)
+	// FindUserByScreenName finds a user by their screen name
+	FindUserByScreenName(ctx context.Context, screenName state.IdentScreenName) (*state.User, error)
+	// InsertUser creates a new user (for DISABLE_AUTH mode)
+	InsertUser(ctx context.Context, u state.User) error
+}
+
+// TokenStore manages authentication tokens.
+type TokenStore interface {
+	// StoreToken saves an authentication token for a user
+	StoreToken(ctx context.Context, token string, screenName state.IdentScreenName, expiresAt time.Time) error
+	// ValidateToken checks if a token is valid and returns the associated screen name
+	ValidateToken(ctx context.Context, token string) (state.IdentScreenName, error)
+	// DeleteToken removes a token
+	DeleteToken(ctx context.Context, token string) error
+}
+
+// ClientLoginRequest represents the request body for clientLogin.
+type ClientLoginRequest struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+	DevID    string `json:"devId"`
+}
+
+// ClientLogin handles POST /auth/clientLogin requests.
+// This endpoint authenticates users and returns an authentication token.
+func (h *AuthHandler) ClientLogin(w http.ResponseWriter, r *http.Request) {
+	var username, password, devID string
+
+	// Check Content-Type to determine how to parse the request
+	contentType := r.Header.Get("Content-Type")
+
+	if contentType == "application/json" {
+		// Parse JSON body
+		var req ClientLoginRequest
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			h.Logger.Error("failed to parse JSON clientLogin request", "error", err)
+			SendError(w, http.StatusBadRequest, "invalid JSON format")
+			return
+		}
+		username = req.Username
+		password = req.Password
+		devID = req.DevID
+	} else {
+		// Parse form-encoded or URL parameters
+		if err := r.ParseForm(); err != nil {
+			h.Logger.Error("failed to parse form data", "error", err)
+			SendError(w, http.StatusBadRequest, "invalid form data")
+			return
+		}
+
+		// Try form values first, then fall back to query parameters
+		username = r.FormValue("s")
+		if username == "" {
+			username = r.FormValue("username")
+		}
+		password = r.FormValue("pwd")
+		if password == "" {
+			password = r.FormValue("password")
+		}
+		devID = r.FormValue("devId")
+
+		h.Logger.Debug("form-encoded login attempt",
+			"username", username,
+			"has_password", password != "",
+			"devId", devID,
+			"form", r.Form)
+	}
+
+	// Validate required fields
+	if username == "" || password == "" {
+		SendError(w, http.StatusBadRequest, "username and password required")
+		return
+	}
+
+	// Authenticate user
+	user, err := h.UserManager.AuthenticateUser(r.Context(), username, password)
+	if err != nil {
+		// If DISABLE_AUTH is enabled and user doesn't exist, create the user
+		if h.DisableAuth && err.Error() == "user not found" {
+			h.Logger.Info("DISABLE_AUTH: Creating new user",
+				"username", username)
+
+			// Create new user with the provided username
+			newUser := state.User{
+				IdentScreenName:   state.NewIdentScreenName(username),
+				DisplayScreenName: state.DisplayScreenName(username),
+			}
+
+			// Insert the new user
+			if err := h.UserManager.InsertUser(r.Context(), newUser); err != nil {
+				h.Logger.Error("failed to create user",
+					"username", username,
+					"error", err)
+				SendError(w, http.StatusInternalServerError, "failed to create user")
+				return
+			}
+
+			// Try to authenticate again after creating the user
+			user, err = h.UserManager.AuthenticateUser(r.Context(), username, password)
+			if err != nil {
+				h.Logger.Error("failed to authenticate after creating user",
+					"username", username,
+					"error", err)
+				SendError(w, http.StatusInternalServerError, "internal server error")
+				return
+			}
+		} else {
+			h.Logger.Warn("authentication failed",
+				"username", username,
+				"error", err)
+			SendError(w, http.StatusUnauthorized, "authentication failed")
+			return
+		}
+	}
+
+	// Generate authentication token
+	token, err := h.generateToken()
+	if err != nil {
+		h.Logger.Error("failed to generate token", "error", err)
+		SendError(w, http.StatusInternalServerError, "internal server error")
+		return
+	}
+
+	// Store token with 24 hour expiry
+	expiresAt := time.Now().Add(24 * time.Hour)
+	if err := h.TokenStore.StoreToken(r.Context(), token, user.IdentScreenName, expiresAt); err != nil {
+		h.Logger.Error("failed to store token", "error", err)
+		SendError(w, http.StatusInternalServerError, "internal server error")
+		return
+	}
+
+	// Generate session secret (for signing subsequent requests)
+	sessionSecret, err := h.generateToken()
+	if err != nil {
+		h.Logger.Error("failed to generate session secret", "error", err)
+		SendError(w, http.StatusInternalServerError, "internal server error")
+		return
+	}
+
+	// Build response
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = map[string]interface{}{
+		"token": map[string]interface{}{
+			"a":         token,
+			"expiresIn": 86400, // 24 hours in seconds
+		},
+		"loginId":        string(user.DisplayScreenName),
+		"screenName":     string(user.DisplayScreenName),
+		"sessionSecret":  sessionSecret,
+		"hostTime":       time.Now().Unix(),
+		"tokenExpiresIn": 86400, // 24 hours in seconds
+	}
+
+	// Send response in requested format (JSON, JSONP, XML, or AMF)
+	SendResponse(w, r, resp, h.Logger)
+
+	h.Logger.Info("user authenticated successfully",
+		"username", username,
+		"screenName", user.DisplayScreenName)
+}
+
+// generateToken generates a secure random token.
+func (h *AuthHandler) generateToken() (string, error) {
+	b := make([]byte, 32)
+	if _, err := rand.Read(b); err != nil {
+		return "", err
+	}
+	return base64.URLEncoding.EncodeToString(b), nil
+}

+ 293 - 0
server/webapi/handlers/buddy_list_manager.go

@@ -0,0 +1,293 @@
+package handlers
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// BuddyListManager handles the conversion of OSCAR feedbag data
+// to WebAPI buddy list format for web clients.
+type BuddyListManager struct {
+	feedbagRetriever FeedbagRetriever
+	sessionRetriever SessionRetriever
+	logger           *slog.Logger
+}
+
+// NewBuddyListManager creates a new instance of the buddy list manager.
+func NewBuddyListManager(feedbagRetriever FeedbagRetriever, sessionRetriever SessionRetriever, logger *slog.Logger) *BuddyListManager {
+	return &BuddyListManager{
+		feedbagRetriever: feedbagRetriever,
+		sessionRetriever: sessionRetriever,
+		logger:           logger,
+	}
+}
+
+// WebAPIBuddyGroup represents a group in the WebAPI buddy list format.
+type WebAPIBuddyGroup struct {
+	Name    string            `json:"name"`
+	Buddies []WebAPIBuddyInfo `json:"buddies"`
+	Recent  bool              `json:"recent,omitempty"`
+	Smart   interface{}       `json:"smart,omitempty"` // Can be null or number
+}
+
+// WebAPIBuddyInfo represents a buddy in the WebAPI format.
+type WebAPIBuddyInfo struct {
+	AimID        string   `json:"aimId"`
+	DisplayID    string   `json:"displayId"`
+	State        string   `json:"state"` // "online", "offline", "away", "idle"
+	StatusMsg    string   `json:"statusMsg,omitempty"`
+	AwayMsg      string   `json:"awayMsg,omitempty"`
+	OnlineTime   int64    `json:"onlineTime,omitempty"`
+	IdleTime     int      `json:"idleTime,omitempty"` // Minutes idle
+	UserType     string   `json:"userType"`           // "aim", "icq", "admin"
+	Bot          bool     `json:"bot"`
+	Service      string   `json:"service,omitempty"` // "aim", "icq"
+	PresenceIcon string   `json:"presenceIcon,omitempty"`
+	BuddyIcon    string   `json:"buddyIcon,omitempty"`
+	Capabilities []string `json:"capabilities,omitempty"`
+	MemberSince  int64    `json:"memberSince,omitempty"`
+}
+
+// GetBuddyListForUser retrieves and converts the buddy list for a user.
+func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, screenName state.IdentScreenName) ([]WebAPIBuddyGroup, error) {
+	// Retrieve feedbag items
+	items, err := m.feedbagRetriever.RetrieveFeedbag(ctx, screenName)
+	if err != nil {
+		return nil, fmt.Errorf("failed to retrieve feedbag: %w", err)
+	}
+
+	// Build group map
+	groupMap := make(map[uint16]string)
+	buddyGroupMap := make(map[uint16][]wire.FeedbagItem)
+
+	for _, item := range items {
+		switch item.ClassID {
+		case wire.FeedbagClassIdGroup:
+			// Store group name
+			groupMap[item.ItemID] = item.Name
+			buddyGroupMap[item.ItemID] = []wire.FeedbagItem{}
+		case wire.FeedbagClassIdBuddy:
+			// Add buddy to its group
+			if _, exists := buddyGroupMap[item.GroupID]; !exists {
+				// Create implicit group if it doesn't exist
+				buddyGroupMap[item.GroupID] = []wire.FeedbagItem{}
+			}
+			buddyGroupMap[item.GroupID] = append(buddyGroupMap[item.GroupID], item)
+		}
+	}
+
+	// Convert to WebAPI format
+	var groups []WebAPIBuddyGroup
+
+	// Add online group (virtual group for online buddies)
+	onlineGroup := WebAPIBuddyGroup{
+		Name:    "Online",
+		Buddies: []WebAPIBuddyInfo{},
+	}
+
+	// Process each group
+	for groupID, buddyItems := range buddyGroupMap {
+		groupName := groupMap[groupID]
+		if groupName == "" {
+			groupName = "Buddies" // Default group name
+		}
+
+		group := WebAPIBuddyGroup{
+			Name:    groupName,
+			Buddies: []WebAPIBuddyInfo{},
+		}
+
+		// Process buddies in this group
+		for _, buddyItem := range buddyItems {
+			buddyInfo := m.getBuddyInfo(buddyItem.Name)
+
+			// Add to online group if buddy is online
+			if buddyInfo.State == "online" || buddyInfo.State == "away" || buddyInfo.State == "idle" {
+				onlineGroup.Buddies = append(onlineGroup.Buddies, buddyInfo)
+			}
+
+			group.Buddies = append(group.Buddies, buddyInfo)
+		}
+
+		// Only add group if it has buddies
+		if len(group.Buddies) > 0 {
+			groups = append(groups, group)
+		}
+	}
+
+	// Add online group at the beginning if it has buddies
+	if len(onlineGroup.Buddies) > 0 {
+		groups = append([]WebAPIBuddyGroup{onlineGroup}, groups...)
+	}
+
+	// Always add an "Offline" group at the end for offline buddies
+	offlineGroup := WebAPIBuddyGroup{
+		Name:    "Offline",
+		Buddies: []WebAPIBuddyInfo{},
+	}
+
+	// Collect all offline buddies
+	for _, group := range groups {
+		if group.Name != "Online" {
+			for _, buddy := range group.Buddies {
+				if buddy.State == "offline" {
+					offlineGroup.Buddies = append(offlineGroup.Buddies, buddy)
+				}
+			}
+		}
+	}
+
+	if len(offlineGroup.Buddies) > 0 {
+		groups = append(groups, offlineGroup)
+	}
+
+	return groups, nil
+}
+
+// getBuddyInfo retrieves the current presence information for a buddy.
+func (m *BuddyListManager) getBuddyInfo(buddyName string) WebAPIBuddyInfo {
+	// Default to offline
+	info := WebAPIBuddyInfo{
+		AimID:     buddyName,
+		DisplayID: buddyName,
+		State:     "offline",
+		UserType:  "aim",
+		Bot:       false,
+		Service:   "aim",
+	}
+
+	// Check if buddy is online
+	buddyScreenName := state.NewIdentScreenName(buddyName)
+	session := m.sessionRetriever.RetrieveSession(buddyScreenName)
+
+	if session != nil {
+		// Buddy is online
+		info.State = "online"
+		info.OnlineTime = session.SignonTime().Unix()
+
+		// Check away status
+		if session.AwayMessage() != "" {
+			info.State = "away"
+			info.AwayMsg = session.AwayMessage()
+		}
+
+		// Check idle status
+		if session.Idle() {
+			idleDuration := time.Since(session.IdleTime())
+			info.IdleTime = int(idleDuration.Minutes())
+			if info.State == "online" {
+				info.State = "idle"
+			}
+		}
+
+		// Status messages not currently supported in Session
+
+		// Set capabilities
+		// Capabilities parsing not implemented
+		info.Capabilities = []string{}
+	}
+
+	return info
+}
+
+// GetPresenceForBuddy retrieves presence information for a specific buddy.
+func (m *BuddyListManager) GetPresenceForBuddy(screenName string) WebAPIBuddyInfo {
+	return m.getBuddyInfo(screenName)
+}
+
+// GetOnlineBuddies returns a list of all online buddies for a user.
+func (m *BuddyListManager) GetOnlineBuddies(ctx context.Context, userScreenName state.IdentScreenName) ([]WebAPIBuddyInfo, error) {
+	// Get user's buddy list
+	items, err := m.feedbagRetriever.RetrieveFeedbag(ctx, userScreenName)
+	if err != nil {
+		return nil, fmt.Errorf("failed to retrieve feedbag: %w", err)
+	}
+
+	var onlineBuddies []WebAPIBuddyInfo
+
+	// Check each buddy's presence
+	for _, item := range items {
+		if item.ClassID == wire.FeedbagClassIdBuddy {
+			buddyInfo := m.getBuddyInfo(item.Name)
+			if buddyInfo.State != "offline" {
+				onlineBuddies = append(onlineBuddies, buddyInfo)
+			}
+		}
+	}
+
+	return onlineBuddies, nil
+}
+
+// FormatBuddyListEvent formats a buddy list for an event.
+func (m *BuddyListManager) FormatBuddyListEvent(groups []WebAPIBuddyGroup) map[string]interface{} {
+	// Convert groups to a format that AMF3 can properly encode
+	// AMF3 has trouble with complex struct slices, so convert to maps
+	groupMaps := make([]interface{}, len(groups))
+	for i, group := range groups {
+		buddyMaps := make([]interface{}, len(group.Buddies))
+		for j, buddy := range group.Buddies {
+			// Convert each buddy to a map
+			buddyMap := map[string]interface{}{
+				"aimId":     buddy.AimID,
+				"displayId": buddy.DisplayID,
+				"state":     buddy.State,
+				"userType":  buddy.UserType,
+				"bot":       buddy.Bot,
+				"service":   buddy.Service,
+			}
+
+			// Add optional fields if present
+			if buddy.StatusMsg != "" {
+				buddyMap["statusMsg"] = buddy.StatusMsg
+			}
+			if buddy.AwayMsg != "" {
+				buddyMap["awayMsg"] = buddy.AwayMsg
+			}
+			if buddy.OnlineTime > 0 {
+				buddyMap["onlineTime"] = float64(buddy.OnlineTime)
+			}
+			if buddy.IdleTime > 0 {
+				buddyMap["idleTime"] = buddy.IdleTime
+			}
+			if buddy.PresenceIcon != "" {
+				buddyMap["presenceIcon"] = buddy.PresenceIcon
+			}
+			if buddy.BuddyIcon != "" {
+				buddyMap["buddyIcon"] = buddy.BuddyIcon
+			}
+			if len(buddy.Capabilities) > 0 {
+				buddyMap["capabilities"] = buddy.Capabilities
+			}
+			if buddy.MemberSince > 0 {
+				buddyMap["memberSince"] = float64(buddy.MemberSince)
+			}
+
+			buddyMaps[j] = buddyMap
+		}
+
+		// Convert group to a map
+		groupMap := map[string]interface{}{
+			"name":    group.Name,
+			"buddies": buddyMaps,
+		}
+
+		// Add optional group fields
+		if group.Recent {
+			groupMap["recent"] = group.Recent
+		}
+		if group.Smart != nil {
+			groupMap["smart"] = group.Smart
+		}
+
+		groupMaps[i] = groupMap
+	}
+
+	return map[string]interface{}{
+		"groups": groupMaps,
+	}
+}

+ 353 - 0
server/webapi/handlers/buddyfeed.go

@@ -0,0 +1,353 @@
+package handlers
+
+import (
+	"encoding/xml"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/state"
+)
+
+// BuddyFeedHandler handles Web AIM API buddy feed endpoints.
+type BuddyFeedHandler struct {
+	SessionManager   *state.WebAPISessionManager
+	FeedManager      *state.BuddyFeedManager
+	SessionRetriever SessionRetriever
+	Logger           *slog.Logger
+}
+
+// GetUser handles GET /buddyfeed/getUser requests to retrieve a user's feed.
+func (h *BuddyFeedHandler) GetUser(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get target user from 't' parameter as per spec
+	targetUser := r.URL.Query().Get("t")
+	if targetUser == "" {
+		SendError(w, http.StatusBadRequest, "missing 't' parameter")
+		return
+	}
+
+	// Get format parameter
+	format := strings.ToLower(r.URL.Query().Get("f"))
+
+	h.Logger.DebugContext(ctx, "retrieving user feed",
+		"user", targetUser,
+		"format", format,
+	)
+
+	// Get the feed configuration
+	feed, err := h.FeedManager.GetUserFeed(ctx, targetUser)
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "failed to get user feed",
+			"user", targetUser,
+			"error", err,
+		)
+		SendError(w, http.StatusInternalServerError, "failed to retrieve feed")
+		return
+	}
+
+	// Build feed response
+	var feedResponse *FeedResponse
+	if feed == nil {
+		// No feed configured - generate empty feed
+		feedResponse = GenerateEmptyFeed(targetUser)
+	} else {
+		// Get feed items
+		items, err := h.FeedManager.GetFeedItems(ctx, feed.ID, 50)
+		if err != nil {
+			h.Logger.ErrorContext(ctx, "failed to get feed items",
+				"feedID", feed.ID,
+				"error", err,
+			)
+			SendError(w, http.StatusInternalServerError, "failed to retrieve feed items")
+			return
+		}
+
+		feedResponse = &FeedResponse{
+			Feed:  *feed,
+			Items: items,
+		}
+	}
+
+	// Send response based on format
+	switch format {
+	case "atom":
+		h.sendAtomFeed(w, feedResponse.ToAtom())
+	case "json":
+		response := BaseResponse{}
+		response.Response.StatusCode = 200
+		response.Response.StatusText = "OK"
+		response.Response.Data = feedResponse.ToJSON()
+		SendResponse(w, r, response, h.Logger)
+	case "rss", "native", "":
+		h.sendRSSFeed(w, feedResponse.ToRSS())
+	default:
+		h.sendRSSFeed(w, feedResponse.ToRSS())
+	}
+}
+
+// GetBuddylist handles GET /buddyfeed/getBuddylist requests to retrieve aggregated buddy feeds.
+func (h *BuddyFeedHandler) GetBuddylist(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Authentication required for buddy list feed
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		SendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Get session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		SendError(w, http.StatusUnauthorized, "invalid or expired session")
+		return
+	}
+
+	// Update session activity
+	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
+		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
+	}
+
+	// Get format and limit parameters
+	format := strings.ToLower(r.URL.Query().Get("f"))
+	if format == "" {
+		format = "rss"
+	}
+
+	limit := 100 // Default limit
+	if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+		if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
+			limit = l
+			if limit > 500 {
+				limit = 500 // Max limit
+			}
+		}
+	}
+
+	h.Logger.DebugContext(ctx, "retrieving buddy list feed",
+		"screenName", session.ScreenName.String(),
+		"format", format,
+		"limit", limit,
+	)
+
+	// Get buddy list from OSCAR session if available
+	var buddies []state.IdentScreenName
+
+	// Return empty feed for now - buddy list integration pending
+	if len(buddies) == 0 {
+		h.Logger.InfoContext(ctx, "no buddies found for feed aggregation",
+			"screenName", session.ScreenName.String(),
+		)
+
+		// Return empty feed
+		emptyFeed := map[string]interface{}{
+			"title":       fmt.Sprintf("%s's Buddy Feed", session.ScreenName.String()),
+			"description": "Aggregated feed from your buddy list",
+			"items":       []interface{}{},
+		}
+
+		if format == "json" {
+			response := BaseResponse{}
+			response.Response.StatusCode = 200
+			response.Response.StatusText = "OK"
+			response.Response.Data = emptyFeed
+			SendResponse(w, r, response, h.Logger)
+		} else {
+			h.sendEmptyRSSFeed(w, session.ScreenName.String())
+		}
+		return
+	}
+
+	// Get aggregated feed items
+	items, err := h.FeedManager.GetBuddyListFeedItems(ctx, buddies, limit)
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "failed to get buddy list feed",
+			"screenName", session.ScreenName.String(),
+			"error", err,
+		)
+		SendError(w, http.StatusInternalServerError, "failed to retrieve feed")
+		return
+	}
+
+	// Build aggregated feed response
+	feedResponse := &FeedResponse{
+		Feed: state.BuddyFeed{
+			Title:       "Buddy List Feed",
+			Description: "Aggregated feed from your buddy list",
+			Link:        "/buddyfeed/getBuddylist",
+			PublishedAt: time.Now(),
+			UpdatedAt:   time.Now(),
+		},
+		Items: items,
+	}
+
+	// Send response based on format
+	switch format {
+	case "atom":
+		h.sendAtomFeed(w, feedResponse.ToAtom())
+	case "json":
+		response := BaseResponse{}
+		response.Response.StatusCode = 200
+		response.Response.StatusText = "OK"
+		response.Response.Data = feedResponse.ToJSON()
+		SendResponse(w, r, response, h.Logger)
+	case "rss", "":
+		h.sendRSSFeed(w, feedResponse.ToRSS())
+	default:
+		h.sendRSSFeed(w, feedResponse.ToRSS())
+	}
+}
+
+// PushFeed handles GET /buddyfeed/pushFeed requests to submit feed updates.
+func (h *BuddyFeedHandler) PushFeed(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get authentication token or session
+	token := r.URL.Query().Get("a")
+	aimsid := r.URL.Query().Get("aimsid")
+
+	if token == "" && aimsid == "" {
+		SendError(w, http.StatusBadRequest, "authentication required")
+		return
+	}
+
+	var screenName string
+	if aimsid != "" {
+		session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+		if err != nil {
+			SendError(w, http.StatusUnauthorized, "invalid or expired session")
+			return
+		}
+		screenName = session.ScreenName.String()
+
+		if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
+			h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
+		}
+	} else {
+		// Extract screen name from token authentication
+		screenName = r.URL.Query().Get("s")
+		if screenName == "" {
+			SendError(w, http.StatusBadRequest, "missing source user")
+			return
+		}
+	}
+
+	// Extract feed parameters as per spec
+	feedTitle := r.URL.Query().Get("feedTitle")
+	feedLink := r.URL.Query().Get("feedLink")
+	feedDesc := r.URL.Query().Get("feedDesc")
+	itemTitle := r.URL.Query().Get("itemTitle")
+	itemLink := r.URL.Query().Get("itemLink")
+	itemGuid := r.URL.Query().Get("itemGuid")
+
+	// Validate required parameters
+	if feedTitle == "" || feedLink == "" || feedDesc == "" ||
+		itemTitle == "" || itemLink == "" || itemGuid == "" {
+		SendError(w, http.StatusBadRequest, "missing required feed parameters")
+		return
+	}
+
+	h.Logger.InfoContext(ctx, "pushing feed update",
+		"screenName", screenName,
+		"itemTitle", itemTitle,
+	)
+
+	// Get or create feed for user
+	feedID, err := h.FeedManager.GetOrCreateFeedForUser(ctx, screenName, "status")
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "failed to get/create feed",
+			"screenName", screenName,
+			"error", err,
+		)
+		SendError(w, http.StatusInternalServerError, "failed to get/create feed")
+		return
+	}
+
+	// Build feed item
+	item := state.BuddyFeedItem{
+		Title:       itemTitle,
+		Description: r.URL.Query().Get("itemDesc"),
+		Link:        itemLink,
+		GUID:        itemGuid,
+		Author:      screenName,
+		PublishedAt: time.Now(),
+	}
+
+	// Add category if provided
+	if category := r.URL.Query().Get("itemCategory"); category != "" {
+		item.Categories = []string{category}
+	}
+
+	// Add the feed item
+	if _, err := h.FeedManager.AddFeedItem(ctx, feedID, item); err != nil {
+		h.Logger.ErrorContext(ctx, "failed to add feed item",
+			"screenName", screenName,
+			"feedID", feedID,
+			"error", err,
+		)
+		SendError(w, http.StatusInternalServerError, "failed to add feed item")
+		return
+	}
+
+	// Send success response
+	response := BaseResponse{}
+	response.Response.StatusCode = 200
+	response.Response.StatusText = "OK"
+	response.Response.Data = map[string]interface{}{
+		"success": true,
+	}
+
+	SendResponse(w, r, response, h.Logger)
+}
+
+// sendRSSFeed sends an RSS feed response.
+func (h *BuddyFeedHandler) sendRSSFeed(w http.ResponseWriter, feed *RSSFeed) {
+	w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
+
+	// Add XML declaration
+	w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>`))
+
+	// Marshal and write the feed
+	encoder := xml.NewEncoder(w)
+	encoder.Indent("", "  ")
+	if err := encoder.Encode(feed); err != nil {
+		h.Logger.Error("failed to encode RSS feed", "error", err)
+	}
+}
+
+// sendAtomFeed sends an Atom feed response.
+func (h *BuddyFeedHandler) sendAtomFeed(w http.ResponseWriter, feed *AtomFeed) {
+	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
+
+	// Add XML declaration
+	w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>`))
+
+	// Marshal and write the feed
+	encoder := xml.NewEncoder(w)
+	encoder.Indent("", "  ")
+	if err := encoder.Encode(feed); err != nil {
+		h.Logger.Error("failed to encode Atom feed", "error", err)
+	}
+}
+
+// sendEmptyRSSFeed sends an empty RSS feed.
+func (h *BuddyFeedHandler) sendEmptyRSSFeed(w http.ResponseWriter, screenName string) {
+	w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
+
+	emptyFeed := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+  <channel>
+    <title>%s's Buddy Feed</title>
+    <description>Aggregated feed from your buddy list</description>
+    <link>/buddyfeed/getBuddylist</link>
+    <language>en-US</language>
+  </channel>
+</rss>`, screenName)
+
+	w.Write([]byte(emptyFeed))
+}

+ 195 - 0
server/webapi/handlers/buddylist.go

@@ -0,0 +1,195 @@
+package handlers
+
+import (
+	"context"
+	"log/slog"
+	"net/http"
+	"strings"
+
+	"github.com/mk6i/retro-aim-server/server/webapi/types"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// BuddyListHandler handles Web AIM API buddy list management endpoints.
+type BuddyListHandler struct {
+	SessionManager *state.WebAPISessionManager
+	FeedbagManager FeedbagManager
+	Logger         *slog.Logger
+}
+
+// FeedbagManager provides methods to manage buddy lists.
+type FeedbagManager interface {
+	RetrieveFeedbag(ctx context.Context, screenName state.IdentScreenName) ([]wire.FeedbagItem, error)
+	InsertItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error
+	UpdateItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error
+	DeleteItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error
+}
+
+// AddBuddy handles GET /buddylist/addBuddy requests.
+func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get session ID from parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Get session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		if err == state.ErrNoWebAPISession {
+			h.sendError(w, http.StatusNotFound, "session not found")
+		} else if err == state.ErrWebAPISessionExpired {
+			h.sendError(w, http.StatusGone, "session expired")
+		} else {
+			h.sendError(w, http.StatusInternalServerError, "internal server error")
+		}
+		return
+	}
+
+	// Touch the session
+	h.SessionManager.TouchSession(r.Context(), aimsid)
+
+	// Get buddy and group parameters
+	buddyName := strings.TrimSpace(r.URL.Query().Get("buddy"))
+	groupName := strings.TrimSpace(r.URL.Query().Get("group"))
+
+	if buddyName == "" {
+		h.sendError(w, http.StatusBadRequest, "missing buddy parameter")
+		return
+	}
+
+	if groupName == "" {
+		groupName = "Buddies" // Default group
+	}
+
+	// Add buddy to feedbag
+	resultCode, buddyInfo := h.addBuddyToFeedbag(ctx, session.ScreenName.IdentScreenName(), buddyName, groupName)
+
+	// Prepare response
+	responseData := map[string]interface{}{
+		"resultCode": resultCode,
+	}
+	if resultCode == "success" {
+		responseData["buddyInfo"] = buddyInfo
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = responseData
+	SendResponse(w, r, resp, h.Logger)
+
+	if resultCode == "success" && session.EventQueue != nil {
+		// Push buddy list update event to the session's event queue
+		event := types.BuddyListEvent{
+			Action: "add",
+			Buddy:  buddyInfo,
+			Group:  groupName,
+		}
+		session.EventQueue.Push(types.EventTypeBuddyList, event)
+	}
+
+	h.Logger.InfoContext(ctx, "buddy added",
+		"aimsid", aimsid,
+		"buddy", buddyName,
+		"group", groupName,
+		"result", resultCode,
+	)
+}
+
+// addBuddyToFeedbag adds a buddy to the user's feedbag.
+func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, screenName state.IdentScreenName, buddyName, groupName string) (string, *BuddyPresenceInfo) {
+	// Retrieve current feedbag
+	items, err := h.FeedbagManager.RetrieveFeedbag(ctx, screenName)
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "failed to retrieve feedbag", "err", err.Error())
+		return "error", nil
+	}
+
+	// Check if buddy already exists
+	for _, item := range items {
+		if item.ClassID == wire.FeedbagClassIdBuddy && item.Name == buddyName {
+			// Buddy already exists
+			return "alreadyExists", nil
+		}
+	}
+
+	// Find or create the group
+	var groupID uint16
+	groupFound := false
+	maxGroupID := uint16(0)
+
+	for _, item := range items {
+		if item.ClassID == wire.FeedbagClassIdGroup {
+			if item.ItemID > maxGroupID {
+				maxGroupID = item.ItemID
+			}
+
+			// Check group name
+			if item.Name == groupName {
+				groupID = item.ItemID
+				groupFound = true
+			}
+		}
+	}
+
+	// If group doesn't exist, create it
+	if !groupFound {
+		groupID = maxGroupID + 1
+		groupItem := wire.FeedbagItem{
+			ItemID:    groupID,
+			ClassID:   wire.FeedbagClassIdGroup,
+			Name:      groupName,
+			GroupID:   0,
+			TLVLBlock: wire.TLVLBlock{},
+		}
+
+		if err := h.FeedbagManager.InsertItem(ctx, screenName, groupItem); err != nil {
+			h.Logger.ErrorContext(ctx, "failed to create group", "err", err.Error())
+			return "error", nil
+		}
+	}
+
+	// Find next available item ID for buddy
+	maxBuddyID := uint16(0)
+	for _, item := range items {
+		if item.ClassID == wire.FeedbagClassIdBuddy && item.ItemID > maxBuddyID {
+			maxBuddyID = item.ItemID
+		}
+	}
+
+	// Create buddy item
+	buddyItem := wire.FeedbagItem{
+		ItemID:    maxBuddyID + 1,
+		ClassID:   wire.FeedbagClassIdBuddy,
+		Name:      buddyName,
+		GroupID:   groupID,
+		TLVLBlock: wire.TLVLBlock{},
+	}
+
+	// Insert buddy into feedbag
+	if err := h.FeedbagManager.InsertItem(ctx, screenName, buddyItem); err != nil {
+		h.Logger.ErrorContext(ctx, "failed to add buddy", "err", err.Error())
+		return "error", nil
+	}
+
+	// Get current presence for the buddy
+	buddyInfo := &BuddyPresenceInfo{
+		AimID:    buddyName,
+		State:    "offline", // Default to offline
+		UserType: "aim",
+	}
+
+	// TODO: Check actual presence status and update buddyInfo accordingly
+
+	return "success", buddyInfo
+}
+
+// sendError is a convenience method that wraps the common SendError function.
+func (h *BuddyListHandler) sendError(w http.ResponseWriter, statusCode int, message string) {
+	SendError(w, statusCode, message)
+}

+ 319 - 0
server/webapi/handlers/chat.go

@@ -0,0 +1,319 @@
+package handlers
+
+import (
+	"encoding/json"
+	"errors"
+	"log/slog"
+	"net/http"
+	"strings"
+
+	"github.com/mk6i/retro-aim-server/state"
+)
+
+// ChatHandler handles Web API chat endpoints
+type ChatHandler struct {
+	SessionManager *state.WebAPISessionManager
+	ChatManager    *state.WebAPIChatManager
+	Logger         *slog.Logger
+}
+
+// CreateAndJoinChat creates (if needed) and joins a chat room
+// GET /chat/createAndJoinChat
+func (h *ChatHandler) CreateAndJoinChat(w http.ResponseWriter, r *http.Request) {
+	// Extract parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	roomID := r.URL.Query().Get("roomId")
+	roomName := r.URL.Query().Get("roomName")
+
+	// Validate session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		h.Logger.Error("invalid session", "aimsid", aimsid, "error", err)
+		SendError(w, http.StatusUnauthorized, "Authentication Required")
+		return
+	}
+
+	// Validate parameters - exactly one of roomId or roomName must be provided
+	if (roomID == "" && roomName == "") || (roomID != "" && roomName != "") {
+		SendError(w, http.StatusBadRequest, "Exactly one of roomId or roomName must be provided")
+		return
+	}
+
+	// Create or join the chat room
+	chatSession, room, err := h.ChatManager.CreateAndJoinChat(r.Context(), aimsid, roomID, roomName, string(session.ScreenName))
+	if err != nil {
+		h.Logger.Error("failed to create/join chat", "error", err, "aimsid", aimsid)
+
+		// Determine appropriate error code
+		statusCode := http.StatusInternalServerError
+		message := "Internal Server Error"
+		if strings.Contains(err.Error(), "maximum capacity") {
+			statusCode = http.StatusServiceUnavailable
+			message = "Room is at maximum capacity"
+		} else if strings.Contains(err.Error(), "must be provided") {
+			statusCode = http.StatusBadRequest
+			message = err.Error()
+		}
+
+		SendError(w, statusCode, message)
+		return
+	}
+
+	// Build response
+	roomData := map[string]interface{}{
+		"roomName":    room.RoomName,
+		"roomId":      room.RoomID,
+		"instanceId":  room.InstanceID,
+		"description": room.Description,
+		"roomType":    string(room.RoomType),
+	}
+
+	// Add category ID if present
+	if room.CategoryID != "" {
+		roomData["categoryId"] = room.CategoryID
+	}
+
+	response := BaseResponse{
+		Response: ResponseBody{
+			StatusCode: 200,
+			StatusText: "OK",
+			Data: map[string]interface{}{
+				"chatsid": chatSession.ChatSID,
+				"room":    roomData,
+			},
+		},
+	}
+
+	// Send response
+	SendResponse(w, r, response, h.Logger)
+
+	h.Logger.Info("user joined chat room",
+		"screenName", session.ScreenName,
+		"roomName", room.RoomName,
+		"roomID", room.RoomID,
+		"chatsid", chatSession.ChatSID)
+}
+
+// SendMessage sends a message to a chat room
+// GET /chat/sendMessage
+func (h *ChatHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
+	// Extract parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	chatsid := r.URL.Query().Get("chatsid")
+	message := r.URL.Query().Get("message")
+	whisperTarget := r.URL.Query().Get("whisperTarget")
+
+	// Validate session
+	_, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		h.Logger.Error("invalid session", "aimsid", aimsid, "error", err)
+		SendError(w, http.StatusUnauthorized, "Authentication Required")
+		return
+	}
+
+	// Validate required parameters
+	if chatsid == "" {
+		SendError(w, http.StatusBadRequest, "chatsid is required")
+		return
+	}
+
+	if message == "" {
+		SendError(w, http.StatusBadRequest, "message is required")
+		return
+	}
+
+	// Send the message
+	err = h.ChatManager.SendMessage(r.Context(), chatsid, message, whisperTarget)
+	if err != nil {
+		h.Logger.Error("failed to send message", "error", err, "chatsid", chatsid)
+
+		// Determine appropriate error code
+		statusCode := http.StatusInternalServerError
+		message := "Internal Server Error"
+		if strings.Contains(err.Error(), "invalid chat session") || strings.Contains(err.Error(), "user has left") {
+			statusCode = http.StatusNotFound
+			message = "Chat session not found"
+		}
+
+		SendError(w, statusCode, message)
+		return
+	}
+
+	// Build response
+	response := BaseResponse{
+		Response: ResponseBody{
+			StatusCode: 200,
+			StatusText: "OK",
+			Data:       map[string]interface{}{},
+		},
+	}
+
+	// Send response
+	SendResponse(w, r, response, h.Logger)
+
+	logMsg := "message sent to chat room"
+	if whisperTarget != "" {
+		logMsg = "whisper sent in chat room"
+	}
+	h.Logger.Debug(logMsg, "chatsid", chatsid, "whisperTarget", whisperTarget)
+}
+
+// SetTyping sets typing status for a chat room
+// GET /chat/setTyping
+func (h *ChatHandler) SetTyping(w http.ResponseWriter, r *http.Request) {
+	// Extract parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	chatsid := r.URL.Query().Get("chatsid")
+	typingStatus := r.URL.Query().Get("typingStatus")
+
+	// Validate session
+	_, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		h.Logger.Error("invalid session", "aimsid", aimsid, "error", err)
+		SendError(w, http.StatusUnauthorized, "Authentication Required")
+		return
+	}
+
+	// Validate required parameters
+	if chatsid == "" {
+		SendError(w, http.StatusBadRequest, "chatsid is required")
+		return
+	}
+
+	if typingStatus == "" {
+		SendError(w, http.StatusBadRequest, "typingStatus is required")
+		return
+	}
+
+	// Validate typing status value
+	validStatuses := map[string]bool{
+		"none":   true,
+		"typing": true,
+		"typed":  true,
+	}
+	if !validStatuses[typingStatus] {
+		SendError(w, http.StatusBadRequest, "Invalid typingStatus value")
+		return
+	}
+
+	// Set typing status
+	err = h.ChatManager.SetTyping(r.Context(), chatsid, typingStatus)
+	if err != nil {
+		h.Logger.Error("failed to set typing status", "error", err, "chatsid", chatsid)
+
+		// Determine appropriate error code
+		statusCode := http.StatusInternalServerError
+		errMessage := "Internal Server Error"
+		if strings.Contains(err.Error(), "invalid chat session") || strings.Contains(err.Error(), "user has left") {
+			statusCode = http.StatusNotFound
+			errMessage = "Chat session not found"
+		}
+
+		SendError(w, statusCode, errMessage)
+		return
+	}
+
+	// Build response
+	response := BaseResponse{
+		Response: ResponseBody{
+			StatusCode: 200,
+			StatusText: "OK",
+			Data:       map[string]interface{}{},
+		},
+	}
+
+	// Send response
+	SendResponse(w, r, response, h.Logger)
+
+	h.Logger.Debug("typing status updated", "chatsid", chatsid, "status", typingStatus)
+}
+
+// LeaveChat leaves the current chat room
+// GET /chat/leaveChat
+func (h *ChatHandler) LeaveChat(w http.ResponseWriter, r *http.Request) {
+	// Extract parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	chatsid := r.URL.Query().Get("chatsid")
+
+	// Validate session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		h.Logger.Error("invalid session", "aimsid", aimsid, "error", err)
+		SendError(w, http.StatusUnauthorized, "Authentication Required")
+		return
+	}
+
+	// Validate required parameters
+	if chatsid == "" {
+		SendError(w, http.StatusBadRequest, "chatsid is required")
+		return
+	}
+
+	// Leave the chat room
+	err = h.ChatManager.LeaveChat(r.Context(), chatsid)
+	if err != nil {
+		h.Logger.Error("failed to leave chat", "error", err, "chatsid", chatsid)
+
+		// Determine appropriate error code
+		statusCode := http.StatusInternalServerError
+		message := "Internal Server Error"
+		if strings.Contains(err.Error(), "invalid chat session") {
+			statusCode = http.StatusNotFound
+			message = "Chat session not found"
+		}
+
+		SendError(w, statusCode, message)
+		return
+	}
+
+	// Build response
+	response := BaseResponse{
+		Response: ResponseBody{
+			StatusCode: 200,
+			StatusText: "OK",
+			Data:       map[string]interface{}{},
+		},
+	}
+
+	// Send response
+	SendResponse(w, r, response, h.Logger)
+
+	h.Logger.Info("user left chat room",
+		"screenName", session.ScreenName,
+		"chatsid", chatsid)
+}
+
+// Helper to validate and convert typed JSON data for chat events
+func validateChatEventData(data json.RawMessage, eventType string) (interface{}, error) {
+	switch eventType {
+	case "message":
+		var msgData state.ChatMessageEventData
+		if err := json.Unmarshal(data, &msgData); err != nil {
+			return nil, err
+		}
+		return msgData, nil
+	case "userEntered", "userLeft":
+		var userData state.ChatUserEventData
+		if err := json.Unmarshal(data, &userData); err != nil {
+			return nil, err
+		}
+		return userData, nil
+	case "typing":
+		var typingData state.ChatTypingEventData
+		if err := json.Unmarshal(data, &typingData); err != nil {
+			return nil, err
+		}
+		return typingData, nil
+	case "userInRoom":
+		var participantData state.ChatParticipantList
+		if err := json.Unmarshal(data, &participantData); err != nil {
+			return nil, err
+		}
+		return participantData, nil
+	case "closed":
+		// No additional data for closed event
+		return nil, nil
+	default:
+		return nil, errors.New("unknown chat event type")
+	}
+}

+ 395 - 0
server/webapi/handlers/common.go

@@ -0,0 +1,395 @@
+package handlers
+
+import (
+	"context"
+	"encoding/hex"
+	"encoding/json"
+	"encoding/xml"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// SessionRetriever provides methods to retrieve OSCAR sessions.
+type SessionRetriever interface {
+	AllSessions() []*state.Session
+	RetrieveSession(screenName state.IdentScreenName) *state.Session
+}
+
+// FeedbagRetriever provides methods to retrieve feedbag data.
+type FeedbagRetriever interface {
+	RetrieveFeedbag(ctx context.Context, screenName state.IdentScreenName) ([]wire.FeedbagItem, error)
+	RelationshipsByUser(ctx context.Context, screenName state.IdentScreenName) ([]state.IdentScreenName, error)
+}
+
+// CommonHandler provides shared utilities for all Web API handlers.
+type CommonHandler struct {
+	Logger *slog.Logger
+}
+
+// BaseResponse is the standard response envelope for all Web API responses.
+// It supports both JSON and XML marshaling.
+type BaseResponse struct {
+	XMLName  xml.Name     `xml:"response" json:"-"`
+	Response ResponseBody `json:"response"`
+}
+
+// ResponseBody contains the status and data for API responses.
+type ResponseBody struct {
+	StatusCode int         `json:"statusCode" xml:"statusCode"`
+	StatusText string      `json:"statusText" xml:"statusText"`
+	Data       interface{} `json:"data,omitempty" xml:"data,omitempty"`
+}
+
+// ErrorResponse represents an error response with proper XML/JSON support.
+type ErrorResponse struct {
+	XMLName  xml.Name `xml:"response" json:"-"`
+	Response struct {
+		StatusCode int    `json:"statusCode" xml:"statusCode"`
+		StatusText string `json:"statusText" xml:"statusText"`
+	} `json:"response" xml:"-"`
+	// For XML responses, flatten the structure
+	StatusCode int    `json:"-" xml:"statusCode"`
+	StatusText string `json:"-" xml:"statusText"`
+}
+
+// XMLMapResponse is a helper struct for converting map-based responses to XML
+type XMLMapResponse struct {
+	XMLName    xml.Name `xml:"response"`
+	StatusCode int      `xml:"statusCode"`
+	StatusText string   `xml:"statusText"`
+	Data       XMLData  `xml:"data,omitempty"`
+}
+
+// XMLData wraps the data for XML responses
+type XMLData struct {
+	// Auth response fields
+	Token          *XMLToken `xml:"token,omitempty"`
+	LoginID        string    `xml:"loginId,omitempty"`
+	ScreenName     string    `xml:"screenName,omitempty"`
+	SessionSecret  string    `xml:"sessionSecret,omitempty"`
+	HostTime       int64     `xml:"hostTime,omitempty"`
+	TokenExpiresIn int       `xml:"tokenExpiresIn,omitempty"`
+
+	// Generic fields for other responses
+	AimSID   string `xml:"aimsid,omitempty"`
+	FetchURL string `xml:"fetchUrl,omitempty"`
+	MsgID    string `xml:"msgId,omitempty"`
+	State    string `xml:"state,omitempty"`
+
+	// For any other data, we'll encode as string
+	Raw string `xml:",chardata"`
+}
+
+// XMLToken represents the token structure in XML
+type XMLToken struct {
+	A         string `xml:"a"`
+	ExpiresIn int    `xml:"expiresIn"`
+}
+
+// SendResponse sends a response in the requested format (JSON, JSONP, XML, or AMF).
+// This is the centralized function that all handlers should use for responses.
+func SendResponse(w http.ResponseWriter, r *http.Request, data interface{}, logger *slog.Logger) {
+	// Check for format parameter (f for format or callback for JSONP)
+	// First check URL query parameters
+	format := strings.ToLower(r.URL.Query().Get("f"))
+	callback := r.URL.Query().Get("callback")
+
+	// If format not in URL query, check form values (for POST requests)
+	if format == "" && r.Method == "POST" {
+		r.ParseForm()
+		format = strings.ToLower(r.FormValue("f"))
+		if callback == "" {
+			callback = r.FormValue("callback")
+		}
+	}
+
+	// Check for AMF format first
+	if format == "amf" || format == "amf3" {
+		SendAMF(w, r, data, logger)
+		return
+	}
+
+	// Check Accept header for AMF
+	accept := strings.ToLower(r.Header.Get("Accept"))
+	if strings.Contains(accept, "application/x-amf") ||
+		strings.Contains(accept, "application/amf") {
+		SendAMF(w, r, data, logger)
+		return
+	}
+
+	// If callback is provided, it's JSONP
+	if callback != "" {
+		SendJSONP(w, callback, data, logger)
+		return
+	}
+
+	// Check for XML format
+	if format == "xml" {
+		SendXML(w, data, logger)
+		return
+	}
+
+	// Default to JSON
+	SendJSON(w, data, logger)
+}
+
+// SendError sends an error response in the appropriate format.
+func SendError(w http.ResponseWriter, statusCode int, message string) {
+	// Try to detect format from Content-Type header if already set
+	contentType := w.Header().Get("Content-Type")
+
+	if strings.Contains(contentType, "amf") {
+		SendAMFError(w, nil, statusCode, message, nil)
+	} else if strings.Contains(contentType, "xml") {
+		SendXMLError(w, statusCode, message)
+	} else {
+		SendJSONError(w, statusCode, message)
+	}
+}
+
+// SendJSONError sends a JSON error response.
+func SendJSONError(w http.ResponseWriter, statusCode int, message string) {
+	resp := ErrorResponse{}
+	resp.Response.StatusCode = statusCode
+	resp.Response.StatusText = message
+
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(statusCode)
+	json.NewEncoder(w).Encode(resp)
+}
+
+// SendXMLError sends an XML error response.
+func SendXMLError(w http.ResponseWriter, statusCode int, message string) {
+	resp := ErrorResponse{}
+	resp.StatusCode = statusCode
+	resp.StatusText = message
+
+	w.Header().Set("Content-Type", "text/xml; charset=utf-8")
+	w.WriteHeader(statusCode)
+
+	// Write XML declaration and marshal the response
+	xmlData, err := xml.Marshal(resp)
+	if err != nil {
+		// Fall back to simple text response
+		http.Error(w, message, statusCode)
+		return
+	}
+
+	xmlOutput := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>%s`, xmlData)
+	w.Write([]byte(xmlOutput))
+}
+
+// SendJSON sends a JSON response.
+func SendJSON(w http.ResponseWriter, data interface{}, logger *slog.Logger) {
+	w.Header().Set("Content-Type", "application/json")
+	if err := json.NewEncoder(w).Encode(data); err != nil {
+		if logger != nil {
+			logger.Error("failed to encode JSON response", "err", err.Error())
+		}
+	}
+}
+
+// SendXML sends an XML response.
+func SendXML(w http.ResponseWriter, data interface{}, logger *slog.Logger) {
+	w.Header().Set("Content-Type", "text/xml; charset=utf-8")
+
+	// Convert BaseResponse with map data to a format XML can handle
+	if baseResp, ok := data.(BaseResponse); ok {
+		data = convertBaseResponseForXML(baseResp)
+	}
+
+	// Marshal the data
+	xmlData, err := xml.Marshal(data)
+	if err != nil {
+		if logger != nil {
+			logger.Error("failed to marshal XML response", "err", err.Error())
+		}
+		SendXMLError(w, http.StatusInternalServerError, "internal server error")
+		return
+	}
+
+	// Write XML declaration and data
+	xmlOutput := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>%s`, xmlData)
+
+	// Set content length for proper response handling
+	w.Header().Set("Content-Length", strconv.Itoa(len(xmlOutput)))
+	w.Write([]byte(xmlOutput))
+}
+
+// SendJSONP sends a JSONP response with the specified callback.
+func SendJSONP(w http.ResponseWriter, callback string, data interface{}, logger *slog.Logger) {
+	// Validate callback to prevent XSS
+	if !IsValidCallback(callback) {
+		SendJSONError(w, http.StatusBadRequest, "invalid callback parameter")
+		return
+	}
+
+	jsonData, err := json.Marshal(data)
+	if err != nil {
+		if logger != nil {
+			logger.Error("failed to marshal response", "err", err.Error())
+		}
+		SendJSONError(w, http.StatusInternalServerError, "internal server error")
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/javascript")
+	w.Write([]byte(callback))
+	w.Write([]byte("("))
+	w.Write(jsonData)
+	w.Write([]byte(");"))
+}
+
+// IsValidCallback validates a JSONP callback name to prevent XSS.
+func IsValidCallback(callback string) bool {
+	if len(callback) == 0 || len(callback) > 100 {
+		return false
+	}
+
+	// Allow alphanumeric, underscore, dollar sign, and dot (for namespace)
+	for _, r := range callback {
+		if !((r >= 'a' && r <= 'z') ||
+			(r >= 'A' && r <= 'Z') ||
+			(r >= '0' && r <= '9') ||
+			r == '_' || r == '$' || r == '.') {
+			return false
+		}
+	}
+
+	return true
+}
+
+// SendAMF sends an AMF response
+func SendAMF(w http.ResponseWriter, r *http.Request, data interface{}, logger *slog.Logger) {
+	encoder := NewAMFEncoder(logger)
+	version := DetectAMFVersion(r)
+
+	amfData, err := encoder.EncodeAMF(data, version)
+	if err != nil {
+		if logger != nil {
+			logger.Error("failed to encode AMF response",
+				"err", err.Error(),
+				"version", version,
+				"dataType", fmt.Sprintf("%T", data))
+		}
+		// Fall back to JSON error
+		SendJSONError(w, http.StatusInternalServerError, "AMF encoding failed")
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/x-amf")
+	w.Header().Set("Content-Length", strconv.Itoa(len(amfData)))
+
+	// Debug logging if enabled
+	if logger != nil && logger.Enabled(context.TODO(), slog.LevelDebug) {
+		hexPreview := ""
+		if len(amfData) > 0 {
+			previewLen := len(amfData)
+			if previewLen > 64 {
+				previewLen = 64
+			}
+			hexPreview = hex.EncodeToString(amfData[:previewLen])
+		}
+
+		logger.Debug("sending AMF response",
+			"version", version,
+			"size", len(amfData),
+			"path", r.URL.Path,
+			"hexPreview", hexPreview)
+	}
+
+	if _, err := w.Write(amfData); err != nil {
+		if logger != nil {
+			logger.Error("failed to write AMF response",
+				"err", err.Error())
+		}
+	}
+}
+
+// convertBaseResponseForXML converts a BaseResponse with map data to XMLMapResponse
+func convertBaseResponseForXML(resp BaseResponse) XMLMapResponse {
+	xmlResp := XMLMapResponse{
+		StatusCode: resp.Response.StatusCode,
+		StatusText: resp.Response.StatusText,
+	}
+
+	// Convert map data to XMLData struct
+	if dataMap, ok := resp.Response.Data.(map[string]interface{}); ok {
+		xmlData := XMLData{}
+
+		// Handle auth response fields
+		if tokenData, ok := dataMap["token"].(map[string]interface{}); ok {
+			xmlData.Token = &XMLToken{}
+			if a, ok := tokenData["a"].(string); ok {
+				xmlData.Token.A = a
+			}
+			if expiresIn, ok := tokenData["expiresIn"].(int); ok {
+				xmlData.Token.ExpiresIn = expiresIn
+			}
+		}
+
+		if loginId, ok := dataMap["loginId"].(string); ok {
+			xmlData.LoginID = loginId
+		}
+		if screenName, ok := dataMap["screenName"].(string); ok {
+			xmlData.ScreenName = screenName
+		}
+		if sessionSecret, ok := dataMap["sessionSecret"].(string); ok {
+			xmlData.SessionSecret = sessionSecret
+		}
+		if hostTime, ok := dataMap["hostTime"].(int64); ok {
+			xmlData.HostTime = hostTime
+		}
+		if tokenExpiresIn, ok := dataMap["tokenExpiresIn"].(int); ok {
+			xmlData.TokenExpiresIn = tokenExpiresIn
+		}
+
+		// Handle session response fields
+		if aimsid, ok := dataMap["aimsid"].(string); ok {
+			xmlData.AimSID = aimsid
+		}
+		if fetchUrl, ok := dataMap["fetchUrl"].(string); ok {
+			xmlData.FetchURL = fetchUrl
+		}
+
+		// Handle message response fields
+		if msgId, ok := dataMap["msgId"].(string); ok {
+			xmlData.MsgID = msgId
+		}
+		if state, ok := dataMap["state"].(string); ok {
+			xmlData.State = state
+		}
+
+		xmlResp.Data = xmlData
+	}
+
+	return xmlResp
+}
+
+// SendAMFError sends an AMF error response
+func SendAMFError(w http.ResponseWriter, r *http.Request, statusCode int, message string, logger *slog.Logger) {
+	errorResp := ErrorResponse{}
+	errorResp.Response.StatusCode = statusCode
+	errorResp.Response.StatusText = message
+
+	encoder := NewAMFEncoder(logger)
+	version := DetectAMFVersion(r)
+
+	amfData, err := encoder.EncodeAMF(errorResp, version)
+	if err != nil {
+		// If AMF encoding fails, fall back to JSON error
+		SendJSONError(w, statusCode, message)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/x-amf")
+	w.Header().Set("Content-Length", strconv.Itoa(len(amfData)))
+	w.WriteHeader(statusCode)
+	w.Write(amfData)
+}

+ 196 - 0
server/webapi/handlers/events.go

@@ -0,0 +1,196 @@
+package handlers
+
+import (
+	"context"
+	"encoding/xml"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/server/webapi/types"
+	"github.com/mk6i/retro-aim-server/state"
+)
+
+// EventsHandler handles Web AIM API event fetching endpoints.
+type EventsHandler struct {
+	SessionManager *state.WebAPISessionManager
+	Logger         *slog.Logger
+}
+
+// FetchEventsResponse represents the response for fetchEvents endpoint.
+type FetchEventsResponse struct {
+	Response struct {
+		StatusCode int             `json:"statusCode"`
+		StatusText string          `json:"statusText"`
+		Data       FetchEventsData `json:"data"`
+	} `json:"response"`
+}
+
+// FetchEventsData contains the events and metadata.
+type FetchEventsData struct {
+	Events          []types.Event `json:"events"`
+	LastSeqNum      uint64        `json:"lastSeqNum"`
+	TimeToNextFetch int           `json:"timeToNextFetch"`
+	FetchBaseURL    string        `json:"fetchBaseURL"`
+}
+
+// FetchEventsXMLResponse represents the XML response for fetchEvents endpoint.
+type FetchEventsXMLResponse struct {
+	XMLName    xml.Name `xml:"response"`
+	StatusCode int      `xml:"statusCode"`
+	StatusText string   `xml:"statusText"`
+	Data       struct {
+		Events          []types.Event `xml:"events>event"`
+		LastSeqNum      uint64        `xml:"lastSeqNum"`
+		TimeToNextFetch int           `xml:"timeToNextFetch"`
+		FetchBaseURL    string        `xml:"fetchBaseURL"`
+	} `xml:"data"`
+}
+
+// FetchEvents handles GET /aim/fetchEvents requests with long-polling support.
+func (h *EventsHandler) FetchEvents(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get session ID from parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Get session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		if err == state.ErrNoWebAPISession {
+			h.sendError(w, http.StatusNotFound, "session not found")
+		} else if err == state.ErrWebAPISessionExpired {
+			h.sendError(w, http.StatusGone, "session expired")
+		} else {
+			h.sendError(w, http.StatusInternalServerError, "internal server error")
+		}
+		return
+	}
+
+	// Touch the session to update last accessed time
+	h.SessionManager.TouchSession(r.Context(), aimsid)
+
+	// Get sequence number parameter
+	var lastSeqNum uint64
+	if seqStr := r.URL.Query().Get("seqNum"); seqStr != "" {
+		if val, err := strconv.ParseUint(seqStr, 10, 64); err == nil {
+			lastSeqNum = val
+		}
+	}
+
+	// Get timeout parameter (in seconds, convert to milliseconds)
+	timeout := time.Duration(session.FetchTimeout) * time.Millisecond
+	if timeoutStr := r.URL.Query().Get("timeout"); timeoutStr != "" {
+		if val, err := strconv.Atoi(timeoutStr); err == nil && val > 0 {
+			timeout = time.Duration(val) * time.Second
+		}
+	}
+
+	// Limit maximum timeout to 60 seconds
+	if timeout > 60*time.Second {
+		timeout = 60 * time.Second
+	}
+
+	// Create a context with timeout for the fetch operation
+	fetchCtx, cancel := context.WithTimeout(ctx, timeout)
+	defer cancel()
+
+	// Fetch events from the queue (will block until events available or timeout)
+	events, err := session.EventQueue.Fetch(fetchCtx, lastSeqNum, timeout)
+	if err != nil {
+		if err == context.DeadlineExceeded {
+			// Timeout is normal - return empty events array
+			events = []types.Event{}
+		} else {
+			h.Logger.ErrorContext(ctx, "failed to fetch events", "err", err.Error())
+			h.sendError(w, http.StatusInternalServerError, "failed to fetch events")
+			return
+		}
+	}
+
+	// Determine the last sequence number
+	var newLastSeqNum uint64 = lastSeqNum
+	if len(events) > 0 {
+		newLastSeqNum = events[len(events)-1].SeqNum
+	}
+
+	// Prepare response
+	resp := FetchEventsResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data.Events = events
+	resp.Response.Data.LastSeqNum = newLastSeqNum
+	resp.Response.Data.TimeToNextFetch = session.TimeToNextFetch
+	// Include fetchBaseURL with updated sequence number for next request
+	resp.Response.Data.FetchBaseURL = fmt.Sprintf("http://%s/aim/fetchEvents?aimsid=%s&seqNum=%d",
+		r.Host, aimsid, newLastSeqNum)
+
+	// Check response format
+	format := strings.ToLower(r.URL.Query().Get("f"))
+
+	if format == "xml" {
+		// Send XML response
+		xmlResp := FetchEventsXMLResponse{}
+		xmlResp.StatusCode = 200
+		xmlResp.StatusText = "OK"
+		xmlResp.Data.Events = events
+		xmlResp.Data.LastSeqNum = newLastSeqNum
+		xmlResp.Data.TimeToNextFetch = session.TimeToNextFetch
+		xmlResp.Data.FetchBaseURL = fmt.Sprintf("http://%s/aim/fetchEvents?aimsid=%s&seqNum=%d",
+			r.Host, aimsid, newLastSeqNum)
+
+		w.Header().Set("Content-Type", "text/xml")
+		fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>`)
+		if err := xml.NewEncoder(w).Encode(xmlResp); err != nil {
+			h.Logger.Error("failed to encode XML response", "error", err)
+		}
+	} else if format == "amf" || format == "amf3" {
+		// For AMF3, build the response with fields in the correct order
+		// The working implementation has: response { data {...}, statusCode, statusText, statusDetailCode }
+		// Convert events to ensure timestamps are float64 for AMF3
+		convertedEvents := ConvertEventsForAMF3(events)
+
+		amfResp := map[string]interface{}{
+			"response": map[string]interface{}{
+				// Data comes FIRST (Gromit processes this large object)
+				"data": map[string]interface{}{
+					"events":          convertedEvents,
+					"lastSeqNum":      newLastSeqNum,
+					"timeToNextFetch": session.TimeToNextFetch,
+					"fetchBaseURL": fmt.Sprintf("http://%s/aim/fetchEvents?aimsid=%s&seqNum=%d",
+						r.Host, aimsid, newLastSeqNum),
+				},
+				// Status fields come AFTER data
+				"statusCode":       200,
+				"statusText":       "OK",
+				"statusDetailCode": 0,
+			},
+		}
+
+		// Use SendResponse which will detect AMF format and encode properly
+		SendResponse(w, r, amfResp, h.Logger)
+	} else {
+		// Send JSON/JSONP response with standard structure
+		SendResponse(w, r, resp, h.Logger)
+	}
+
+	if len(events) > 0 {
+		h.Logger.DebugContext(ctx, "events fetched",
+			"aimsid", aimsid,
+			"count", len(events),
+			"last_seq", newLastSeqNum,
+		)
+	}
+}
+
+// sendError is a convenience method that wraps the common SendError function.
+func (h *EventsHandler) sendError(w http.ResponseWriter, statusCode int, message string) {
+	SendError(w, statusCode, message)
+}

+ 53 - 0
server/webapi/handlers/expressions.go

@@ -0,0 +1,53 @@
+package handlers
+
+import (
+	"log/slog"
+	"net/http"
+)
+
+// ExpressionsHandler handles Web AIM API expressions/buddy icon endpoints.
+type ExpressionsHandler struct {
+	Logger *slog.Logger
+}
+
+// NewExpressionsHandler creates a new ExpressionsHandler.
+func NewExpressionsHandler(logger *slog.Logger) *ExpressionsHandler {
+	return &ExpressionsHandler{
+		Logger: logger,
+	}
+}
+
+// Get handles GET /expressions/get requests for buddy icons and expressions.
+func (h *ExpressionsHandler) Get(w http.ResponseWriter, r *http.Request) {
+	// Parse parameters
+	format := r.URL.Query().Get("f")
+	targetUser := r.URL.Query().Get("t")
+	expressionType := r.URL.Query().Get("type")
+
+	h.Logger.Debug("expressions/get request",
+		"format", format,
+		"target", targetUser,
+		"type", expressionType,
+	)
+
+	// For redirect format, return a 404 (no buddy icon)
+	// In a real implementation, this would redirect to the actual icon URL
+	if format == "redirect" {
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+
+	// For other formats, return an empty response indicating no expressions
+	response := map[string]interface{}{
+		"response": map[string]interface{}{
+			"statusCode": 200,
+			"statusText": "OK",
+			"data": map[string]interface{}{
+				"expressions": []interface{}{},
+			},
+		},
+	}
+
+	// Send response in requested format
+	SendResponse(w, r, response, h.Logger)
+}

+ 232 - 0
server/webapi/handlers/feed_converter.go

@@ -0,0 +1,232 @@
+package handlers
+
+import (
+	"encoding/xml"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/state"
+)
+
+// FeedConverter handles conversion of feed data to various output formats.
+type FeedConverter struct{}
+
+// NewFeedConverter creates a new feed converter.
+func NewFeedConverter() *FeedConverter {
+	return &FeedConverter{}
+}
+
+// FeedResponse wraps feed data with conversion methods.
+type FeedResponse struct {
+	Feed  state.BuddyFeed       `json:"feed"`
+	Items []state.BuddyFeedItem `json:"items"`
+}
+
+// ToRSS converts the feed response to RSS format.
+func (fr *FeedResponse) ToRSS() *RSSFeed {
+	rss := &RSSFeed{
+		Version: "2.0",
+		Channel: RSSChannel{
+			Title:       fr.Feed.Title,
+			Link:        fr.Feed.Link,
+			Description: fr.Feed.Description,
+			Language:    "en-US",
+			PubDate:     fr.Feed.PublishedAt.Format(time.RFC1123Z),
+			Items:       make([]RSSItem, 0, len(fr.Items)),
+		},
+	}
+
+	for _, item := range fr.Items {
+		rssItem := RSSItem{
+			Title:       item.Title,
+			Link:        item.Link,
+			Description: item.Description,
+			Author:      item.Author,
+			Categories:  item.Categories,
+			GUID:        item.GUID,
+			PubDate:     item.PublishedAt.Format(time.RFC1123Z),
+		}
+		rss.Channel.Items = append(rss.Channel.Items, rssItem)
+	}
+
+	return rss
+}
+
+// ToAtom converts the feed response to Atom format.
+func (fr *FeedResponse) ToAtom() *AtomFeed {
+	atom := &AtomFeed{
+		Title:   fr.Feed.Title,
+		Link:    AtomLink{Href: fr.Feed.Link, Rel: "alternate"},
+		Updated: fr.Feed.UpdatedAt.Format(time.RFC3339),
+		ID:      fr.Feed.Link,
+		Author:  AtomAuthor{Name: fr.Feed.ScreenName},
+		Entries: make([]AtomEntry, 0, len(fr.Items)),
+	}
+
+	for _, item := range fr.Items {
+		entry := AtomEntry{
+			Title:     item.Title,
+			Link:      AtomLink{Href: item.Link},
+			ID:        item.GUID,
+			Updated:   item.PublishedAt.Format(time.RFC3339),
+			Published: item.PublishedAt.Format(time.RFC3339),
+			Author:    AtomAuthor{Name: item.Author},
+			Summary:   item.Description,
+			Content:   AtomContent{Type: "html", Text: item.Description},
+		}
+		atom.Entries = append(atom.Entries, entry)
+	}
+
+	return atom
+}
+
+// ToJSON converts the feed response to JSON format.
+func (fr *FeedResponse) ToJSON() map[string]interface{} {
+	jsonItems := make([]map[string]interface{}, 0, len(fr.Items))
+
+	for _, item := range fr.Items {
+		jsonItem := map[string]interface{}{
+			"id":          item.GUID,
+			"title":       item.Title,
+			"description": item.Description,
+			"link":        item.Link,
+			"author":      item.Author,
+			"categories":  item.Categories,
+			"published":   item.PublishedAt.Unix(),
+		}
+		jsonItems = append(jsonItems, jsonItem)
+	}
+
+	return map[string]interface{}{
+		"title":       fr.Feed.Title,
+		"description": fr.Feed.Description,
+		"link":        fr.Feed.Link,
+		"updated":     fr.Feed.UpdatedAt.Unix(),
+		"items":       jsonItems,
+	}
+}
+
+// RSS/Atom feed structures for XML output
+type RSSFeed struct {
+	XMLName xml.Name   `xml:"rss"`
+	Version string     `xml:"version,attr"`
+	Channel RSSChannel `xml:"channel"`
+}
+
+type RSSChannel struct {
+	Title       string    `xml:"title"`
+	Link        string    `xml:"link"`
+	Description string    `xml:"description"`
+	Language    string    `xml:"language,omitempty"`
+	PubDate     string    `xml:"pubDate,omitempty"`
+	Items       []RSSItem `xml:"item"`
+}
+
+type RSSItem struct {
+	Title       string   `xml:"title"`
+	Link        string   `xml:"link"`
+	Description string   `xml:"description"`
+	Author      string   `xml:"author,omitempty"`
+	Categories  []string `xml:"category,omitempty"`
+	GUID        string   `xml:"guid,omitempty"`
+	PubDate     string   `xml:"pubDate"`
+}
+
+type AtomFeed struct {
+	XMLName xml.Name    `xml:"http://www.w3.org/2005/Atom feed"`
+	Title   string      `xml:"title"`
+	Link    AtomLink    `xml:"link"`
+	Updated string      `xml:"updated"`
+	Author  AtomAuthor  `xml:"author,omitempty"`
+	ID      string      `xml:"id"`
+	Entries []AtomEntry `xml:"entry"`
+}
+
+type AtomLink struct {
+	Href string `xml:"href,attr"`
+	Rel  string `xml:"rel,attr,omitempty"`
+}
+
+type AtomAuthor struct {
+	Name string `xml:"name"`
+}
+
+type AtomEntry struct {
+	Title     string      `xml:"title"`
+	Link      AtomLink    `xml:"link"`
+	ID        string      `xml:"id"`
+	Updated   string      `xml:"updated"`
+	Published string      `xml:"published,omitempty"`
+	Author    AtomAuthor  `xml:"author,omitempty"`
+	Summary   string      `xml:"summary,omitempty"`
+	Content   AtomContent `xml:"content,omitempty"`
+}
+
+type AtomContent struct {
+	Type string `xml:"type,attr"`
+	Text string `xml:",chardata"`
+}
+
+// GenerateEmptyFeed creates an empty feed for users without configured feeds.
+func GenerateEmptyFeed(screenName string) *FeedResponse {
+	feed := state.BuddyFeed{
+		ScreenName:  screenName,
+		Title:       screenName + "'s Feed",
+		Description: "No updates from " + screenName,
+		Link:        "/buddyfeed/getUser?u=" + screenName,
+		PublishedAt: time.Now(),
+		UpdatedAt:   time.Now(),
+	}
+
+	return &FeedResponse{
+		Feed:  feed,
+		Items: []state.BuddyFeedItem{},
+	}
+}
+
+// BuildFeedData creates feed data map from request parameters.
+func BuildFeedData(params map[string]string) map[string]interface{} {
+	feedData := make(map[string]interface{})
+
+	// Required fields
+	if title, ok := params["itemTitle"]; ok {
+		feedData["title"] = title
+	}
+	if desc, ok := params["itemDesc"]; ok {
+		feedData["description"] = desc
+	}
+	if link, ok := params["itemLink"]; ok {
+		feedData["link"] = link
+	}
+	if guid, ok := params["itemGuid"]; ok {
+		feedData["guid"] = guid
+	}
+
+	// Feed metadata
+	if feedTitle, ok := params["feedTitle"]; ok {
+		feedData["feedTitle"] = feedTitle
+	}
+	if feedLink, ok := params["feedLink"]; ok {
+		feedData["feedLink"] = feedLink
+	}
+	if feedDesc, ok := params["feedDesc"]; ok {
+		feedData["feedDesc"] = feedDesc
+	}
+
+	// Optional fields
+	if publisher, ok := params["feedPublisher"]; ok && publisher != "" {
+		feedData["publisher"] = publisher
+	}
+	if pubDate, ok := params["itemPubDate"]; ok && pubDate != "" {
+		feedData["pubDate"] = pubDate
+	}
+	if category, ok := params["itemCategory"]; ok && category != "" {
+		feedData["categories"] = []string{category}
+	}
+
+	// Default type if not specified
+	if _, ok := feedData["type"]; !ok {
+		feedData["type"] = "status"
+	}
+
+	return feedData
+}

+ 416 - 0
server/webapi/handlers/messaging.go

@@ -0,0 +1,416 @@
+package handlers
+
+import (
+	"bytes"
+	"context"
+	"crypto/rand"
+	"encoding/binary"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"strconv"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/server/webapi/types"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// MessageRelayer defines methods for relaying messages between users
+type MessageRelayer interface {
+	RelayToScreenName(ctx context.Context, recipient state.IdentScreenName, msg wire.SNACMessage)
+}
+
+// OfflineMessageManager defines methods for managing offline messages
+type OfflineMessageManager interface {
+	SaveMessage(ctx context.Context, msg state.OfflineMessage) error
+}
+
+// RelationshipFetcher defines methods for fetching user relationships
+type RelationshipFetcher interface {
+	Relationship(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) (state.Relationship, error)
+}
+
+// MessagingHandler handles Web AIM API messaging endpoints
+type MessagingHandler struct {
+	SessionManager        *state.WebAPISessionManager
+	MessageRelayer        MessageRelayer
+	OfflineMessageManager OfflineMessageManager
+	SessionRetriever      SessionRetriever
+	RelationshipFetcher   RelationshipFetcher
+	Logger                *slog.Logger
+}
+
+// SendIM handles the /im/sendIM endpoint for sending instant messages
+func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get session from aimsid
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: aimsid")
+		return
+	}
+
+	sess, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		if err == state.ErrNoWebAPISession || err == state.ErrWebAPISessionExpired {
+			h.sendErrorResponse(w, http.StatusUnauthorized, "invalid or expired session")
+		} else {
+			h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
+		}
+		return
+	}
+
+	// Update session activity
+	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
+		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
+	}
+
+	// Parse parameters
+	recipient := r.URL.Query().Get("t")
+	if recipient == "" {
+		h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: t (recipient)")
+		return
+	}
+
+	message := r.URL.Query().Get("message")
+	if message == "" {
+		h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: message")
+		return
+	}
+
+	// Parse optional parameters
+	autoResponse := r.URL.Query().Get("autoResponse") == "1"
+	offlineIM := r.URL.Query().Get("offlineIM") != "0" // default to true
+
+	// Create recipient identifier
+	recipientIdent := state.NewIdentScreenName(recipient)
+
+	// Check blocking relationship
+	rel, err := h.RelationshipFetcher.Relationship(ctx, sess.ScreenName.IdentScreenName(), recipientIdent)
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "failed to fetch relationship", "error", err)
+		h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
+		return
+	}
+
+	// Check if sender blocks recipient or recipient blocks sender
+	if rel.BlocksYou {
+		// Recipient blocks sender - pretend recipient is offline
+		h.sendErrorResponse(w, http.StatusNotFound, "recipient is not online")
+		return
+	}
+	if rel.YouBlock {
+		// Sender has blocked recipient - cannot send message
+		h.sendErrorResponse(w, http.StatusForbidden, "cannot send message to blocked user")
+		return
+	}
+
+	// Check if recipient is online
+	recipientSession := h.SessionRetriever.RetrieveSession(recipientIdent)
+
+	// Generate message cookie
+	var cookie [8]byte
+	if _, err := rand.Read(cookie[:]); err != nil {
+		h.Logger.ErrorContext(ctx, "failed to generate message cookie", "error", err)
+		h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
+		return
+	}
+	cookieUint64 := binary.BigEndian.Uint64(cookie[:])
+
+	// Get sender's OSCAR session if available
+	var senderInfo wire.TLVUserInfo
+	if sess.OSCARSession != nil {
+		senderInfo = sess.OSCARSession.TLVUserInfo()
+	} else {
+		// Create minimal user info for web-only sessions
+		senderInfo = wire.TLVUserInfo{
+			ScreenName:   sess.ScreenName.String(),
+			WarningLevel: 0,
+		}
+		senderInfo.Append(wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(sess.CreatedAt.Unix())))
+		senderInfo.Append(wire.NewTLVBE(wire.OServiceUserInfoStatus, uint32(0x0000))) // online status
+	}
+
+	// Create message ID for response (UUID format like working implementation)
+	// Using the cookie bytes to generate a UUID-like string
+	messageID := fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
+		binary.BigEndian.Uint32(cookie[:4]),
+		binary.BigEndian.Uint16(cookie[4:6]),
+		binary.BigEndian.Uint16(cookie[6:8]),
+		binary.BigEndian.Uint16([]byte{0x80, 0x00}), // Version bits
+		time.Now().UnixNano()&0xffffffffffff)
+
+	if recipientSession == nil {
+		// Recipient is offline
+		if offlineIM {
+			// Save offline message
+			offlineMsg := state.OfflineMessage{
+				Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
+					Cookie:     cookieUint64,
+					ChannelID:  wire.ICBMChannelIM,
+					ScreenName: recipient,
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							wire.NewTLVBE(wire.ICBMTLVAOLIMData, h.encodeIMMessage(message, autoResponse)),
+							wire.NewTLVBE(wire.ICBMTLVStore, uint8(1)), // store offline
+						},
+					},
+				},
+				Recipient: recipientIdent,
+				Sender:    sess.ScreenName.IdentScreenName(),
+				Sent:      time.Now().UTC(),
+			}
+
+			if err := h.OfflineMessageManager.SaveMessage(ctx, offlineMsg); err != nil {
+				h.Logger.ErrorContext(ctx, "failed to save offline message",
+					"from", sess.ScreenName.String(),
+					"to", recipient,
+					"error", err)
+				h.sendErrorResponse(w, http.StatusInternalServerError, "failed to save offline message")
+				return
+			}
+
+			h.Logger.DebugContext(ctx, "saved offline message",
+				"from", sess.ScreenName.String(),
+				"to", recipient)
+		} else {
+			// Recipient is offline and offline delivery is disabled
+			h.sendErrorResponse(w, http.StatusNotFound, "recipient is not online")
+			return
+		}
+	} else {
+		// Recipient is online, deliver message
+		clientIM := wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
+			Cookie:       cookieUint64,
+			ChannelID:    wire.ICBMChannelIM,
+			TLVUserInfo:  senderInfo,
+			TLVRestBlock: wire.TLVRestBlock{},
+		}
+
+		// Add message data
+		clientIM.Append(wire.NewTLVBE(wire.ICBMTLVAOLIMData, h.encodeIMMessage(message, autoResponse)))
+
+		// Add auto-response flag if applicable
+		if autoResponse {
+			clientIM.Append(wire.NewTLVBE(wire.ICBMTLVAutoResponse, []byte{}))
+		}
+
+		// Send message to recipient
+		h.MessageRelayer.RelayToScreenName(ctx, recipientIdent, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.ICBM,
+				SubGroup:  wire.ICBMChannelMsgToClient,
+				RequestID: wire.ReqIDFromServer,
+			},
+			Body: clientIM,
+		})
+
+		// Queue IM event for the recipient's WebAPI session if they have one
+		if recipientWebSession, err := h.SessionManager.GetSessionByUser(r.Context(), recipientIdent); err == nil && recipientWebSession != nil {
+			eventData := types.IMEvent{
+				From:      sess.ScreenName.String(),
+				Message:   message,
+				Timestamp: float64(time.Now().Unix()),
+				AutoResp:  autoResponse,
+			}
+			recipientWebSession.EventQueue.Push(types.EventTypeIM, eventData)
+		}
+
+		// Also queue sentIM event for the sender's WebAPI session to show in their UI
+		senderEventData := types.SentIMEvent{
+			Sender: types.UserInfo{
+				AimID:     sess.ScreenName.String(),
+				DisplayID: sess.ScreenName.String(),
+				UserType:  "aim",
+			},
+			Dest: types.UserInfo{
+				AimID:     recipient,
+				DisplayID: recipient,
+				UserType:  "aim",
+			},
+			Message:   message,
+			Timestamp: float64(time.Now().Unix()),
+			AutoResp:  autoResponse,
+		}
+		sess.EventQueue.Push(types.EventTypeSentIM, senderEventData)
+
+		h.Logger.DebugContext(ctx, "queued sentIM event for sender",
+			"from", sess.ScreenName.String(),
+			"to", recipient,
+			"eventType", types.EventTypeSentIM,
+		)
+
+		h.Logger.DebugContext(ctx, "delivered instant message",
+			"from", sess.ScreenName.String(),
+			"to", recipient)
+	}
+
+	// Send success response
+	responseData := map[string]interface{}{
+		"msgId": messageID,
+		"state": "delivered",
+	}
+	response := BaseResponse{}
+	response.Response.StatusCode = 200
+	response.Response.StatusText = "OK"
+	response.Response.Data = responseData
+	SendResponse(w, r, response, h.Logger)
+}
+
+// encodeIMMessage encodes a text message into the OSCAR IM format
+func (h *MessagingHandler) encodeIMMessage(text string, autoResponse bool) []byte {
+	// Create ICBM fragment list for the message
+	frags, err := wire.ICBMFragmentList(text)
+	if err != nil {
+		// If fragment creation fails, return simple text bytes
+		return []byte(text)
+	}
+
+	// Marshal the fragments
+	buf := &bytes.Buffer{}
+	for _, frag := range frags {
+		if err := wire.MarshalBE(frag, buf); err != nil {
+			// If marshaling fails, return simple text bytes
+			return []byte(text)
+		}
+	}
+	return buf.Bytes()
+}
+
+// sendErrorResponse sends an error response in Web AIM API format
+func (h *MessagingHandler) sendErrorResponse(w http.ResponseWriter, statusCode int, errorText string) {
+	SendError(w, statusCode, errorText)
+}
+
+// SetTyping handles the /im/setTyping endpoint for typing indicators
+func (h *MessagingHandler) SetTyping(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get session from aimsid
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: aimsid")
+		return
+	}
+
+	sess, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		if err == state.ErrNoWebAPISession || err == state.ErrWebAPISessionExpired {
+			h.sendErrorResponse(w, http.StatusUnauthorized, "invalid or expired session")
+		} else {
+			h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
+		}
+		return
+	}
+
+	// Update session activity
+	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
+		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
+	}
+
+	// Parse parameters
+	recipient := r.URL.Query().Get("t")
+	if recipient == "" {
+		h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: t (recipient)")
+		return
+	}
+
+	typingStr := r.URL.Query().Get("typing")
+	typing := false
+	if typingStr != "" {
+		var err error
+		typing, err = strconv.ParseBool(typingStr)
+		if err != nil {
+			// Try numeric format (0/1)
+			typing = typingStr == "1"
+		}
+	}
+
+	// Create recipient identifier
+	recipientIdent := state.NewIdentScreenName(recipient)
+
+	// Check blocking relationship
+	rel, err := h.RelationshipFetcher.Relationship(ctx, sess.ScreenName.IdentScreenName(), recipientIdent)
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "failed to fetch relationship", "error", err)
+		h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
+		return
+	}
+
+	// Check if sender blocks recipient or recipient blocks sender
+	if rel.BlocksYou || rel.YouBlock {
+		// Either party blocks the other - silently succeed without sending notification
+		h.sendSuccessResponse(w, r, nil)
+		return
+	}
+
+	// Check if recipient is online
+	recipientSession := h.SessionRetriever.RetrieveSession(recipientIdent)
+	if recipientSession == nil {
+		// Silently succeed even if recipient is offline
+		h.sendSuccessResponse(w, r, nil)
+		return
+	}
+
+	// Generate typing notification cookie
+	var cookie [8]byte
+	if _, err := rand.Read(cookie[:]); err != nil {
+		h.Logger.ErrorContext(ctx, "failed to generate typing cookie", "error", err)
+		h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
+		return
+	}
+	cookieUint64 := binary.BigEndian.Uint64(cookie[:])
+
+	// Create typing notification
+	var notificationType uint16
+	if typing {
+		notificationType = 0x0002 // Typing started
+	} else {
+		notificationType = 0x0001 // Typing stopped
+	}
+
+	typingNotification := wire.SNAC_0x04_0x14_ICBMClientEvent{
+		Cookie:     cookieUint64,
+		ChannelID:  wire.ICBMChannelIM,
+		ScreenName: sess.ScreenName.String(),
+		Event:      notificationType,
+	}
+
+	// Send typing notification to recipient
+	h.MessageRelayer.RelayToScreenName(ctx, recipientIdent, wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.ICBM,
+			SubGroup:  wire.ICBMClientEvent,
+			RequestID: wire.ReqIDFromServer,
+		},
+		Body: typingNotification,
+	})
+
+	// Queue typing event for the recipient's WebAPI session if they have one
+	if recipientWebSession, err := h.SessionManager.GetSessionByUser(r.Context(), recipientIdent); err == nil && recipientWebSession != nil {
+		eventData := types.TypingEvent{
+			From:   sess.ScreenName.String(),
+			Typing: typing,
+		}
+		recipientWebSession.EventQueue.Push(types.EventTypeTyping, eventData)
+	}
+
+	h.Logger.DebugContext(ctx, "sent typing notification",
+		"from", sess.ScreenName.String(),
+		"to", recipient,
+		"typing", typing)
+
+	// Send success response
+	h.sendSuccessResponse(w, r, nil)
+}
+
+// sendSuccessResponse sends a success response in Web AIM API format
+func (h *MessagingHandler) sendSuccessResponse(w http.ResponseWriter, r *http.Request, data interface{}) {
+	response := BaseResponse{}
+	response.Response.StatusCode = 200
+	response.Response.StatusText = "OK"
+	response.Response.Data = data
+	SendResponse(w, r, response, h.Logger)
+}

+ 351 - 0
server/webapi/handlers/oscar_bridge.go

@@ -0,0 +1,351 @@
+package handlers
+
+import (
+	"bytes"
+	"context"
+	"encoding/hex"
+	"encoding/xml"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"strings"
+
+	"github.com/mk6i/retro-aim-server/server/webapi/middleware"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// OSCARBridgeHandler handles Web API to OSCAR protocol bridging endpoints.
+// This handler is responsible for creating a bridge between web-based clients
+// and the native OSCAR protocol, allowing web clients to connect to OSCAR services.
+type OSCARBridgeHandler struct {
+	SessionManager   *state.WebAPISessionManager
+	OSCARAuthService OSCARAuthService
+	CookieBaker      CookieBaker
+	BridgeStore      OSCARBridgeStore
+	Config           OSCARConfig
+	Logger           *slog.Logger
+}
+
+// OSCARAuthService defines methods needed for OSCAR authentication and session management.
+type OSCARAuthService interface {
+	// RegisterBOSSession creates a new BOS (Basic OSCAR Service) session
+	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.Session, error)
+	// RetrieveBOSSession retrieves an existing BOS session
+	RetrieveBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.Session, error)
+	// Signout ends an OSCAR session
+	Signout(ctx context.Context, sess *state.Session)
+}
+
+// CookieBaker issues and validates authentication cookies for OSCAR services.
+type CookieBaker interface {
+	// Issue creates a new authentication cookie from the given payload
+	Issue(data []byte) ([]byte, error)
+	// Crack verifies and decodes an authentication cookie
+	Crack(data []byte) ([]byte, error)
+}
+
+// OSCARBridgeStore manages the persistence of OSCAR bridge sessions.
+type OSCARBridgeStore interface {
+	// SaveBridgeSession stores the mapping between WebAPI and OSCAR sessions
+	SaveBridgeSession(ctx context.Context, webSessionID string, oscarCookie []byte, bosHost string, bosPort int) error
+	// GetBridgeSession retrieves bridge session details
+	GetBridgeSession(ctx context.Context, webSessionID string) (*state.OSCARBridgeSession, error)
+	// DeleteBridgeSession removes a bridge session
+	DeleteBridgeSession(ctx context.Context, webSessionID string) error
+}
+
+// OSCARConfig provides configuration for OSCAR services.
+type OSCARConfig interface {
+	// GetBOSAddress returns the BOS server address for client connections
+	GetBOSAddress() (host string, port int)
+	// GetSSLBOSAddress returns the SSL-enabled BOS server address
+	GetSSLBOSAddress() (host string, port int)
+	// IsSSLAvailable checks if SSL is configured for BOS connections
+	IsSSLAvailable() bool
+	// IsAuthDisabled returns whether authentication is disabled
+	IsAuthDisabled() bool
+}
+
+// StartOSCARSessionRequest represents the request parameters for startOSCARSession.
+type StartOSCARSessionRequest struct {
+	AimSID   string // WebAPI session ID
+	UseSSL   bool   // Whether to use SSL for the OSCAR connection
+	Compress bool   // Whether to use compression (not implemented)
+}
+
+// StartOSCARSessionResponse represents the response for startOSCARSession endpoint.
+type StartOSCARSessionResponse struct {
+	XMLName  xml.Name `xml:"response" json:"-"`
+	Response struct {
+		StatusCode int    `json:"statusCode" xml:"statusCode"`
+		StatusText string `json:"statusText" xml:"statusText"`
+		Data       struct {
+			Host        string `json:"host" xml:"host"`
+			Port        int    `json:"port" xml:"port"`
+			Cookie      string `json:"cookie" xml:"cookie"`
+			UseSSL      bool   `json:"useSSL" xml:"useSSL"`
+			Encryption  string `json:"encryption,omitempty" xml:"encryption,omitempty"`
+			Compression string `json:"compression,omitempty" xml:"compression,omitempty"`
+		} `json:"data" xml:"data"`
+	} `json:"response" xml:"-"`
+	// For XML responses, flatten the structure
+	StatusCode int    `json:"-" xml:"statusCode"`
+	StatusText string `json:"-" xml:"statusText"`
+	Data       struct {
+		Host        string `json:"-" xml:"host"`
+		Port        int    `json:"-" xml:"port"`
+		Cookie      string `json:"-" xml:"cookie"`
+		UseSSL      bool   `json:"-" xml:"useSSL"`
+		Encryption  string `json:"-" xml:"encryption,omitempty"`
+		Compression string `json:"-" xml:"compression,omitempty"`
+	} `json:"-" xml:"data"`
+}
+
+// StartOSCARSession handles GET /aim/startOSCARSession requests.
+// This endpoint creates a bridge between a WebAPI session and the native OSCAR protocol,
+// returning connection details that allow a web client to establish a direct OSCAR connection.
+//
+// The endpoint performs the following operations:
+// 1. Validates the WebAPI session
+// 2. Creates an OSCAR authentication cookie
+// 3. Optionally pre-registers a BOS session
+// 4. Returns connection details (host, port, cookie)
+//
+// Parameters:
+//   - aimsid: The WebAPI session ID (required)
+//   - useSSL: Whether to use SSL connection (optional, default: false)
+//   - compress: Whether to use compression (optional, not implemented)
+//   - f: Response format - "json" or "xml" (optional, default: "json")
+//
+// Returns:
+//   - 200 OK: Successfully created OSCAR session bridge
+//   - 400 Bad Request: Missing or invalid parameters
+//   - 401 Unauthorized: Invalid or expired WebAPI session
+//   - 500 Internal Server Error: Failed to create OSCAR session
+func (h *OSCARBridgeHandler) StartOSCARSession(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Log the request
+	h.Logger.InfoContext(ctx, "startOSCARSession requested",
+		"method", r.Method,
+		"remote_addr", r.RemoteAddr,
+		"user_agent", r.UserAgent())
+
+	// Get API key info from context (set by auth middleware)
+	apiKey, ok := ctx.Value(middleware.ContextKeyAPIKey).(*state.WebAPIKey)
+	if !ok {
+		h.Logger.Error("API key not found in context")
+		h.sendError(w, r, http.StatusInternalServerError, "internal server error")
+		return
+	}
+
+	// Verify that this API key has permission to create OSCAR sessions
+	if !h.hasOSCARBridgeCapability(apiKey) {
+		h.Logger.Warn("API key lacks OSCAR bridge capability",
+			"dev_id", apiKey.DevID)
+		h.sendError(w, r, http.StatusForbidden, "OSCAR bridge not enabled for this application")
+		return
+	}
+
+	// Parse request parameters
+	params := r.URL.Query()
+	aimsid := params.Get("aimsid")
+
+	if aimsid == "" {
+		h.Logger.Warn("missing aimsid parameter")
+		h.sendError(w, r, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Validate WebAPI session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		if err == state.ErrNoWebAPISession {
+			h.Logger.Warn("session not found", "aimsid", aimsid)
+			h.sendError(w, r, http.StatusNotFound, "session not found")
+		} else if err == state.ErrWebAPISessionExpired {
+			h.Logger.Warn("session expired", "aimsid", aimsid)
+			h.sendError(w, r, http.StatusGone, "session expired")
+		} else {
+			h.Logger.Error("failed to get session", "error", err)
+			h.sendError(w, r, http.StatusInternalServerError, "internal server error")
+		}
+		return
+	}
+
+	// Touch the session to update last access time
+	h.SessionManager.TouchSession(r.Context(), aimsid)
+
+	// Check if session already has an OSCAR bridge
+	if session.OSCARSession != nil {
+		h.Logger.Info("session already has OSCAR bridge",
+			"aimsid", aimsid,
+			"screen_name", session.ScreenName)
+		// Return existing connection details
+		h.returnExistingBridge(w, r, session)
+		return
+	}
+
+	// Parse optional parameters
+	useSSL := h.parseBoolParam(params.Get("useSSL"))
+	compress := h.parseBoolParam(params.Get("compress"))
+
+	// Check SSL availability if requested
+	if useSSL && !h.Config.IsSSLAvailable() {
+		h.Logger.Warn("SSL requested but not available")
+		h.sendError(w, r, http.StatusBadRequest, "SSL not available")
+		return
+	}
+
+	// Create OSCAR authentication cookie
+	cookie, err := h.createOSCARCookie(session)
+	if err != nil {
+		h.Logger.Error("failed to create OSCAR cookie",
+			"error", err,
+			"screen_name", session.ScreenName)
+		h.sendError(w, r, http.StatusInternalServerError, "failed to create authentication cookie")
+		return
+	}
+
+	// Get BOS server address
+	var host string
+	var port int
+	if useSSL {
+		host, port = h.Config.GetSSLBOSAddress()
+	} else {
+		host, port = h.Config.GetBOSAddress()
+	}
+
+	// Store bridge session in database
+	if h.BridgeStore != nil {
+		if err := h.BridgeStore.SaveBridgeSession(ctx, aimsid, cookie, host, port); err != nil {
+			h.Logger.Error("failed to save bridge session",
+				"error", err,
+				"aimsid", aimsid)
+			// Continue anyway - the bridge will work without persistence
+		}
+	}
+
+	// Prepare response
+	resp := h.buildResponse(host, port, cookie, useSSL, compress)
+
+	// Send response in requested format
+	h.sendResponse(w, r, resp)
+
+	h.Logger.InfoContext(ctx, "OSCAR session bridge created",
+		"aimsid", aimsid,
+		"screen_name", session.ScreenName,
+		"bos_host", host,
+		"bos_port", port,
+		"use_ssl", useSSL,
+		"compress", compress)
+}
+
+// createOSCARCookie generates an OSCAR authentication cookie for the session.
+func (h *OSCARBridgeHandler) createOSCARCookie(session *state.WebAPISession) ([]byte, error) {
+	// Create server cookie with session details
+	serverCookie := state.ServerCookie{
+		Service:       wire.BOS, // Basic OSCAR Service
+		ScreenName:    session.ScreenName,
+		ClientID:      fmt.Sprintf("WebAPI-%s", session.ClientName),
+		MultiConnFlag: 0, // Single connection
+	}
+
+	// Marshal the cookie to bytes
+	buf := &bytes.Buffer{}
+	if err := wire.MarshalBE(serverCookie, buf); err != nil {
+		return nil, fmt.Errorf("failed to marshal server cookie: %w", err)
+	}
+
+	// Issue the cookie with HMAC signature
+	cookie, err := h.CookieBaker.Issue(buf.Bytes())
+	if err != nil {
+		return nil, fmt.Errorf("failed to issue cookie: %w", err)
+	}
+
+	return cookie, nil
+}
+
+// hasOSCARBridgeCapability checks if the API key has permission to create OSCAR bridges.
+func (h *OSCARBridgeHandler) hasOSCARBridgeCapability(apiKey *state.WebAPIKey) bool {
+	if len(apiKey.Capabilities) == 0 {
+		return true // No restrictions if capabilities not specified
+	}
+
+	// Check if OSCAR bridge is explicitly enabled
+	for _, cap := range apiKey.Capabilities {
+		if cap == "oscar_bridge" || cap == "*" {
+			return true
+		}
+	}
+
+	return false
+}
+
+// parseBoolParam parses a boolean parameter from query string.
+func (h *OSCARBridgeHandler) parseBoolParam(value string) bool {
+	value = strings.ToLower(value)
+	return value == "true" || value == "1" || value == "yes"
+}
+
+// returnExistingBridge returns details for an existing OSCAR bridge.
+func (h *OSCARBridgeHandler) returnExistingBridge(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+	// Retrieve existing bridge details from store
+	if h.BridgeStore != nil {
+		bridge, err := h.BridgeStore.GetBridgeSession(r.Context(), session.AimSID)
+		if err == nil && bridge != nil {
+			resp := h.buildResponse(bridge.BOSHost, bridge.BOSPort, bridge.OSCARCookie, bridge.UseSSL, false)
+			h.sendResponse(w, r, resp)
+			return
+		}
+	}
+
+	// If we can't retrieve the bridge, return an error
+	h.sendError(w, r, http.StatusInternalServerError, "failed to retrieve existing bridge")
+}
+
+// buildResponse constructs the response object.
+func (h *OSCARBridgeHandler) buildResponse(host string, port int, cookie []byte, useSSL, compress bool) *StartOSCARSessionResponse {
+	resp := &StartOSCARSessionResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data.Host = host
+	resp.Response.Data.Port = port
+	resp.Response.Data.Cookie = hex.EncodeToString(cookie) // Hex encode the cookie
+	resp.Response.Data.UseSSL = useSSL
+
+	// Add encryption info if SSL is used
+	if useSSL {
+		resp.Response.Data.Encryption = "TLS"
+	}
+
+	// Add compression info if requested (not implemented)
+	if compress {
+		resp.Response.Data.Compression = "none" // Compression not implemented
+	}
+
+	// Duplicate data for XML format
+	resp.StatusCode = resp.Response.StatusCode
+	resp.StatusText = resp.Response.StatusText
+	resp.Data.Host = resp.Response.Data.Host
+	resp.Data.Port = resp.Response.Data.Port
+	resp.Data.Cookie = resp.Response.Data.Cookie
+	resp.Data.UseSSL = resp.Response.Data.UseSSL
+	resp.Data.Encryption = resp.Response.Data.Encryption
+	resp.Data.Compression = resp.Response.Data.Compression
+
+	return resp
+}
+
+// sendResponse sends the response in the requested format.
+func (h *OSCARBridgeHandler) sendResponse(w http.ResponseWriter, r *http.Request, resp *StartOSCARSessionResponse) {
+	// Use the centralized SendResponse function which handles all formats
+	SendResponse(w, r, resp, h.Logger)
+}
+
+// sendError sends an error response in the appropriate format.
+func (h *OSCARBridgeHandler) sendError(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
+	// SendError already detects format from Content-Type header
+	SendError(w, statusCode, message)
+}

+ 482 - 0
server/webapi/handlers/preference.go

@@ -0,0 +1,482 @@
+package handlers
+
+import (
+	"context"
+	"log/slog"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// PreferenceHandler handles Web AIM API preference-related endpoints.
+type PreferenceHandler struct {
+	SessionManager    *state.WebAPISessionManager
+	PreferenceManager PreferenceManager
+	PermitDenyManager PermitDenyManager
+	Logger            *slog.Logger
+}
+
+// PreferenceManager provides methods to manage user preferences.
+type PreferenceManager interface {
+	SetPreferences(ctx context.Context, screenName state.IdentScreenName, prefs map[string]interface{}) error
+	GetPreferences(ctx context.Context, screenName state.IdentScreenName) (map[string]interface{}, error)
+}
+
+// PermitDenyManager provides methods to manage permit/deny lists.
+type PermitDenyManager interface {
+	SetPDMode(ctx context.Context, screenName state.IdentScreenName, mode wire.FeedbagPDMode) error
+	GetPDMode(ctx context.Context, screenName state.IdentScreenName) (wire.FeedbagPDMode, error)
+	GetPermitList(ctx context.Context, screenName state.IdentScreenName) ([]state.IdentScreenName, error)
+	GetDenyList(ctx context.Context, screenName state.IdentScreenName) ([]state.IdentScreenName, error)
+	AddPermitBuddy(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) error
+	RemovePermitBuddy(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) error
+	AddDenyBuddy(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) error
+	RemoveDenyBuddy(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) error
+}
+
+// PermitDenyData contains permit/deny list information.
+type PermitDenyData struct {
+	PDMode     int      `json:"pdMode" xml:"pdMode"`
+	PermitList []string `json:"permitList,omitempty" xml:"permitList>user,omitempty"`
+	DenyList   []string `json:"denyList,omitempty" xml:"denyList>user,omitempty"`
+}
+
+// SetPreferences handles GET /preference/set requests to update user preferences.
+func (h *PreferenceHandler) SetPreferences(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get session ID from parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Get session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		h.sendError(w, http.StatusUnauthorized, "invalid or expired session")
+		return
+	}
+
+	// Update session activity
+	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
+		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
+	}
+
+	// Parse preferences from query parameters
+	prefs := make(map[string]interface{})
+
+	// Common preference keys from the Web AIM API spec
+	prefKeys := []string{
+		"statusMsg", "awayMsg", "profileMsg", "buddyIcon",
+		"soundsOn", "alertsOn", "typingStatus", "idleTime",
+		"pdMode", "invisibleTo", "visibleTo", "blockList",
+		"allowList", "language", "timeZone", "dateFormat",
+		"showTimestamps", "fontSize", "fontFamily", "theme",
+		"autoResponse", "saveHistory", "encryptMessages",
+	}
+
+	// Extract preferences from query parameters
+	for _, key := range prefKeys {
+		if val := r.URL.Query().Get(key); val != "" {
+			// Try to parse as boolean
+			if val == "true" || val == "false" {
+				prefs[key] = val == "true"
+			} else if num, err := strconv.Atoi(val); err == nil {
+				// Try to parse as integer
+				prefs[key] = num
+			} else {
+				// Store as string
+				prefs[key] = val
+			}
+		}
+	}
+
+	// Allow any other parameters starting with "pref_" for extensibility
+	for key, values := range r.URL.Query() {
+		if strings.HasPrefix(key, "pref_") && len(values) > 0 {
+			actualKey := strings.TrimPrefix(key, "pref_")
+			prefs[actualKey] = values[0]
+		}
+	}
+
+	// Save preferences
+	if err := h.PreferenceManager.SetPreferences(ctx, session.ScreenName.IdentScreenName(), prefs); err != nil {
+		h.Logger.ErrorContext(ctx, "failed to set preferences", "err", err.Error())
+		h.sendError(w, http.StatusInternalServerError, "failed to save preferences")
+		return
+	}
+
+	h.Logger.DebugContext(ctx, "preferences updated",
+		"screenName", session.ScreenName.String(),
+		"prefCount", len(prefs),
+	)
+
+	// Send success response
+	response := BaseResponse{}
+	response.Response.StatusCode = 200
+	response.Response.StatusText = "OK"
+	response.Response.Data = prefs
+	SendResponse(w, r, response, h.Logger)
+}
+
+// GetPreferences handles GET /preference/get requests to retrieve user preferences.
+func (h *PreferenceHandler) GetPreferences(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get session ID from parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Get session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		h.sendError(w, http.StatusUnauthorized, "invalid or expired session")
+		return
+	}
+
+	// Update session activity
+	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
+		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
+	}
+
+	// Get target user (optional, defaults to session user)
+	targetUser := session.ScreenName.IdentScreenName()
+	if t := r.URL.Query().Get("t"); t != "" {
+		targetUser = state.NewIdentScreenName(t)
+	}
+
+	// Get all stored preferences or defaults
+	allPrefs, err := h.PreferenceManager.GetPreferences(ctx, targetUser)
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "failed to get preferences", "err", err.Error())
+		allPrefs = h.getDefaultPreferences()
+	}
+	if len(allPrefs) == 0 {
+		allPrefs = h.getDefaultPreferences()
+	}
+
+	// Check if specific preferences are being requested
+	requestedPrefs := make(map[string]interface{})
+	defaultPrefs := h.getDefaultPreferences()
+
+	// Check each known preference key in the query parameters
+	// When a preference appears in the query (e.g., playIMSound=1),
+	// the client is requesting that specific preference value
+	for key := range defaultPrefs {
+		if r.URL.Query().Has(key) {
+			// Client is requesting this specific preference
+			if prefValue, exists := allPrefs[key]; exists {
+				requestedPrefs[key] = prefValue
+			} else {
+				requestedPrefs[key] = defaultPrefs[key]
+			}
+		}
+	}
+
+	// If no specific preferences were requested, return all
+	var prefs map[string]interface{}
+	if len(requestedPrefs) > 0 {
+		prefs = requestedPrefs
+	} else {
+		prefs = allPrefs
+	}
+
+	h.Logger.DebugContext(ctx, "preferences retrieved",
+		"screenName", targetUser.String(),
+		"prefCount", len(prefs),
+		"requested", len(requestedPrefs) > 0,
+	)
+
+	// Check for AMF format to handle special Gromit compatibility requirements
+	format := strings.ToLower(r.URL.Query().Get("f"))
+	if format == "amf" || format == "amf3" {
+		// Convert string "1"/"0" to numeric values for Gromit compatibility
+		// Gromit expects numeric values for boolean preferences
+		convertedPrefs := make(map[string]interface{})
+		for key, val := range prefs {
+			if strVal, ok := val.(string); ok {
+				if strVal == "1" {
+					convertedPrefs[key] = 1
+				} else if strVal == "0" {
+					convertedPrefs[key] = 0
+				} else {
+					// Keep non-boolean values as strings
+					convertedPrefs[key] = val
+				}
+			} else {
+				convertedPrefs[key] = val
+			}
+		}
+		prefs = convertedPrefs
+
+		h.Logger.DebugContext(ctx, "AMF preference response",
+			"prefs", prefs,
+			"prefCount", len(prefs),
+			"format", format,
+		)
+
+		// Ensure prefs is never nil or empty for Gromit
+		if len(prefs) == 0 {
+			// If no preferences found, at least return the requested ones with defaults
+			if len(requestedPrefs) > 0 {
+				prefs = requestedPrefs
+			} else {
+				// Return playIMSound as default if nothing else
+				prefs = map[string]interface{}{
+					"playIMSound": 1,
+				}
+			}
+		}
+
+		// For single preference requests, return directly for Gromit compatibility
+		// For multiple preferences, wrap in jsonData
+		if len(prefs) != 1 {
+			prefs = map[string]interface{}{
+				"jsonData": prefs,
+			}
+		}
+	}
+
+	// Send response in requested format
+	response := BaseResponse{}
+	response.Response.StatusCode = 200
+	response.Response.StatusText = "OK"
+	response.Response.Data = prefs
+	SendResponse(w, r, response, h.Logger)
+}
+
+// SetPermitDeny handles GET /preference/setPermitDeny requests to update permit/deny settings.
+func (h *PreferenceHandler) SetPermitDeny(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get session ID from parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Get session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		h.sendError(w, http.StatusUnauthorized, "invalid or expired session")
+		return
+	}
+
+	// Update session activity
+	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
+		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
+	}
+
+	// Get pdMode parameter
+	pdModeStr := r.URL.Query().Get("pdMode")
+	if pdModeStr != "" {
+		pdMode, err := strconv.Atoi(pdModeStr)
+		if err != nil || pdMode < 0 || pdMode > 5 {
+			h.sendError(w, http.StatusBadRequest, "invalid pdMode value (must be 0-5)")
+			return
+		}
+
+		// Set the PD mode
+		if err := h.PermitDenyManager.SetPDMode(ctx, session.ScreenName.IdentScreenName(), wire.FeedbagPDMode(pdMode)); err != nil {
+			h.Logger.ErrorContext(ctx, "failed to set PD mode", "err", err.Error())
+			h.sendError(w, http.StatusInternalServerError, "failed to update PD mode")
+			return
+		}
+	}
+
+	// Handle permit list updates
+	if permitAdd := r.URL.Query().Get("permitAdd"); permitAdd != "" {
+		users := strings.Split(permitAdd, ",")
+		for _, user := range users {
+			user = strings.TrimSpace(user)
+			if user != "" {
+				targetSN := state.NewIdentScreenName(user)
+				if err := h.PermitDenyManager.AddPermitBuddy(ctx, session.ScreenName.IdentScreenName(), targetSN); err != nil {
+					h.Logger.ErrorContext(ctx, "failed to add to permit list", "user", user, "err", err.Error())
+				}
+			}
+		}
+	}
+
+	if permitRemove := r.URL.Query().Get("permitRemove"); permitRemove != "" {
+		users := strings.Split(permitRemove, ",")
+		for _, user := range users {
+			user = strings.TrimSpace(user)
+			if user != "" {
+				targetSN := state.NewIdentScreenName(user)
+				if err := h.PermitDenyManager.RemovePermitBuddy(ctx, session.ScreenName.IdentScreenName(), targetSN); err != nil {
+					h.Logger.ErrorContext(ctx, "failed to remove from permit list", "user", user, "err", err.Error())
+				}
+			}
+		}
+	}
+
+	// Handle deny list updates
+	if denyAdd := r.URL.Query().Get("denyAdd"); denyAdd != "" {
+		users := strings.Split(denyAdd, ",")
+		for _, user := range users {
+			user = strings.TrimSpace(user)
+			if user != "" {
+				targetSN := state.NewIdentScreenName(user)
+				if err := h.PermitDenyManager.AddDenyBuddy(ctx, session.ScreenName.IdentScreenName(), targetSN); err != nil {
+					h.Logger.ErrorContext(ctx, "failed to add to deny list", "user", user, "err", err.Error())
+				}
+			}
+		}
+	}
+
+	if denyRemove := r.URL.Query().Get("denyRemove"); denyRemove != "" {
+		users := strings.Split(denyRemove, ",")
+		for _, user := range users {
+			user = strings.TrimSpace(user)
+			if user != "" {
+				targetSN := state.NewIdentScreenName(user)
+				if err := h.PermitDenyManager.RemoveDenyBuddy(ctx, session.ScreenName.IdentScreenName(), targetSN); err != nil {
+					h.Logger.ErrorContext(ctx, "failed to remove from deny list", "user", user, "err", err.Error())
+				}
+			}
+		}
+	}
+
+	// Get updated PD data
+	pdMode, _ := h.PermitDenyManager.GetPDMode(ctx, session.ScreenName.IdentScreenName())
+	permitList, _ := h.PermitDenyManager.GetPermitList(ctx, session.ScreenName.IdentScreenName())
+	denyList, _ := h.PermitDenyManager.GetDenyList(ctx, session.ScreenName.IdentScreenName())
+
+	// Convert to string arrays
+	permitUsers := make([]string, len(permitList))
+	for i, u := range permitList {
+		permitUsers[i] = u.String()
+	}
+	denyUsers := make([]string, len(denyList))
+	for i, u := range denyList {
+		denyUsers[i] = u.String()
+	}
+
+	h.Logger.DebugContext(ctx, "permit/deny settings updated",
+		"screenName", session.ScreenName.String(),
+		"pdMode", pdMode,
+		"permitCount", len(permitUsers),
+		"denyCount", len(denyUsers),
+	)
+
+	// Note: We don't broadcast immediate presence changes here.
+	// The blocking relationship is now in the database and will be respected
+	// by all future presence checks and message routing.
+	// The blocked users will appear offline to each other on the next presence update.
+
+	// Send response
+	permitDenyData := PermitDenyData{
+		PDMode:     int(pdMode),
+		PermitList: permitUsers,
+		DenyList:   denyUsers,
+	}
+
+	response := BaseResponse{}
+	response.Response.StatusCode = 200
+	response.Response.StatusText = "OK"
+	response.Response.Data = permitDenyData
+	SendResponse(w, r, response, h.Logger)
+}
+
+// GetPermitDeny handles GET /preference/getPermitDeny requests to retrieve permit/deny settings.
+func (h *PreferenceHandler) GetPermitDeny(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get session ID from parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Get session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		h.sendError(w, http.StatusUnauthorized, "invalid or expired session")
+		return
+	}
+
+	// Update session activity
+	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
+		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
+	}
+
+	// Get PD data
+	pdMode, _ := h.PermitDenyManager.GetPDMode(ctx, session.ScreenName.IdentScreenName())
+	permitList, _ := h.PermitDenyManager.GetPermitList(ctx, session.ScreenName.IdentScreenName())
+	denyList, _ := h.PermitDenyManager.GetDenyList(ctx, session.ScreenName.IdentScreenName())
+
+	// Convert to string arrays
+	permitUsers := make([]string, len(permitList))
+	for i, u := range permitList {
+		permitUsers[i] = u.String()
+	}
+	denyUsers := make([]string, len(denyList))
+	for i, u := range denyList {
+		denyUsers[i] = u.String()
+	}
+
+	h.Logger.DebugContext(ctx, "permit/deny settings retrieved",
+		"screenName", session.ScreenName.String(),
+		"pdMode", pdMode,
+		"permitCount", len(permitUsers),
+		"denyCount", len(denyUsers),
+	)
+
+	// Send response
+	permitDenyData := PermitDenyData{
+		PDMode:     int(pdMode),
+		PermitList: permitUsers,
+		DenyList:   denyUsers,
+	}
+
+	response := BaseResponse{}
+	response.Response.StatusCode = 200
+	response.Response.StatusText = "OK"
+	response.Response.Data = permitDenyData
+	SendResponse(w, r, response, h.Logger)
+}
+
+// sendError sends an error response in Web AIM API format.
+// getDefaultPreferences returns default preference values that clients expect.
+func (h *PreferenceHandler) getDefaultPreferences() map[string]interface{} {
+	return map[string]interface{}{
+		"autoPlay":            "1",
+		"playIMSound":         "1",
+		"playBuddySound":      "1",
+		"showTimestamps":      "1",
+		"showAdsFlag":         "1",
+		"soundSetting":        "1",
+		"awayMessageOn":       "0",
+		"awayMessage":         "",
+		"confirmSignOff":      "0",
+		"skipNavigator":       "1",
+		"displayIdleTime":     "1",
+		"repliesAnyone":       "0",
+		"repliesUsersOnline":  "0",
+		"repliesBuddies":      "0",
+		"replyMessage":        "",
+		"allowAccessPresence": "0",
+		"blockIdleStatus":     "0",
+		"reportIdleTyping":    "1",
+		"smileysDisabled":     "0",
+		"sortBuddiesAlpha":    "0",
+		"statusMsg":           "",
+		"statusIcon":          "",
+		"skin":                "default",
+	}
+}
+
+func (h *PreferenceHandler) sendError(w http.ResponseWriter, statusCode int, message string) {
+	SendError(w, statusCode, message)
+}

+ 665 - 0
server/webapi/handlers/presence.go

@@ -0,0 +1,665 @@
+package handlers
+
+import (
+	"context"
+	"log/slog"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/server/webapi/types"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// PresenceHandler handles Web AIM API presence-related endpoints.
+type PresenceHandler struct {
+	SessionManager      *state.WebAPISessionManager
+	SessionRetriever    SessionRetriever
+	FeedbagRetriever    FeedbagRetriever
+	BuddyBroadcaster    BuddyBroadcaster
+	ProfileManager      ProfileManager
+	RelationshipFetcher RelationshipFetcher
+	Logger              *slog.Logger
+}
+
+// BuddyBroadcaster broadcasts buddy presence updates
+type BuddyBroadcaster interface {
+	BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error
+	BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error
+}
+
+// ProfileManager manages user profiles
+type ProfileManager interface {
+	SetProfile(ctx context.Context, screenName state.IdentScreenName, profile string) error
+	Profile(ctx context.Context, screenName state.IdentScreenName) (string, error)
+}
+
+// PresenceData contains presence information.
+type PresenceData struct {
+	Groups []BuddyGroupInfo    `json:"groups,omitempty" xml:"groups>group,omitempty"`
+	Users  []BuddyPresenceInfo `json:"users,omitempty" xml:"users>user,omitempty"`
+}
+
+// BuddyGroupInfo represents a buddy group with its members.
+type BuddyGroupInfo struct {
+	Name    string              `json:"name" xml:"name"`
+	Buddies []BuddyPresenceInfo `json:"buddies" xml:"buddies>buddy"`
+}
+
+// BuddyPresenceInfo represents presence information for a buddy.
+type BuddyPresenceInfo struct {
+	AimID      string `json:"aimId" xml:"aimId"`
+	State      string `json:"state" xml:"state"` // "online", "offline", "away", "idle"
+	StatusMsg  string `json:"statusMsg,omitempty" xml:"statusMsg,omitempty"`
+	AwayMsg    string `json:"awayMsg,omitempty" xml:"awayMsg,omitempty"`
+	IdleTime   int    `json:"idleTime,omitempty" xml:"idleTime,omitempty"`
+	OnlineTime int64  `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"`
+	UserType   string `json:"userType" xml:"userType"` // "aim", "icq", "admin"
+}
+
+// GetPresence handles GET /presence/get requests.
+func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get session ID from parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Get session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		if err == state.ErrNoWebAPISession {
+			h.sendError(w, http.StatusNotFound, "session not found")
+		} else if err == state.ErrWebAPISessionExpired {
+			h.sendError(w, http.StatusGone, "session expired")
+		} else {
+			h.sendError(w, http.StatusInternalServerError, "internal server error")
+		}
+		return
+	}
+
+	// Touch the session
+	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
+		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
+	}
+
+	// Check if buddy list is requested
+	getBuddyList := r.URL.Query().Get("bl") == "1"
+
+	// Get target users if specified
+	targetUsers := r.URL.Query().Get("t")
+
+	// Prepare response
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+
+	// Create PresenceData struct to hold the response data
+	presenceData := PresenceData{}
+
+	if getBuddyList {
+		// Retrieve buddy list from feedbag
+		groups, err := h.getBuddyListGroups(ctx, session.ScreenName.IdentScreenName())
+		if err != nil {
+			h.Logger.ErrorContext(ctx, "failed to get buddy list", "err", err.Error())
+			// Return empty buddy list on error instead of failing
+			groups = []BuddyGroupInfo{}
+		}
+		presenceData.Groups = groups
+	} else if targetUsers != "" {
+		// Get presence for specific users
+		users := strings.Split(targetUsers, ",")
+		presenceList := make([]BuddyPresenceInfo, 0, len(users))
+
+		for _, user := range users {
+			user = strings.TrimSpace(user)
+			if user == "" {
+				continue
+			}
+
+			userScreenName := state.NewIdentScreenName(user)
+
+			// Check blocking relationship (OSCAR compliant)
+			rel, err := h.RelationshipFetcher.Relationship(ctx, session.ScreenName.IdentScreenName(), userScreenName)
+			if err != nil {
+				h.Logger.WarnContext(ctx, "failed to get relationship", "error", err)
+				// On error, show as offline
+				presence := BuddyPresenceInfo{
+					AimID:    user,
+					State:    "offline",
+					UserType: "aim",
+				}
+				presenceList = append(presenceList, presence)
+				continue
+			}
+
+			// OSCAR compliance: mutual invisibility when blocking
+			if rel.YouBlock || rel.BlocksYou {
+				presence := BuddyPresenceInfo{
+					AimID:    user,
+					State:    "offline",
+					UserType: "aim",
+				}
+				presenceList = append(presenceList, presence)
+			} else {
+				presence := h.getUserPresence(userScreenName)
+				presenceList = append(presenceList, presence)
+			}
+		}
+
+		presenceData.Users = presenceList
+	} else {
+		// No specific request, return empty data
+		presenceData.Groups = []BuddyGroupInfo{}
+	}
+
+	// Set the data to the response
+	resp.Response.Data = presenceData
+
+	// Send response in requested format
+	SendResponse(w, r, resp, h.Logger)
+
+	h.Logger.DebugContext(ctx, "presence retrieved",
+		"aimsid", aimsid,
+		"buddy_list", getBuddyList,
+		"targets", targetUsers,
+	)
+}
+
+// getBuddyListGroups retrieves the buddy list organized by groups.
+func (h *PresenceHandler) getBuddyListGroups(ctx context.Context, screenName state.IdentScreenName) ([]BuddyGroupInfo, error) {
+	// Get feedbag items
+	items, err := h.FeedbagRetriever.RetrieveFeedbag(ctx, screenName)
+	if err != nil {
+		return nil, err
+	}
+
+	// Organize items into groups
+	groupMap := make(map[uint16]*BuddyGroupInfo)
+	buddyToGroup := make(map[string]uint16)
+
+	// First pass: identify groups
+	for _, item := range items {
+		if item.ClassID == wire.FeedbagClassIdGroup {
+			name := item.Name
+			if name == "" {
+				name = "Buddies" // Default group name
+			}
+
+			groupMap[item.ItemID] = &BuddyGroupInfo{
+				Name:    name,
+				Buddies: []BuddyPresenceInfo{},
+			}
+		}
+	}
+
+	// Second pass: add buddies to groups
+	for _, item := range items {
+		if item.ClassID == wire.FeedbagClassIdBuddy {
+			// Get buddy screen name
+			buddyName := item.Name
+			if buddyName == "" {
+				continue
+			}
+
+			// Find buddy's group
+			groupID := item.GroupID
+
+			buddyToGroup[buddyName] = groupID
+		}
+	}
+
+	// If no groups exist, create a default one
+	if len(groupMap) == 0 {
+		groupMap[0] = &BuddyGroupInfo{
+			Name:    "Buddies",
+			Buddies: []BuddyPresenceInfo{},
+		}
+	}
+
+	// Add buddies to their groups with presence info
+	for buddyName, groupID := range buddyToGroup {
+		group, exists := groupMap[groupID]
+		if !exists {
+			// Put in first available group if group doesn't exist
+			for _, g := range groupMap {
+				group = g
+				break
+			}
+		}
+
+		buddyScreenName := state.NewIdentScreenName(buddyName)
+
+		// Check blocking relationship (OSCAR compliant)
+		rel, err := h.RelationshipFetcher.Relationship(ctx, screenName, buddyScreenName)
+		if err != nil {
+			h.Logger.WarnContext(ctx, "failed to get relationship", "error", err)
+			// On error, include the buddy but they'll appear offline
+			presence := BuddyPresenceInfo{
+				AimID:    buddyName,
+				State:    "offline",
+				UserType: "aim",
+			}
+			group.Buddies = append(group.Buddies, presence)
+			continue
+		}
+
+		// OSCAR compliance: mutual invisibility when blocking
+		if rel.YouBlock || rel.BlocksYou {
+			// Add them as offline to maintain buddy list structure
+			presence := BuddyPresenceInfo{
+				AimID:    buddyName,
+				State:    "offline",
+				UserType: "aim",
+			}
+			group.Buddies = append(group.Buddies, presence)
+		} else {
+			// Normal presence lookup
+			presence := h.getUserPresence(buddyScreenName)
+			group.Buddies = append(group.Buddies, presence)
+		}
+	}
+
+	// Convert map to slice
+	groups := make([]BuddyGroupInfo, 0, len(groupMap))
+	for _, group := range groupMap {
+		groups = append(groups, *group)
+	}
+
+	return groups, nil
+}
+
+// getUserPresence gets the current presence state for a user.
+func (h *PresenceHandler) getUserPresence(screenName state.IdentScreenName) BuddyPresenceInfo {
+	// Default offline presence
+	presence := BuddyPresenceInfo{
+		AimID:    screenName.String(),
+		State:    "offline",
+		UserType: "aim",
+	}
+
+	// Check if user is online by looking for their OSCAR session
+	if session := h.SessionRetriever.RetrieveSession(screenName); session != nil {
+		presence.State = "online"
+
+		// Check user status
+		statusBitmask := session.UserStatusBitmask()
+		if statusBitmask&wire.OServiceUserStatusAway != 0 {
+			presence.State = "away"
+			// TODO: Get away message from session
+		} else if statusBitmask&wire.OServiceUserStatusDND != 0 {
+			presence.State = "dnd"
+		}
+
+		// Check idle time
+		if session.Idle() {
+			presence.State = "idle"
+			idleTime := time.Since(session.IdleTime())
+			presence.IdleTime = int(idleTime.Minutes())
+		}
+
+		// Get online time
+		presence.OnlineTime = session.SignonTime().Unix()
+
+		// TODO: Get status message from profile
+	}
+
+	// Determine user type
+	if strings.HasPrefix(screenName.String(), "admin") {
+		presence.UserType = "admin"
+	} else if isICQScreenName(screenName.String()) {
+		presence.UserType = "icq"
+	}
+
+	return presence
+}
+
+// isICQScreenName checks if a screen name is an ICQ number.
+func isICQScreenName(screenName string) bool {
+	if len(screenName) == 0 {
+		return false
+	}
+	for _, r := range screenName {
+		if r < '0' || r > '9' {
+			return false
+		}
+	}
+	return true
+}
+
+// sendError is a convenience method that wraps the common SendError function.
+func (h *PresenceHandler) sendError(w http.ResponseWriter, statusCode int, message string) {
+	SendError(w, statusCode, message)
+}
+
+// SetState handles GET /presence/setState requests to update user's presence state.
+func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get session ID from parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Get session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		h.sendError(w, http.StatusUnauthorized, "invalid or expired session")
+		return
+	}
+
+	// Update session activity
+	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
+		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
+	}
+
+	// Get the requested state
+	stateParam := r.URL.Query().Get("state")
+	awayMsg := r.URL.Query().Get("awayMsg")
+
+	// Get OSCAR session if available
+	oscarSession := session.OSCARSession
+	if oscarSession == nil {
+		// For web-only sessions, we'll need to track state in the WebAPI session
+		// For now, just store in event data
+		h.Logger.WarnContext(ctx, "no OSCAR session for presence update", "aimsid", aimsid)
+
+		// Still send success response
+		response := BaseResponse{}
+		response.Response.StatusCode = 200
+		response.Response.StatusText = "OK"
+		SendResponse(w, r, response, h.Logger)
+		return
+	}
+
+	// Map web state to OSCAR status bits
+	var statusBitmask uint32
+	switch stateParam {
+	case "online":
+		statusBitmask = 0x0000 // Clear all status bits
+		oscarSession.SetAwayMessage("")
+	case "away":
+		statusBitmask = wire.OServiceUserStatusAway
+		if awayMsg != "" {
+			oscarSession.SetAwayMessage(awayMsg)
+		}
+	case "invisible":
+		statusBitmask = wire.OServiceUserStatusInvisible
+	case "dnd":
+		statusBitmask = wire.OServiceUserStatusDND
+	default:
+		h.sendError(w, http.StatusBadRequest, "invalid state parameter")
+		return
+	}
+
+	// Update OSCAR session status
+	oscarSession.SetUserStatusBitmask(statusBitmask)
+
+	// Broadcast presence update
+	if statusBitmask&wire.OServiceUserStatusInvisible != 0 {
+		// User going invisible - broadcast departure
+		if err := h.BuddyBroadcaster.BroadcastBuddyDeparted(ctx, oscarSession); err != nil {
+			h.Logger.ErrorContext(ctx, "failed to broadcast buddy departed", "err", err.Error())
+		}
+	} else {
+		// User visible - broadcast arrival/update
+		if err := h.BuddyBroadcaster.BroadcastBuddyArrived(ctx, oscarSession.IdentScreenName(), oscarSession.TLVUserInfo()); err != nil {
+			h.Logger.ErrorContext(ctx, "failed to broadcast buddy arrived", "err", err.Error())
+		}
+	}
+
+	// Queue presence event for other WebAPI sessions watching this user
+	h.broadcastPresenceEvent(session.ScreenName.IdentScreenName(), stateParam, awayMsg, "")
+
+	h.Logger.InfoContext(ctx, "presence state updated",
+		"screenName", session.ScreenName.String(),
+		"state", stateParam,
+		"hasAwayMsg", awayMsg != "",
+	)
+
+	// Send success response
+	response := BaseResponse{}
+	response.Response.StatusCode = 200
+	response.Response.StatusText = "OK"
+	SendResponse(w, r, response, h.Logger)
+}
+
+// SetStatus handles GET /presence/setStatus requests to update user's status message.
+func (h *PresenceHandler) SetStatus(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get session ID from parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Get session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		h.sendError(w, http.StatusUnauthorized, "invalid or expired session")
+		return
+	}
+
+	// Update session activity
+	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
+		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
+	}
+
+	// Get the status message
+	statusMsg := r.URL.Query().Get("statusMsg")
+	statusCode := r.URL.Query().Get("statusCode")
+
+	// Store status message in session (this would normally be stored in a profile/status service)
+	// For now, we'll broadcast it as part of presence
+
+	// Get OSCAR session if available
+	if oscarSession := session.OSCARSession; oscarSession != nil {
+		// In OSCAR, status messages are typically part of the profile
+		// We'll need to extend this based on the actual implementation
+
+		// Broadcast presence update with new status
+		if err := h.BuddyBroadcaster.BroadcastBuddyArrived(ctx, oscarSession.IdentScreenName(), oscarSession.TLVUserInfo()); err != nil {
+			h.Logger.ErrorContext(ctx, "failed to broadcast status update", "err", err.Error())
+		}
+	}
+
+	// Queue status event for other WebAPI sessions
+	h.broadcastPresenceEvent(session.ScreenName.IdentScreenName(), "", "", statusMsg)
+
+	h.Logger.InfoContext(ctx, "status message updated",
+		"screenName", session.ScreenName.String(),
+		"statusMsg", statusMsg,
+		"statusCode", statusCode,
+	)
+
+	// Send success response
+	response := BaseResponse{}
+	response.Response.StatusCode = 200
+	response.Response.StatusText = "OK"
+	SendResponse(w, r, response, h.Logger)
+}
+
+// SetProfile handles GET /presence/setProfile requests to update user's profile.
+func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get session ID from parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Get session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		h.sendError(w, http.StatusUnauthorized, "invalid or expired session")
+		return
+	}
+
+	// Update session activity
+	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
+		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
+	}
+
+	// Get the profile content
+	profile := r.URL.Query().Get("profile")
+
+	// Limit profile size (4KB max)
+	if len(profile) > 4096 {
+		h.sendError(w, http.StatusBadRequest, "profile too large (max 4KB)")
+		return
+	}
+
+	// Save profile using ProfileManager
+	if err := h.ProfileManager.SetProfile(ctx, session.ScreenName.IdentScreenName(), profile); err != nil {
+		h.Logger.ErrorContext(ctx, "failed to set profile", "err", err.Error())
+		h.sendError(w, http.StatusInternalServerError, "failed to save profile")
+		return
+	}
+
+	h.Logger.InfoContext(ctx, "profile updated",
+		"screenName", session.ScreenName.String(),
+		"profileSize", len(profile),
+	)
+
+	// Send success response
+	response := BaseResponse{}
+	response.Response.StatusCode = 200
+	response.Response.StatusText = "OK"
+	SendResponse(w, r, response, h.Logger)
+}
+
+// GetProfile handles GET /presence/getProfile requests to retrieve user's profile.
+func (h *PresenceHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get session ID from parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Get session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		h.sendError(w, http.StatusUnauthorized, "invalid or expired session")
+		return
+	}
+
+	// Update session activity
+	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
+		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
+	}
+
+	// Get target screen name (optional - defaults to self)
+	targetSN := r.URL.Query().Get("sn")
+	if targetSN == "" {
+		targetSN = session.ScreenName.String()
+	}
+
+	// Retrieve profile using ProfileManager
+	profile, err := h.ProfileManager.Profile(ctx, state.NewIdentScreenName(targetSN))
+	if err != nil {
+		h.Logger.WarnContext(ctx, "failed to get profile", "err", err.Error())
+		// Return empty profile on error
+		profile = ""
+	}
+
+	// Send response
+	responseData := map[string]interface{}{
+		"screenName":  targetSN,
+		"profile":     profile,
+		"lastUpdated": time.Now().Unix(),
+	}
+
+	response := BaseResponse{}
+	response.Response.StatusCode = 200
+	response.Response.StatusText = "OK"
+	response.Response.Data = responseData
+	SendResponse(w, r, response, h.Logger)
+}
+
+// Icon handles GET /presence/icon requests for presence icons.
+func (h *PresenceHandler) Icon(w http.ResponseWriter, r *http.Request) {
+	// Get parameters
+	name := r.URL.Query().Get("name")
+	size := r.URL.Query().Get("size")
+	iconType := r.URL.Query().Get("type")
+
+	if name == "" {
+		h.sendError(w, http.StatusBadRequest, "missing name parameter")
+		return
+	}
+
+	// Default values
+	if size == "" {
+		size = "32"
+	}
+	if iconType == "" {
+		iconType = "aim"
+	}
+
+	// For now, redirect to a placeholder icon
+	// In production, this would redirect to actual icon storage/CDN
+	iconURL := "/static/icons/default_" + iconType + "_" + size + ".png"
+
+	// If it's an email lookup, extract username
+	if strings.Contains(name, "@") {
+		parts := strings.Split(name, "@")
+		if len(parts) > 0 {
+			name = parts[0]
+		}
+	}
+
+	// Check if user is online and get their state
+	screenName := state.NewIdentScreenName(name)
+	if session := h.SessionRetriever.RetrieveSession(screenName); session != nil {
+		statusBitmask := session.UserStatusBitmask()
+		if statusBitmask&wire.OServiceUserStatusAway != 0 {
+			iconURL = "/static/icons/away_" + iconType + "_" + size + ".png"
+		} else if session.Idle() {
+			iconURL = "/static/icons/idle_" + iconType + "_" + size + ".png"
+		} else {
+			iconURL = "/static/icons/online_" + iconType + "_" + size + ".png"
+		}
+	} else {
+		iconURL = "/static/icons/offline_" + iconType + "_" + size + ".png"
+	}
+
+	// Redirect to icon URL
+	http.Redirect(w, r, iconURL, http.StatusFound)
+}
+
+// broadcastPresenceEvent sends presence updates to all WebAPI sessions watching this user
+func (h *PresenceHandler) broadcastPresenceEvent(screenName state.IdentScreenName, stateStr, awayMsg, statusMsg string) {
+	// Get all sessions that have this user in their buddy list
+	// For now, we'll broadcast to all sessions (this should be optimized)
+	// Using background context as this is an async broadcast operation
+	for _, sess := range h.SessionManager.GetAllSessions(context.Background()) {
+		if sess.EventQueue != nil && sess.Events != nil {
+			// Check if session is subscribed to presence events
+			for _, event := range sess.Events {
+				if event == "presence" || event == "myInfo" {
+					eventData := types.PresenceEvent{
+						AimID:     screenName.String(),
+						State:     stateStr,
+						AwayMsg:   awayMsg,
+						StatusMsg: statusMsg,
+					}
+					sess.EventQueue.Push(types.EventTypePresence, eventData)
+					break
+				}
+			}
+		}
+	}
+}

+ 555 - 0
server/webapi/handlers/session.go

@@ -0,0 +1,555 @@
+package handlers
+
+import (
+	"context"
+	"encoding/xml"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/mk6i/retro-aim-server/server/webapi/middleware"
+	"github.com/mk6i/retro-aim-server/server/webapi/types"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// SessionHandler handles Web AIM API session management endpoints.
+type SessionHandler struct {
+	SessionManager      *state.WebAPISessionManager
+	OSCARSessionManager SessionManager
+	OSCARAuthService    AuthService
+	BuddyListService    BuddyListService
+	BuddyListRegistry   BuddyListRegistry
+	BuddyBroadcaster    BuddyBroadcaster
+	BuddyListManager    *BuddyListManager
+	TokenStore          TokenStore
+	Logger              *slog.Logger
+}
+
+// AuthService defines methods needed for authentication.
+type AuthService interface {
+	BUCPChallenge(ctx context.Context, bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
+	BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error)
+	RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.Session, error)
+}
+
+// SessionManager defines methods for OSCAR session management.
+type SessionManager interface {
+	AddSession(ctx context.Context, screenName state.DisplayScreenName) (*state.Session, error)
+	RemoveSession(sess *state.Session)
+	RelayToScreenName(ctx context.Context, screenName state.IdentScreenName, msg wire.SNACMessage)
+}
+
+// BuddyListRegistry defines methods for buddy list management.
+type BuddyListRegistry interface {
+	RegisterBuddyList(ctx context.Context, screenName state.IdentScreenName) error
+	UnregisterBuddyList(ctx context.Context, screenName state.IdentScreenName) error
+}
+
+// BuddyListService defines methods for buddy list operations.
+type BuddyListService interface {
+	GetBuddyList(ctx context.Context, screenName state.IdentScreenName) ([]BuddyGroup, error)
+}
+
+// BuddyGroup represents a group of buddies.
+type BuddyGroup struct {
+	Name    string  `json:"name"`
+	Buddies []Buddy `json:"buddies"`
+}
+
+// Buddy represents a buddy in the buddy list.
+type Buddy struct {
+	AimID     string `json:"aimId"`
+	State     string `json:"state"`
+	StatusMsg string `json:"statusMsg,omitempty"`
+	AwayMsg   string `json:"awayMsg,omitempty"`
+	UserType  string `json:"userType"`
+}
+
+// StartSessionResponse represents the response for startSession endpoint.
+type StartSessionResponse struct {
+	Response struct {
+		StatusCode int    `json:"statusCode"`
+		StatusText string `json:"statusText"`
+		Data       struct {
+			AimSID          string                 `json:"aimsid"`
+			FetchTimeout    int                    `json:"fetchTimeout"`
+			TimeToNextFetch int                    `json:"timeToNextFetch"`
+			FetchBaseURL    string                 `json:"fetchBaseURL"` // Gromit expects this directly in data!
+			Events          map[string]interface{} `json:"events,omitempty"`
+			WellKnownUrls   map[string]string      `json:"wellKnownUrls,omitempty"`
+		} `json:"data"`
+	} `json:"response"`
+}
+
+// StartSessionXMLResponse represents the XML response for startSession endpoint.
+type StartSessionXMLResponse struct {
+	XMLName    xml.Name `xml:"response"`
+	StatusCode int      `xml:"statusCode"`
+	StatusText string   `xml:"statusText"`
+	Data       struct {
+		AimSID          string `xml:"aimsid"`
+		FetchTimeout    int    `xml:"fetchTimeout"`
+		TimeToNextFetch int    `xml:"timeToNextFetch"`
+		FetchBaseURL    string `xml:"fetchBaseURL"` // Gromit expects this directly!
+		WellKnownUrls   *struct {
+			WebApiBase   string `xml:"webApiBase"`
+			FetchBaseURL string `xml:"fetchBaseURL"`
+		} `xml:"wellKnownUrls,omitempty"`
+		MyInfo *struct {
+			AimID     string `xml:"aimId"`
+			DisplayID string `xml:"displayId"`
+			Buddylist struct {
+				Groups *[]BuddyGroup `xml:"group,omitempty"`
+			} `xml:"buddylist,omitempty"`
+		} `xml:"myInfo,omitempty"`
+		Events *struct {
+			BuddyList struct {
+				Groups *[]BuddyGroup `xml:"group,omitempty"`
+			} `xml:"buddylist"`
+		} `xml:"events,omitempty"`
+	} `xml:"data"`
+}
+
+// EndSessionResponse represents the response for endSession endpoint.
+type EndSessionResponse struct {
+	Response struct {
+		StatusCode int    `json:"statusCode"`
+		StatusText string `json:"statusText"`
+	} `json:"response"`
+}
+
+// StartSession handles GET /aim/startSession requests.
+func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get API key info from context (set by auth middleware)
+	apiKey, ok := ctx.Value(middleware.ContextKeyAPIKey).(*state.WebAPIKey)
+	if !ok {
+		h.sendError(w, http.StatusInternalServerError, "internal server error")
+		return
+	}
+
+	// Parse parameters
+	params := r.URL.Query()
+
+	// Get authentication token if provided
+	authToken := params.Get("a")
+
+	// Get client info
+	clientName := params.Get("clientName")
+	if clientName == "" {
+		clientName = "WebAIM"
+	}
+	clientVersion := params.Get("clientVersion")
+	if clientVersion == "" {
+		clientVersion = "1.0"
+	}
+
+	// Get events to subscribe to
+	eventsParam := params.Get("events")
+	var events []string
+	if eventsParam != "" {
+		events = strings.Split(eventsParam, ",")
+		h.Logger.DebugContext(ctx, "parsing events from request",
+			"eventsParam", eventsParam,
+			"parsedEvents", events,
+		)
+	} else {
+		// Default events if none specified
+		events = []string{"buddylist", "presence", "im", "sentIM"}
+		h.Logger.DebugContext(ctx, "using default events",
+			"events", events,
+		)
+	}
+
+	// Get timeout settings
+	timeout := 60000 // Default 60 seconds for better stability with Gromit
+	if t := params.Get("timeout"); t != "" {
+		if val, err := strconv.Atoi(t); err == nil && val > 0 {
+			timeout = val * 1000 // Convert to milliseconds
+		}
+	}
+
+	// Determine screen name from auth token or anonymous
+	var screenName state.DisplayScreenName
+
+	if authToken != "" {
+		// Validate auth token and get screen name
+		if h.TokenStore == nil {
+			h.Logger.Error("TokenStore not configured")
+			h.sendError(w, http.StatusInternalServerError, "authentication not configured")
+			return
+		}
+		identScreenName, err := h.TokenStore.ValidateToken(r.Context(), authToken)
+		if err != nil {
+			h.Logger.Warn("invalid authentication token",
+				"error", err)
+			h.sendError(w, http.StatusUnauthorized, "invalid or expired token")
+			return
+		}
+		// For WebAPI sessions, we can use the IdentScreenName directly as DisplayScreenName
+		// since WRAITH handles the display formatting
+		screenName = state.DisplayScreenName(identScreenName.String())
+		tokenPreview := authToken
+		if len(tokenPreview) > 8 {
+			tokenPreview = tokenPreview[:8] + "..."
+		}
+		h.Logger.Info("authenticated session requested",
+			"token", tokenPreview,
+			"screenName", screenName)
+	} else {
+		// Anonymous session - generate guest name
+		screenName = state.DisplayScreenName("Guest_" + strconv.FormatInt(time.Now().Unix(), 36))
+		h.Logger.Info("anonymous session requested",
+			"screenName", screenName)
+	}
+
+	// Create OSCAR session for authenticated users
+	var oscarSession *state.Session
+	var err error
+	if authToken != "" && h.OSCARSessionManager != nil {
+		// Create OSCAR session
+		oscarSession, err = h.OSCARSessionManager.AddSession(ctx, screenName)
+		if err != nil {
+			h.Logger.ErrorContext(ctx, "failed to create OSCAR session", "err", err.Error())
+			// Continue without OSCAR session - WebAPI can work standalone
+			oscarSession = nil
+		} else {
+			oscarSession.SetSignonComplete()
+
+			// Register buddy list
+			if h.BuddyListRegistry != nil {
+				if err := h.BuddyListRegistry.RegisterBuddyList(ctx, screenName.IdentScreenName()); err != nil {
+					h.Logger.ErrorContext(ctx, "failed to register buddy list", "err", err.Error())
+				}
+			}
+
+			// Broadcast buddy arrival to OSCAR clients
+			if h.BuddyBroadcaster != nil {
+				if err := h.BuddyBroadcaster.BroadcastBuddyArrived(ctx, oscarSession.IdentScreenName(), oscarSession.TLVUserInfo()); err != nil {
+					h.Logger.ErrorContext(ctx, "failed to broadcast buddy arrival", "err", err.Error())
+				}
+			}
+		}
+	}
+
+	// Create WebAPI session
+	session, err := h.SessionManager.CreateSession(r.Context(), screenName, apiKey.DevID, events, oscarSession, h.Logger)
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "failed to create session", "err", err.Error())
+		h.sendError(w, http.StatusInternalServerError, "failed to create session")
+		return
+	}
+
+	h.Logger.DebugContext(ctx, "session created with event subscriptions",
+		"aimsid", session.AimSID,
+		"events", events,
+	)
+
+	// Store client info
+	session.ClientName = clientName
+	session.ClientVersion = clientVersion
+	session.FetchTimeout = timeout
+	session.RemoteAddr = r.RemoteAddr
+
+	// Queue myInfo event for authenticated users
+	if authToken != "" {
+		for _, event := range events {
+			if event == "myInfo" || event == "presence" {
+				myInfoData := map[string]interface{}{
+					"aimId":        screenName.String(),
+					"displayId":    screenName.String(),
+					"state":        "online",
+					"onlineTime":   time.Now().Unix(),
+					"memberSince":  time.Now().Unix() - 86400*30, // 30 days ago
+					"capabilities": []string{},
+					"bot":          false,
+					"service":      "aim",
+				}
+				session.EventQueue.Push(types.EventType("myInfo"), myInfoData)
+				break
+			}
+		}
+	}
+
+	// Prepare response
+	resp := StartSessionResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data.AimSID = session.AimSID
+	resp.Response.Data.FetchTimeout = session.FetchTimeout
+	resp.Response.Data.TimeToNextFetch = session.TimeToNextFetch
+	// Gromit expects fetchBaseURL directly in data, not in wellKnownUrls
+	resp.Response.Data.FetchBaseURL = fmt.Sprintf("http://%s/aim/fetchEvents?aimsid=%s&seqNum=0", r.Host, session.AimSID)
+
+	// Add wellKnownUrls for other clients that might use it
+	resp.Response.Data.WellKnownUrls = map[string]string{
+		"webApiBase":   fmt.Sprintf("http://%s/", r.Host),
+		"fetchBaseURL": fmt.Sprintf("http://%s/aim/fetchEvents", r.Host),
+	}
+
+	// Add myInfo data if authenticated
+	if authToken != "" {
+		if resp.Response.Data.Events == nil {
+			resp.Response.Data.Events = make(map[string]interface{})
+		}
+		resp.Response.Data.Events["myInfo"] = map[string]interface{}{
+			"aimId":        screenName.String(),
+			"displayId":    screenName.String(),
+			"state":        "online",
+			"onlineTime":   time.Now().Unix(),
+			"memberSince":  time.Now().Unix() - 86400*30, // 30 days ago
+			"capabilities": []string{},
+			"bot":          false,
+			"service":      "aim",
+			"self": map[string]interface{}{
+				"instNum":        1,
+				"loginTime":      time.Now().Unix(),
+				"sessionTimeout": 30,
+				"events":         events,
+				"assertCaps":     []string{},
+				"rightsInfo": map[string]interface{}{
+					"maxDenies":            500,
+					"maxPermits":           500,
+					"maxWatchers":          3000,
+					"maxBuddies":           500,
+					"maxTempBuddies":       160,
+					"maxIMSize":            3987,
+					"minInterIcbmInterval": 1000,
+					"maxSourceEvil":        900,
+					"maxDstEvil":           999,
+					"maxSigLen":            4096,
+				},
+			},
+		}
+	}
+
+	// If buddy list event is subscribed, include initial buddy list
+	for _, event := range events {
+		if event == "buddylist" {
+			if authToken != "" && h.BuddyListManager != nil {
+				// Fetch actual buddy list from service
+				buddyGroups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session.ScreenName.IdentScreenName())
+				if err != nil {
+					h.Logger.ErrorContext(ctx, "failed to get buddy list", "err", err.Error())
+					// Continue with empty buddy list
+					buddyGroups = []WebAPIBuddyGroup{}
+				}
+
+				// Convert to handler format and include in response
+				if resp.Response.Data.Events == nil {
+					resp.Response.Data.Events = make(map[string]interface{})
+				}
+				resp.Response.Data.Events["buddylist"] = map[string]interface{}{
+					"groups": buddyGroups,
+				}
+
+			} else {
+				// No auth token, return empty buddy list
+				if resp.Response.Data.Events == nil {
+					resp.Response.Data.Events = make(map[string]interface{})
+				}
+				resp.Response.Data.Events["buddylist"] = map[string]interface{}{
+					"groups": []WebAPIBuddyGroup{},
+				}
+			}
+			break
+		}
+	}
+
+	// Check response format
+	format := r.URL.Query().Get("f")
+	if format == "" {
+		format = "json" // default to JSON
+	}
+
+	// Send response in requested format
+	if format == "xml" {
+		// Build XML response
+		xmlResp := StartSessionXMLResponse{}
+		xmlResp.StatusCode = 200
+		xmlResp.StatusText = "OK"
+		xmlResp.Data.AimSID = session.AimSID
+		xmlResp.Data.FetchTimeout = timeout
+		xmlResp.Data.TimeToNextFetch = 500
+		// Gromit expects fetchBaseURL directly in data
+		xmlResp.Data.FetchBaseURL = fmt.Sprintf("http://%s/aim/fetchEvents?aimsid=%s&seqNum=0", r.Host, session.AimSID)
+
+		// Add wellKnownUrls for other clients
+		xmlResp.Data.WellKnownUrls = &struct {
+			WebApiBase   string `xml:"webApiBase"`
+			FetchBaseURL string `xml:"fetchBaseURL"`
+		}{
+			WebApiBase:   fmt.Sprintf("http://%s/", r.Host),
+			FetchBaseURL: fmt.Sprintf("http://%s/aim/fetchEvents", r.Host),
+		}
+
+		// Add myInfo with user data
+		xmlResp.Data.MyInfo = &struct {
+			AimID     string `xml:"aimId"`
+			DisplayID string `xml:"displayId"`
+			Buddylist struct {
+				Groups *[]BuddyGroup `xml:"group,omitempty"`
+			} `xml:"buddylist,omitempty"`
+		}{
+			AimID:     session.ScreenName.String(),
+			DisplayID: session.ScreenName.String(),
+		}
+
+		// Add buddy list if requested in myInfo or events
+		for _, event := range events {
+			if event == "buddylist" || event == "myInfo" {
+				var buddyGroups []BuddyGroup
+
+				if authToken != "" && h.BuddyListManager != nil {
+					// Fetch actual buddy list from service
+					webAPIGroups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session.ScreenName.IdentScreenName())
+					if err != nil {
+						h.Logger.ErrorContext(ctx, "failed to get buddy list for XML response", "err", err.Error())
+						buddyGroups = []BuddyGroup{}
+					} else {
+						// Convert WebAPIBuddyGroup to handler.BuddyGroup
+						for _, webGroup := range webAPIGroups {
+							group := BuddyGroup{
+								Name:    webGroup.Name,
+								Buddies: []Buddy{},
+							}
+							for _, webBuddy := range webGroup.Buddies {
+								buddy := Buddy{
+									AimID:     webBuddy.AimID,
+									State:     webBuddy.State,
+									StatusMsg: webBuddy.StatusMsg,
+									AwayMsg:   webBuddy.AwayMsg,
+									UserType:  webBuddy.UserType,
+								}
+								group.Buddies = append(group.Buddies, buddy)
+							}
+							buddyGroups = append(buddyGroups, group)
+						}
+					}
+				} else {
+					buddyGroups = []BuddyGroup{}
+				}
+
+				// Add to myInfo buddylist
+				xmlResp.Data.MyInfo.Buddylist.Groups = &buddyGroups
+
+				// Also add to events if specifically requested
+				if event == "buddylist" {
+					if xmlResp.Data.Events == nil {
+						xmlResp.Data.Events = &struct {
+							BuddyList struct {
+								Groups *[]BuddyGroup `xml:"group,omitempty"`
+							} `xml:"buddylist"`
+						}{}
+					}
+					xmlResp.Data.Events.BuddyList.Groups = &buddyGroups
+				}
+				break
+			}
+		}
+
+		// Send XML response
+		w.Header().Set("Content-Type", "text/xml; charset=utf-8")
+
+		// Build complete XML string first
+		xmlData, err := xml.Marshal(xmlResp)
+		if err != nil {
+			h.Logger.Error("failed to marshal XML response", "error", err)
+			h.sendError(w, http.StatusInternalServerError, "internal server error")
+			return
+		}
+
+		// Write XML declaration and data as one response
+		xmlOutput := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>%s`, xmlData)
+		w.Header().Set("Content-Length", strconv.Itoa(len(xmlOutput)))
+		fmt.Fprint(w, xmlOutput)
+	} else {
+		// Send response in requested format (JSON, JSONP, or AMF)
+		SendResponse(w, r, resp, h.Logger)
+	}
+
+	h.Logger.DebugContext(ctx, "session started",
+		"aimsid", session.AimSID,
+		"screen_name", screenName,
+		"dev_id", apiKey.DevID,
+		"events", events,
+		"format", format,
+	)
+}
+
+// EndSession handles GET /aim/endSession requests.
+func (h *SessionHandler) EndSession(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get session ID from parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Get session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		if err == state.ErrNoWebAPISession {
+			h.sendError(w, http.StatusNotFound, "session not found")
+		} else if err == state.ErrWebAPISessionExpired {
+			h.sendError(w, http.StatusGone, "session expired")
+		} else {
+			h.sendError(w, http.StatusInternalServerError, "internal server error")
+		}
+		return
+	}
+
+	// Clean up OSCAR session if present
+	if session.OSCARSession != nil && h.OSCARSessionManager != nil {
+		// Broadcast departure to OSCAR clients
+		if h.BuddyBroadcaster != nil {
+			if err := h.BuddyBroadcaster.BroadcastBuddyDeparted(ctx, session.OSCARSession); err != nil {
+				h.Logger.ErrorContext(ctx, "failed to broadcast buddy departure", "err", err.Error())
+			}
+		}
+
+		// Unregister buddy list
+		if h.BuddyListRegistry != nil {
+			if err := h.BuddyListRegistry.UnregisterBuddyList(ctx, session.ScreenName.IdentScreenName()); err != nil {
+				h.Logger.ErrorContext(ctx, "failed to unregister buddy list", "err", err.Error())
+			}
+		}
+
+		// Remove OSCAR session
+		h.OSCARSessionManager.RemoveSession(session.OSCARSession)
+		session.OSCARSession = nil
+	}
+
+	// Remove session
+	if err := h.SessionManager.RemoveSession(r.Context(), aimsid); err != nil {
+		h.Logger.ErrorContext(ctx, "failed to remove session", "err", err.Error())
+		h.sendError(w, http.StatusInternalServerError, "failed to end session")
+		return
+	}
+
+	// Send response
+	resp := EndSessionResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+
+	// Send response in requested format (JSON, JSONP, or AMF)
+	SendResponse(w, r, resp, h.Logger)
+
+	h.Logger.DebugContext(ctx, "session ended",
+		"aimsid", aimsid,
+		"screen_name", session.ScreenName,
+	)
+}
+
+// sendError is a convenience method that wraps the common SendError function.
+func (h *SessionHandler) sendError(w http.ResponseWriter, statusCode int, message string) {
+	SendError(w, statusCode, message)
+}

+ 323 - 0
server/webapi/handlers/vanity.go

@@ -0,0 +1,323 @@
+package handlers
+
+import (
+	"log/slog"
+	"net/http"
+	"strings"
+
+	"github.com/mk6i/retro-aim-server/state"
+)
+
+// VanityHandler handles Web AIM API vanity URL endpoints.
+type VanityHandler struct {
+	SessionManager *state.WebAPISessionManager
+	VanityManager  *state.VanityURLManager
+	Logger         *slog.Logger
+}
+
+// GetVanityInfo handles GET /aim/getVanityInfo requests to retrieve vanity URL information.
+func (h *VanityHandler) GetVanityInfo(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// According to spec, this endpoint requires signed request parameters
+	// but we'll make them optional for compatibility
+	ts := r.URL.Query().Get("ts")
+	sig := r.URL.Query().Get("sig_sha256")
+
+	// Validate timestamp if provided
+	if ts != "" && sig == "" {
+		SendError(w, http.StatusBadRequest, "signature required when timestamp provided")
+		return
+	}
+
+	// Get authentication from either aimsid or token
+	aimsid := r.URL.Query().Get("aimsid")
+	_ = r.URL.Query().Get("a") // Token auth not fully implemented
+
+	var screenName string
+	if aimsid != "" {
+		session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+		if err == nil {
+			screenName = session.ScreenName.String()
+		}
+	}
+
+	// If no explicit target, use authenticated user
+	targetUser := r.URL.Query().Get("t")
+	if targetUser == "" && screenName != "" {
+		targetUser = screenName
+	}
+
+	if targetUser == "" {
+		SendError(w, http.StatusBadRequest, "missing target user")
+		return
+	}
+
+	h.Logger.DebugContext(ctx, "retrieving vanity info",
+		"targetUser", targetUser,
+		"authenticated", screenName,
+	)
+
+	// Lookup vanity info by screen name
+	info, err := h.VanityManager.GetVanityInfoByScreenName(ctx, targetUser)
+
+	// Handle error or no vanity URL found
+	if err != nil || info == nil {
+		if err != nil && !strings.Contains(err.Error(), "not found") {
+			h.Logger.ErrorContext(ctx, "failed to get vanity info",
+				"error", err,
+			)
+			SendError(w, http.StatusInternalServerError, "failed to retrieve vanity info")
+			return
+		}
+
+		// No vanity URL configured - return not found
+		response := BaseResponse{}
+		response.Response.StatusCode = 200
+		response.Response.StatusText = "OK"
+		response.Response.Data = map[string]interface{}{
+			"found":      false,
+			"screenName": targetUser,
+		}
+		SendResponse(w, r, response, h.Logger)
+		return
+	}
+
+	// Build response
+	responseData := map[string]interface{}{
+		"found":      true,
+		"screenName": info.ScreenName,
+		"vanityUrl":  info.VanityURL,
+		"profileUrl": info.ProfileURL,
+		"isActive":   info.IsActive,
+	}
+
+	// Add optional fields if present
+	if info.DisplayName != "" {
+		responseData["displayName"] = info.DisplayName
+	}
+	if info.Bio != "" {
+		responseData["bio"] = info.Bio
+	}
+	if info.Location != "" {
+		responseData["location"] = info.Location
+	}
+	if info.Website != "" {
+		responseData["website"] = info.Website
+	}
+
+	// Add extra data if present
+	if info.Extra != nil {
+		for k, v := range info.Extra {
+			responseData[k] = v
+		}
+	}
+
+	response := BaseResponse{}
+	response.Response.StatusCode = 200
+	response.Response.StatusText = "OK"
+	response.Response.Data = responseData
+
+	SendResponse(w, r, response, h.Logger)
+}
+
+// SetVanityURL handles requests to set or update a vanity URL (requires authentication).
+func (h *VanityHandler) SetVanityURL(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Authentication required
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		SendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Get session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		SendError(w, http.StatusUnauthorized, "invalid or expired session")
+		return
+	}
+
+	// Update session activity
+	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
+		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
+	}
+
+	// Get vanity URL from parameters
+	vanityURL := r.URL.Query().Get("vanityUrl")
+	if vanityURL == "" {
+		SendError(w, http.StatusBadRequest, "missing vanityUrl parameter")
+		return
+	}
+
+	// Collect optional profile information
+	info := make(map[string]interface{})
+	if displayName := r.URL.Query().Get("displayName"); displayName != "" {
+		info["displayName"] = displayName
+	}
+	if bio := r.URL.Query().Get("bio"); bio != "" {
+		info["bio"] = bio
+	}
+	if location := r.URL.Query().Get("location"); location != "" {
+		info["location"] = location
+	}
+	if website := r.URL.Query().Get("website"); website != "" {
+		info["website"] = website
+	}
+
+	h.Logger.InfoContext(ctx, "setting vanity URL",
+		"screenName", session.ScreenName.String(),
+		"vanityUrl", vanityURL,
+	)
+
+	// Create or update the vanity URL
+	if err := h.VanityManager.CreateOrUpdateVanityURL(ctx, session.ScreenName.String(), vanityURL, info); err != nil {
+		h.Logger.ErrorContext(ctx, "failed to set vanity URL",
+			"screenName", session.ScreenName.String(),
+			"vanityUrl", vanityURL,
+			"error", err,
+		)
+
+		// Check if it's a validation or duplicate error
+		if strings.Contains(err.Error(), "reserved") ||
+			strings.Contains(err.Error(), "already taken") ||
+			strings.Contains(err.Error(), "must be") ||
+			strings.Contains(err.Error(), "cannot") {
+			SendError(w, http.StatusBadRequest, err.Error())
+			return
+		}
+
+		SendError(w, http.StatusInternalServerError, "failed to set vanity URL")
+		return
+	}
+
+	// Return success response with the new vanity info
+	vanityInfo, _ := h.VanityManager.GetVanityInfoByScreenName(ctx, session.ScreenName.String())
+
+	responseData := map[string]interface{}{
+		"success":    true,
+		"screenName": session.ScreenName.String(),
+		"vanityUrl":  vanityURL,
+	}
+
+	if vanityInfo != nil {
+		responseData["profileUrl"] = vanityInfo.ProfileURL
+	}
+
+	response := BaseResponse{}
+	response.Response.StatusCode = 200
+	response.Response.StatusText = "OK"
+	response.Response.Data = responseData
+
+	SendResponse(w, r, response, h.Logger)
+}
+
+// CheckAvailability handles requests to check if a vanity URL is available.
+func (h *VanityHandler) CheckAvailability(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get vanity URL from parameters
+	vanityURL := r.URL.Query().Get("vanityUrl")
+	if vanityURL == "" {
+		SendError(w, http.StatusBadRequest, "missing vanityUrl parameter")
+		return
+	}
+
+	h.Logger.DebugContext(ctx, "checking vanity URL availability",
+		"vanityUrl", vanityURL,
+	)
+
+	// Check availability
+	available, err := h.VanityManager.CheckAvailability(ctx, vanityURL)
+	if err != nil {
+		// If it's a validation error, return it as a bad request
+		if strings.Contains(err.Error(), "must be") ||
+			strings.Contains(err.Error(), "cannot") ||
+			strings.Contains(err.Error(), "can only") {
+			response := BaseResponse{}
+			response.Response.StatusCode = 200
+			response.Response.StatusText = "OK"
+			response.Response.Data = map[string]interface{}{
+				"available": false,
+				"reason":    err.Error(),
+			}
+			SendResponse(w, r, response, h.Logger)
+			return
+		}
+
+		h.Logger.ErrorContext(ctx, "failed to check availability",
+			"vanityUrl", vanityURL,
+			"error", err,
+		)
+		SendError(w, http.StatusInternalServerError, "failed to check availability")
+		return
+	}
+
+	// Build response
+	responseData := map[string]interface{}{
+		"available": available,
+		"vanityUrl": vanityURL,
+	}
+
+	if !available {
+		responseData["reason"] = "This vanity URL is already taken or reserved"
+	}
+
+	response := BaseResponse{}
+	response.Response.StatusCode = 200
+	response.Response.StatusText = "OK"
+	response.Response.Data = responseData
+
+	SendResponse(w, r, response, h.Logger)
+}
+
+// DeleteVanityURL handles requests to delete a vanity URL (requires authentication).
+func (h *VanityHandler) DeleteVanityURL(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Authentication required
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		SendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Get session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		SendError(w, http.StatusUnauthorized, "invalid or expired session")
+		return
+	}
+
+	// Update session activity
+	if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
+		h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
+	}
+
+	h.Logger.InfoContext(ctx, "deleting vanity URL",
+		"screenName", session.ScreenName.String(),
+	)
+
+	// Delete the vanity URL
+	if err := h.VanityManager.DeleteVanityURL(ctx, session.ScreenName.String()); err != nil {
+		h.Logger.ErrorContext(ctx, "failed to delete vanity URL",
+			"screenName", session.ScreenName.String(),
+			"error", err,
+		)
+		SendError(w, http.StatusInternalServerError, "failed to delete vanity URL")
+		return
+	}
+
+	// Return success response
+	response := BaseResponse{}
+	response.Response.StatusCode = 200
+	response.Response.StatusText = "OK"
+	response.Response.Data = map[string]interface{}{
+		"success":    true,
+		"screenName": session.ScreenName.String(),
+		"message":    "Vanity URL deleted successfully",
+	}
+
+	SendResponse(w, r, response, h.Logger)
+}

+ 212 - 0
server/webapi/handlers/webapi_event_converter.go

@@ -0,0 +1,212 @@
+package handlers
+
+import "github.com/mk6i/retro-aim-server/server/webapi/types"
+
+// ConvertEventForAMF3 converts a WebAPIEvent to a map suitable for AMF3 encoding,
+// ensuring all timestamps are float64 to avoid uint29 overflow issues.
+func ConvertEventForAMF3(event types.Event) map[string]interface{} {
+	result := map[string]interface{}{
+		"type":      string(event.Type),
+		"seqNum":    event.SeqNum,
+		"timestamp": float64(event.Timestamp), // Convert to float64
+	}
+
+	// Convert event data based on type
+	switch event.Type {
+	case types.EventTypeIM:
+		if imEvent, ok := event.Data.(types.IMEvent); ok {
+			// Gromit expects 'source' as a user object and 'autoresponse' (lowercase)
+			result["eventData"] = map[string]interface{}{
+				"source": map[string]interface{}{
+					"aimId": imEvent.From,
+				},
+				"message":      imEvent.Message,
+				"timestamp":    imEvent.Timestamp, // Already float64
+				"autoresponse": imEvent.AutoResp,
+			}
+		} else if dataMap, ok := event.Data.(map[string]interface{}); ok {
+			// Already a map, ensure timestamps are float64
+			if ts, exists := dataMap["timestamp"]; exists {
+				if tsInt, ok := ts.(int64); ok {
+					dataMap["timestamp"] = float64(tsInt)
+				}
+			}
+			result["eventData"] = dataMap
+		} else {
+			result["eventData"] = event.Data
+		}
+
+	case types.EventTypeOfflineIM:
+		if imEvent, ok := event.Data.(types.IMEvent); ok {
+			result["eventData"] = map[string]interface{}{
+				"aimId":     imEvent.From,
+				"message":   imEvent.Message,
+				"timestamp": float64(imEvent.Timestamp), // Convert to float64
+			}
+		} else if dataMap, ok := event.Data.(map[string]interface{}); ok {
+			// Already a map, ensure timestamps are float64
+			if ts, exists := dataMap["timestamp"]; exists {
+				if tsInt, ok := ts.(int64); ok {
+					dataMap["timestamp"] = float64(tsInt)
+				}
+			}
+			result["eventData"] = dataMap
+		} else {
+			result["eventData"] = event.Data
+		}
+
+	case types.EventTypePresence:
+		if presenceEvent, ok := event.Data.(types.PresenceEvent); ok {
+			eventData := map[string]interface{}{
+				"aimId":    presenceEvent.AimID,
+				"state":    presenceEvent.State,
+				"userType": presenceEvent.UserType,
+			}
+			// Convert timestamp fields to float64
+			if presenceEvent.OnlineTime > 0 {
+				eventData["onlineTime"] = float64(presenceEvent.OnlineTime)
+			}
+			result["eventData"] = eventData
+		} else if dataMap, ok := event.Data.(map[string]interface{}); ok {
+			// Already a map, ensure timestamps are float64
+			if ot, exists := dataMap["onlineTime"]; exists {
+				if otInt, ok := ot.(int64); ok {
+					dataMap["onlineTime"] = float64(otInt)
+				}
+			}
+			result["eventData"] = dataMap
+		} else {
+			result["eventData"] = event.Data
+		}
+
+	case types.EventType("myInfo"):
+		// MyInfo events often contain timestamps
+		if dataMap, ok := event.Data.(map[string]interface{}); ok {
+			// Convert any int64 timestamps to float64
+			for key, val := range dataMap {
+				if key == "onlineTime" || key == "memberSince" || key == "awayTime" || key == "statusTime" {
+					if intVal, ok := val.(int64); ok {
+						dataMap[key] = float64(intVal)
+					}
+				}
+			}
+			result["eventData"] = dataMap
+		} else {
+			result["eventData"] = event.Data
+		}
+
+	case types.EventTypeBuddyList:
+		// Buddy list events are already converted to maps in FormatBuddyListEvent
+		// Just pass through
+		result["eventData"] = event.Data
+
+	case types.EventTypeTyping:
+		if typingEvent, ok := event.Data.(types.TypingEvent); ok {
+			// Gromit expects 'aimId' and 'typingStatus'
+			result["eventData"] = map[string]interface{}{
+				"aimId":        typingEvent.From,
+				"typingStatus": typingEvent.Typing,
+			}
+		} else {
+			result["eventData"] = event.Data
+		}
+
+	case types.EventTypeSentIM:
+		if sentIMEvent, ok := event.Data.(types.SentIMEvent); ok {
+			// Gromit expects both 'source' (sender) and 'dest' (recipient) for sentIM
+			// The parseIM function needs source even for outgoing messages
+			result["eventData"] = map[string]interface{}{
+				"source": map[string]interface{}{
+					"aimId":     sentIMEvent.Sender.AimID,
+					"displayId": sentIMEvent.Sender.DisplayID,
+					"userType":  sentIMEvent.Sender.UserType,
+					"state":     "online",
+				},
+				"dest": map[string]interface{}{
+					"aimId":     sentIMEvent.Dest.AimID,
+					"displayId": sentIMEvent.Dest.DisplayID,
+					"userType":  sentIMEvent.Dest.UserType,
+					"state":     "online",
+				},
+				"message":      sentIMEvent.Message,
+				"timestamp":    sentIMEvent.Timestamp, // Already float64
+				"autoresponse": sentIMEvent.AutoResp,
+			}
+		} else {
+			result["eventData"] = event.Data
+		}
+
+	default:
+		// For unknown types, check if data is a map and convert any int64 values
+		if dataMap, ok := event.Data.(map[string]interface{}); ok {
+			result["eventData"] = convertTimestampsInMap(dataMap)
+		} else {
+			result["eventData"] = event.Data
+		}
+	}
+
+	return result
+}
+
+// convertTimestampsInMap recursively converts int64 values that look like timestamps to float64
+func convertTimestampsInMap(data map[string]interface{}) map[string]interface{} {
+	result := make(map[string]interface{})
+	for key, val := range data {
+		// Check if key suggests it's a timestamp
+		if isTimestampField(key) {
+			if intVal, ok := val.(int64); ok {
+				result[key] = float64(intVal)
+				continue
+			}
+		}
+
+		// Recursively process nested maps
+		if nestedMap, ok := val.(map[string]interface{}); ok {
+			result[key] = convertTimestampsInMap(nestedMap)
+		} else if nestedSlice, ok := val.([]interface{}); ok {
+			convertedSlice := make([]interface{}, len(nestedSlice))
+			for i, item := range nestedSlice {
+				if itemMap, ok := item.(map[string]interface{}); ok {
+					convertedSlice[i] = convertTimestampsInMap(itemMap)
+				} else {
+					convertedSlice[i] = item
+				}
+			}
+			result[key] = convertedSlice
+		} else {
+			result[key] = val
+		}
+	}
+	return result
+}
+
+// isTimestampField checks if a field name suggests it contains a timestamp
+func isTimestampField(fieldName string) bool {
+	timestampFields := []string{
+		"timestamp", "Timestamp",
+		"onlineTime", "OnlineTime",
+		"memberSince", "MemberSince",
+		"awayTime", "AwayTime",
+		"statusTime", "StatusTime",
+		"idleTime", "IdleTime",
+		"loginTime", "LoginTime",
+		"createdAt", "CreatedAt",
+		"updatedAt", "UpdatedAt",
+	}
+
+	for _, tf := range timestampFields {
+		if fieldName == tf {
+			return true
+		}
+	}
+	return false
+}
+
+// ConvertEventsForAMF3 converts a slice of WebAPIEvents for AMF3 encoding
+func ConvertEventsForAMF3(events []types.Event) []interface{} {
+	result := make([]interface{}, len(events))
+	for i, event := range events {
+		result[i] = ConvertEventForAMF3(event)
+	}
+	return result
+}

+ 493 - 0
server/webapi/middleware/auth.go

@@ -0,0 +1,493 @@
+package middleware
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/patrickmn/go-cache"
+	"golang.org/x/time/rate"
+
+	"github.com/mk6i/retro-aim-server/state"
+)
+
+// contextKey is a custom type for context keys to avoid collisions.
+type contextKey string
+
+const (
+	// ContextKeyAPIKey is the context key for storing the validated API key.
+	ContextKeyAPIKey contextKey = "api_key"
+	// ContextKeyDevID is the context key for storing the developer ID.
+	ContextKeyDevID contextKey = "dev_id"
+)
+
+// APIKeyValidator defines methods for validating Web API keys.
+type APIKeyValidator interface {
+	// GetAPIKeyByDevKey retrieves and validates an API key by its dev_key value.
+	GetAPIKeyByDevKey(ctx context.Context, devKey string) (*state.WebAPIKey, error)
+	// UpdateLastUsed updates the last_used timestamp for an API key.
+	UpdateLastUsed(ctx context.Context, devKey string) error
+}
+
+// RateLimitInfo contains rate limit metadata for a request.
+type RateLimitInfo struct {
+	Limit     int   // Total requests allowed per window
+	Remaining int   // Requests remaining in current window
+	Reset     int64 // Unix timestamp when the window resets
+	Allowed   bool  // Whether the request is allowed
+}
+
+// rateLimiterEntry tracks rate limiting data for a single devID.
+type rateLimiterEntry struct {
+	limiter    *rate.Limiter
+	limit      int
+	windowSize time.Duration
+	lastReset  time.Time
+}
+
+// RateLimiter manages per-devID rate limiting for the Web API.
+type RateLimiter struct {
+	limiters   *cache.Cache
+	mu         sync.RWMutex
+	windowSize time.Duration // Rate limit window size (default: 1 minute)
+}
+
+// NewRateLimiter creates a new rate limiter with automatic cleanup.
+func NewRateLimiter() *RateLimiter {
+	// Create cache with 5 minute expiration and 10 minute cleanup interval
+	c := cache.New(5*time.Minute, 10*time.Minute)
+	return &RateLimiter{
+		limiters:   c,
+		windowSize: time.Minute, // Default 1 minute window
+	}
+}
+
+// CheckRateLimit checks if a request from the given devID is allowed and returns rate limit info.
+func (r *RateLimiter) CheckRateLimit(devID string, limit int) RateLimitInfo {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+
+	now := time.Now()
+
+	// Get or create limiter entry for this devID
+	var entry *rateLimiterEntry
+	if val, found := r.limiters.Get(devID); found {
+		entry = val.(*rateLimiterEntry)
+		// Check if limit has changed
+		if entry.limit != limit {
+			// Recreate limiter with new limit
+			entry.limiter = rate.NewLimiter(rate.Every(r.windowSize/time.Duration(limit)), limit)
+			entry.limit = limit
+		}
+	} else {
+		// Create new limiter with burst equal to limit (allows initial burst)
+		entry = &rateLimiterEntry{
+			limiter:    rate.NewLimiter(rate.Every(r.windowSize/time.Duration(limit)), limit),
+			limit:      limit,
+			windowSize: r.windowSize,
+			lastReset:  now,
+		}
+		r.limiters.Set(devID, entry, cache.DefaultExpiration)
+	}
+
+	// Check if request is allowed
+	allowed := entry.limiter.Allow()
+
+	// Calculate remaining requests (approximate based on tokens available)
+	tokens := entry.limiter.Tokens()
+	remaining := int(tokens)
+	if remaining < 0 {
+		remaining = 0
+	}
+
+	// Calculate reset time (next window start)
+	resetTime := now.Add(r.windowSize).Unix()
+
+	return RateLimitInfo{
+		Limit:     limit,
+		Remaining: remaining,
+		Reset:     resetTime,
+		Allowed:   allowed,
+	}
+}
+
+// Allow checks if a request from the given devID is allowed based on rate limits.
+func (r *RateLimiter) Allow(devID string, limit int) bool {
+	info := r.CheckRateLimit(devID, limit)
+	return info.Allowed
+}
+
+// AuthMiddleware provides authentication and rate limiting for Web API endpoints.
+type AuthMiddleware struct {
+	Validator   APIKeyValidator
+	RateLimiter *RateLimiter
+	Logger      *slog.Logger
+}
+
+// NewAuthMiddleware creates a new authentication middleware instance.
+func NewAuthMiddleware(validator APIKeyValidator, logger *slog.Logger) *AuthMiddleware {
+	return &AuthMiddleware{
+		Validator:   validator,
+		RateLimiter: NewRateLimiter(),
+		Logger:      logger,
+	}
+}
+
+// Authenticate is an HTTP middleware that validates API keys and enforces rate limits.
+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")
+		}
+
+		if apiKey == "" {
+			m.sendErrorResponse(w, http.StatusBadRequest, "required parameter 'k' is missing")
+			return
+		}
+
+		// Validate API key
+		ctx := r.Context()
+		key, err := m.Validator.GetAPIKeyByDevKey(ctx, apiKey)
+		if err != nil {
+			if err == state.ErrNoAPIKey {
+				m.Logger.DebugContext(ctx, "invalid API key attempted", "key", apiKey[:min(8, len(apiKey))]+"...")
+				m.sendErrorResponse(w, http.StatusForbidden, "invalid API key")
+				return
+			}
+			m.Logger.ErrorContext(ctx, "error validating API key", "err", err.Error())
+			m.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
+			return
+		}
+
+		// Check if key is active
+		if !key.IsActive {
+			m.Logger.DebugContext(ctx, "inactive API key used", "dev_id", key.DevID)
+			m.sendErrorResponse(w, http.StatusForbidden, "API key is inactive")
+			return
+		}
+
+		// Check rate limit
+		rateLimitInfo := m.RateLimiter.CheckRateLimit(key.DevID, key.RateLimit)
+
+		// Always add rate limit headers
+		w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", rateLimitInfo.Limit))
+		w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", rateLimitInfo.Remaining))
+		w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", rateLimitInfo.Reset))
+
+		if !rateLimitInfo.Allowed {
+			m.Logger.WarnContext(ctx, "rate limit exceeded", "dev_id", key.DevID, "limit", key.RateLimit)
+			// Add Retry-After header
+			retryAfter := rateLimitInfo.Reset - time.Now().Unix()
+			if retryAfter < 1 {
+				retryAfter = 1
+			}
+			w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
+			m.sendErrorResponse(w, http.StatusTooManyRequests, "rate limit exceeded")
+			return
+		}
+
+		// Update last used timestamp asynchronously
+		go func() {
+			if err := m.Validator.UpdateLastUsed(context.Background(), apiKey); err != nil {
+				m.Logger.Error("failed to update last_used timestamp", "err", err.Error())
+			}
+		}()
+
+		// Add API key info to context for use in handlers
+		ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
+		ctx = context.WithValue(ctx, ContextKeyDevID, key.DevID)
+
+		// Log the API request
+		m.Logger.InfoContext(ctx, "API request authenticated",
+			"dev_id", key.DevID,
+			"app_name", key.AppName,
+			"method", r.Method,
+			"path", r.URL.Path,
+		)
+
+		// Pass to next handler with enriched context
+		next.ServeHTTP(w, r.WithContext(ctx))
+	})
+}
+
+// CORSMiddleware handles CORS headers based on allowed origins for the API key.
+func (m *AuthMiddleware) CORSMiddleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// Get API key from context (set by Authenticate middleware)
+		key, ok := r.Context().Value(ContextKeyAPIKey).(*state.WebAPIKey)
+
+		// If no API key in context (e.g., using aimsid auth), allow all origins
+		// This is safe because the actual authentication is handled by the session
+		var allowedOrigins []string
+		if ok && key != nil {
+			allowedOrigins = key.AllowedOrigins
+		} else {
+			// For session-based auth without API key, allow all origins
+			// The session itself provides the security boundary
+			m.Logger.DebugContext(r.Context(), "CORS handling for non-API-key auth (aimsid/token)")
+			allowedOrigins = []string{"*"}
+		}
+
+		origin := r.Header.Get("Origin")
+
+		// Check if origin is allowed
+		if m.isOriginAllowed(origin, allowedOrigins) {
+			if len(allowedOrigins) == 1 && allowedOrigins[0] == "*" {
+				// For wildcard, set the actual origin to allow credentials
+				if origin != "" {
+					w.Header().Set("Access-Control-Allow-Origin", origin)
+				} else {
+					w.Header().Set("Access-Control-Allow-Origin", "*")
+				}
+			} else {
+				w.Header().Set("Access-Control-Allow-Origin", origin)
+			}
+			w.Header().Set("Access-Control-Allow-Credentials", "true")
+			w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
+			w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
+			w.Header().Set("Access-Control-Max-Age", "3600")
+		}
+
+		// Handle preflight requests
+		if r.Method == "OPTIONS" {
+			w.WriteHeader(http.StatusNoContent)
+			return
+		}
+
+		next.ServeHTTP(w, r)
+	})
+}
+
+// CapabilitiesMiddleware checks if the API key has the required capability for an endpoint.
+func (m *AuthMiddleware) CapabilitiesMiddleware(requiredCapability string) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			// Get API key from context
+			key, ok := r.Context().Value(ContextKeyAPIKey).(*state.WebAPIKey)
+			if !ok {
+				m.Logger.Error("CapabilitiesMiddleware called without authentication context")
+				http.Error(w, "internal server error", http.StatusInternalServerError)
+				return
+			}
+
+			// If no capabilities are defined, allow all (backward compatibility)
+			if len(key.Capabilities) == 0 {
+				next.ServeHTTP(w, r)
+				return
+			}
+
+			// Check if required capability is present
+			hasCapability := false
+			for _, cap := range key.Capabilities {
+				if cap == requiredCapability || cap == "*" {
+					hasCapability = true
+					break
+				}
+			}
+
+			if !hasCapability {
+				m.Logger.WarnContext(r.Context(), "capability check failed",
+					"dev_id", key.DevID,
+					"required", requiredCapability,
+					"available", key.Capabilities,
+				)
+				m.sendErrorResponse(w, http.StatusForbidden, fmt.Sprintf("missing required capability: %s", requiredCapability))
+				return
+			}
+
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
+// isOriginAllowed checks if an origin is in the allowed list.
+func (m *AuthMiddleware) isOriginAllowed(origin string, allowedOrigins []string) bool {
+	// If no origins specified, allow all (for backward compatibility/development)
+	if len(allowedOrigins) == 0 {
+		return true
+	}
+
+	origin = strings.ToLower(origin)
+	for _, allowed := range allowedOrigins {
+		allowed = strings.ToLower(allowed)
+
+		// Exact match
+		if origin == allowed {
+			return true
+		}
+
+		// Wildcard support (e.g., "*.example.com")
+		if strings.HasPrefix(allowed, "*.") {
+			domain := allowed[2:]
+			if strings.HasSuffix(origin, domain) {
+				return true
+			}
+		}
+
+		// Allow all origins (development only)
+		if allowed == "*" {
+			m.Logger.Warn("wildcard origin (*) used - should not be used in production")
+			return true
+		}
+	}
+
+	return false
+}
+
+// sendErrorResponse sends a JSON error response.
+func (m *AuthMiddleware) sendErrorResponse(w http.ResponseWriter, statusCode int, message string) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(statusCode)
+
+	response := map[string]interface{}{
+		"error": message,
+		"code":  statusCode,
+	}
+
+	if err := json.NewEncoder(w).Encode(response); err != nil {
+		m.Logger.Error("failed to encode error response", "err", err.Error())
+	}
+}
+
+// GetAPIKeyFromContext retrieves the API key from the request context.
+func GetAPIKeyFromContext(ctx context.Context) (*state.WebAPIKey, bool) {
+	key, ok := ctx.Value(ContextKeyAPIKey).(*state.WebAPIKey)
+	return key, ok
+}
+
+// GetDevIDFromContext retrieves the developer ID from the request context.
+func GetDevIDFromContext(ctx context.Context) (string, bool) {
+	devID, ok := ctx.Value(ContextKeyDevID).(string)
+	return devID, ok
+}
+
+// min returns the minimum of two integers.
+func min(a, b int) int {
+	if a < b {
+		return a
+	}
+	return b
+}
+
+// AuthenticateFlexible is an HTTP middleware that supports multiple authentication methods:
+// 1. aimsid (session ID) - no k required
+// 2. a (AOL token) - no k required
+// 3. ts + sig_sha256 (signed request) - no k required
+// 4. k (API key) - fallback if no other auth provided
+// This follows the Web AIM API specification where k is not required when aimsid is present.
+func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		ctx := r.Context()
+
+		// 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 != "" {
+			// 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))]+"...")
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		// Priority 2: Check for AOL token auth
+		if token := r.URL.Query().Get("a"); token != "" {
+			// Token auth is present, but we still need to validate the API key
+			// The token provides user authentication while the API key identifies the app
+			m.Logger.DebugContext(ctx, "token authentication detected, will validate API key as well")
+			// Don't return here - continue to API key validation below
+		}
+
+		// Priority 3: Check for signed request auth
+		if ts := r.URL.Query().Get("ts"); ts != "" {
+			if sig := r.URL.Query().Get("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
+				m.Logger.DebugContext(ctx, "signed request detected, falling through to API key validation")
+				// Don't return here - continue to API key validation below
+			}
+		}
+
+		// 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")
+		}
+
+		if apiKey == "" {
+			m.sendErrorResponse(w, http.StatusBadRequest, "authentication required: provide aimsid or k parameter")
+			return
+		}
+
+		// Validate API key as before
+		key, err := m.Validator.GetAPIKeyByDevKey(ctx, apiKey)
+		if err != nil {
+			if err == state.ErrNoAPIKey {
+				m.Logger.DebugContext(ctx, "invalid API key attempted", "key", apiKey[:min(8, len(apiKey))]+"...")
+				m.sendErrorResponse(w, http.StatusForbidden, "invalid API key")
+				return
+			}
+			m.Logger.ErrorContext(ctx, "error validating API key", "err", err.Error())
+			m.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
+			return
+		}
+
+		// Check if key is active
+		if !key.IsActive {
+			m.Logger.DebugContext(ctx, "inactive API key used", "dev_id", key.DevID)
+			m.sendErrorResponse(w, http.StatusForbidden, "API key is inactive")
+			return
+		}
+
+		// Check rate limit
+		rateLimitInfo := m.RateLimiter.CheckRateLimit(key.DevID, key.RateLimit)
+
+		// Always add rate limit headers
+		w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", rateLimitInfo.Limit))
+		w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", rateLimitInfo.Remaining))
+		w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", rateLimitInfo.Reset))
+
+		if !rateLimitInfo.Allowed {
+			m.Logger.WarnContext(ctx, "rate limit exceeded", "dev_id", key.DevID, "limit", key.RateLimit)
+			// Add Retry-After header
+			retryAfter := rateLimitInfo.Reset - time.Now().Unix()
+			if retryAfter < 1 {
+				retryAfter = 1
+			}
+			w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
+			m.sendErrorResponse(w, http.StatusTooManyRequests, "rate limit exceeded")
+			return
+		}
+
+		// Update last used timestamp asynchronously
+		go func() {
+			if err := m.Validator.UpdateLastUsed(context.Background(), apiKey); err != nil {
+				m.Logger.Error("failed to update last_used timestamp", "err", err.Error())
+			}
+		}()
+
+		// Add API key info to context for use in handlers
+		ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
+		ctx = context.WithValue(ctx, ContextKeyDevID, key.DevID)
+
+		// Log the API request
+		m.Logger.InfoContext(ctx, "API request authenticated via key",
+			"dev_id", key.DevID,
+			"app_name", key.AppName,
+			"method", r.Method,
+			"path", r.URL.Path,
+		)
+
+		// Pass to next handler with enriched context
+		next.ServeHTTP(w, r.WithContext(ctx))
+	})
+}

+ 140 - 0
server/webapi/oscar_config.go

@@ -0,0 +1,140 @@
+package webapi
+
+import (
+	"net"
+	"strconv"
+	"strings"
+
+	"github.com/mk6i/retro-aim-server/config"
+)
+
+// OSCARConfigAdapter adapts the main server configuration to provide
+// OSCAR-specific configuration for the Web API bridge.
+type OSCARConfigAdapter struct {
+	cfg       config.Config
+	listeners []config.Listener
+}
+
+// NewOSCARConfigAdapter creates a new OSCAR configuration adapter.
+func NewOSCARConfigAdapter(cfg config.Config) *OSCARConfigAdapter {
+	listeners, _ := cfg.ParseListenersCfg()
+	return &OSCARConfigAdapter{
+		cfg:       cfg,
+		listeners: listeners,
+	}
+}
+
+// GetBOSAddress returns the plain (non-SSL) BOS server address for client connections.
+// This parses the configured BOS advertised host to extract the hostname and port.
+func (a *OSCARConfigAdapter) GetBOSAddress() (host string, port int) {
+	// Default to first listener configuration
+	if len(a.listeners) == 0 {
+		return "localhost", 5190 // Default OSCAR port
+	}
+
+	listener := a.listeners[0]
+
+	// Parse the advertised host for plain connections
+	if listener.BOSAdvertisedHostPlain != "" {
+		host, portStr := splitHostPort(listener.BOSAdvertisedHostPlain)
+		if portStr != "" {
+			if p, err := strconv.Atoi(portStr); err == nil {
+				port = p
+			}
+		}
+		if port == 0 {
+			port = 5190 // Default OSCAR port
+		}
+		return host, port
+	}
+
+	// Fall back to parsing the listen address
+	if listener.BOSListenAddress != "" {
+		host, portStr, err := net.SplitHostPort(listener.BOSListenAddress)
+		if err == nil {
+			if host == "" {
+				host = "localhost"
+			}
+			if p, err := strconv.Atoi(portStr); err == nil {
+				port = p
+			}
+		}
+		if port == 0 {
+			port = 5190
+		}
+		return host, port
+	}
+
+	return "localhost", 5190
+}
+
+// GetSSLBOSAddress returns the SSL-enabled BOS server address for client connections.
+func (a *OSCARConfigAdapter) GetSSLBOSAddress() (host string, port int) {
+	// Default to first listener configuration with SSL
+	for _, listener := range a.listeners {
+		if listener.HasSSL && listener.BOSAdvertisedHostSSL != "" {
+			host, portStr := splitHostPort(listener.BOSAdvertisedHostSSL)
+			if portStr != "" {
+				if p, err := strconv.Atoi(portStr); err == nil {
+					port = p
+				}
+			}
+			if port == 0 {
+				port = 5190 // Default OSCAR SSL port (could be different)
+			}
+			return host, port
+		}
+	}
+
+	// Fall back to plain address if no SSL configured
+	return a.GetBOSAddress()
+}
+
+// IsSSLAvailable checks if any listener has SSL configured.
+func (a *OSCARConfigAdapter) IsSSLAvailable() bool {
+	for _, listener := range a.listeners {
+		if listener.HasSSL {
+			return true
+		}
+	}
+	return false
+}
+
+// IsAuthDisabled returns whether authentication is disabled.
+func (a *OSCARConfigAdapter) IsAuthDisabled() bool {
+	return a.cfg.DisableAuth
+}
+
+// splitHostPort splits a host:port string, handling IPv6 addresses correctly.
+// Unlike net.SplitHostPort, this doesn't return an error for missing ports.
+func splitHostPort(hostport string) (host string, port string) {
+	// Handle IPv6 addresses
+	if strings.HasPrefix(hostport, "[") {
+		endIdx := strings.LastIndex(hostport, "]")
+		if endIdx != -1 {
+			host = hostport[1:endIdx]
+			if endIdx+1 < len(hostport) && hostport[endIdx+1] == ':' {
+				port = hostport[endIdx+2:]
+			}
+			return
+		}
+	}
+
+	// Handle IPv4 and hostnames
+	lastColon := strings.LastIndex(hostport, ":")
+	if lastColon != -1 {
+		// Check if this might be an IPv6 address without brackets
+		if strings.Count(hostport, ":") > 1 {
+			// Multiple colons, likely IPv6 without port
+			host = hostport
+			return
+		}
+		host = hostport[:lastColon]
+		port = hostport[lastColon+1:]
+		return
+	}
+
+	// No port specified
+	host = hostport
+	return
+}

+ 209 - 1
server/webapi/server.go

@@ -8,15 +8,223 @@ import (
 	"net/http"
 
 	"golang.org/x/sync/errgroup"
+
+	"github.com/mk6i/retro-aim-server/server/webapi/handlers"
+	"github.com/mk6i/retro-aim-server/server/webapi/middleware"
+	"github.com/mk6i/retro-aim-server/state"
 )
 
-func NewServer(listeners []string, logger *slog.Logger, handler Handler) *Server {
+func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyValidator middleware.APIKeyValidator, sessionManager *state.WebAPISessionManager) *Server {
 	servers := make([]*http.Server, 0, len(listeners))
 
+	// Create authentication middleware
+	authMiddleware := middleware.NewAuthMiddleware(apiKeyValidator, logger)
+
+	// Create handlers
+	authHandler := &handlers.AuthHandler{
+		UserManager: handler.UserManager,
+		TokenStore:  handler.TokenStore,
+		Logger:      logger,
+		DisableAuth: handler.OSCARConfig.IsAuthDisabled(),
+	}
+
+	sessionHandler := &handlers.SessionHandler{
+		SessionManager:      sessionManager,
+		OSCARSessionManager: handler.SessionRetriever.(handlers.SessionManager),
+		OSCARAuthService:    handler.AuthService,
+		BuddyListService:    nil,
+		BuddyListRegistry:   handler.BuddyListRegistry,
+		BuddyBroadcaster:    handler.BuddyBroadcaster,
+		BuddyListManager:    handler.BuddyListManager.(*handlers.BuddyListManager),
+		TokenStore:          handler.TokenStore,
+		Logger:              logger,
+	}
+
+	eventsHandler := &handlers.EventsHandler{
+		SessionManager: sessionManager,
+		Logger:         logger,
+	}
+
+	presenceHandler := &handlers.PresenceHandler{
+		SessionManager:      sessionManager,
+		SessionRetriever:    handler.SessionRetriever,
+		FeedbagRetriever:    handler.FeedbagRetriever,
+		BuddyBroadcaster:    handler.BuddyBroadcaster,
+		ProfileManager:      handler.ProfileManager,
+		RelationshipFetcher: handler.RelationshipFetcher,
+		Logger:              logger,
+	}
+
+	buddyListHandler := &handlers.BuddyListHandler{
+		SessionManager: sessionManager,
+		FeedbagManager: handler.FeedbagManager,
+		Logger:         logger,
+	}
+
+	// Phase 2: Messaging handler
+	messagingHandler := &handlers.MessagingHandler{
+		SessionManager:        sessionManager,
+		MessageRelayer:        handler.MessageRelayer,
+		OfflineMessageManager: handler.OfflineMessageManager,
+		SessionRetriever:      handler.SessionRetriever,
+		RelationshipFetcher:   handler.RelationshipFetcher,
+		Logger:                logger,
+	}
+
+	// Phase 3: Preference handler
+	preferenceHandler := &handlers.PreferenceHandler{
+		SessionManager:    sessionManager,
+		PreferenceManager: handler.PreferenceManager,
+		PermitDenyManager: handler.PermitDenyManager,
+		Logger:            logger,
+	}
+
+	// Phase 4: OSCAR Bridge handler
+	oscarBridgeHandler := &handlers.OSCARBridgeHandler{
+		SessionManager:   sessionManager,
+		OSCARAuthService: handler.AuthService,
+		CookieBaker:      handler.CookieBaker,
+		BridgeStore:      handler.OSCARBridgeStore,
+		Config:           handler.OSCARConfig,
+		Logger:           logger,
+	}
+
+	// Phase 5: Chat handler
+	chatHandler := &handlers.ChatHandler{
+		SessionManager: sessionManager,
+		ChatManager:    handler.ChatManager,
+		Logger:         logger,
+	}
+
 	for _, l := range listeners {
 		mux := http.NewServeMux()
+
+		// Public endpoint (no auth required for hello world)
 		mux.HandleFunc("GET /", 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) {
+			// 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) {
+			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")
+			w.WriteHeader(http.StatusNoContent)
+		})
+
+		// Authenticated Web AIM API endpoints
+		// Session 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(
+				http.HandlerFunc(sessionHandler.EndSession))))
+
+		// Event fetching - uses aimsid for auth, no k required
+		mux.Handle("GET /aim/fetchEvents", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(eventsHandler.FetchEvents))))
+
+		// Presence and buddy list
+		mux.Handle("GET /presence/get", authMiddleware.Authenticate(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(presenceHandler.GetPresence))))
+
+		mux.Handle("GET /buddylist/addBuddy", authMiddleware.Authenticate(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(buddyListHandler.AddBuddy))))
+
+		// Phase 2: Messaging endpoints
+		// sendIM supports aimsid-based auth, so we use flexible auth
+		mux.Handle("GET /im/sendIM", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(messagingHandler.SendIM))))
+
+		mux.Handle("GET /im/setTyping", authMiddleware.Authenticate(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(messagingHandler.SetTyping))))
+
+		// Phase 2: Presence management endpoints
+		mux.Handle("GET /presence/setState", authMiddleware.Authenticate(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(presenceHandler.SetState))))
+
+		// These presence endpoints support aimsid-based auth where k is not required
+		mux.Handle("GET /presence/setStatus", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(presenceHandler.SetStatus))))
+
+		mux.Handle("GET /presence/setProfile", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(presenceHandler.SetProfile))))
+
+		mux.Handle("GET /presence/getProfile", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(presenceHandler.GetProfile))))
+
+		// Phase 2: Presence icon endpoint (no auth required)
+		mux.HandleFunc("GET /presence/icon", presenceHandler.Icon)
+
+		// Phase 3: Preference management endpoints
+		// These endpoints support aimsid-based auth, so we use a flexible auth approach
+		mux.Handle("GET /preference/set", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(preferenceHandler.SetPreferences))))
+
+		mux.Handle("GET /preference/get", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(preferenceHandler.GetPreferences))))
+
+		mux.Handle("GET /preference/setPermitDeny", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(preferenceHandler.SetPermitDeny))))
+
+		mux.Handle("GET /preference/getPermitDeny", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(preferenceHandler.GetPermitDeny))))
+
+		// Phase 4: Advanced Features
+		// OSCAR Bridge endpoint
+		mux.Handle("GET /aim/startOSCARSession", authMiddleware.Authenticate(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(oscarBridgeHandler.StartOSCARSession))))
+
+		// Expressions endpoint (for buddy icons, etc.)
+		expressionsHandler := handlers.NewExpressionsHandler(logger)
+		mux.Handle("GET /expressions/get", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(expressionsHandler.Get))))
+
+		// Phase 5: Chat room endpoints
+		// All chat endpoints use aimsid for authentication
+		mux.Handle("GET /chat/createAndJoinChat", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(chatHandler.CreateAndJoinChat))))
+
+		mux.Handle("GET /chat/sendMessage", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(chatHandler.SendMessage))))
+
+		mux.Handle("GET /chat/setTyping", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(chatHandler.SetTyping))))
+
+		mux.Handle("GET /chat/leaveChat", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				http.HandlerFunc(chatHandler.LeaveChat))))
+
 		servers = append(servers, &http.Server{
 			Addr:    l,
 			Handler: mux,

+ 105 - 0
server/webapi/types.go

@@ -2,6 +2,7 @@ package webapi
 
 import (
 	"context"
+	"time"
 
 	"github.com/google/uuid"
 	"github.com/mk6i/retro-aim-server/config"
@@ -104,3 +105,107 @@ type CookieBaker interface {
 type AdminService interface {
 	InfoChangeRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x04_AdminInfoChangeRequest) (wire.SNACMessage, error)
 }
+
+// SessionRetriever provides methods to retrieve OSCAR sessions.
+type SessionRetriever interface {
+	AllSessions() []*state.Session
+	RetrieveSession(screenName state.IdentScreenName) *state.Session
+}
+
+// FeedbagRetriever provides methods to retrieve buddy list data.
+type FeedbagRetriever interface {
+	RetrieveFeedbag(ctx context.Context, screenName state.IdentScreenName) ([]wire.FeedbagItem, error)
+	RelationshipsByUser(ctx context.Context, screenName state.IdentScreenName) ([]state.IdentScreenName, error)
+}
+
+// FeedbagManager provides methods to manage buddy lists.
+type FeedbagManager interface {
+	RetrieveFeedbag(ctx context.Context, screenName state.IdentScreenName) ([]wire.FeedbagItem, error)
+	InsertItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error
+	UpdateItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error
+	DeleteItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error
+}
+
+// Phase 2: Additional interfaces for messaging and presence
+
+// MessageRelayer relays messages between users
+type MessageRelayer interface {
+	RelayToScreenName(ctx context.Context, recipient state.IdentScreenName, msg wire.SNACMessage)
+}
+
+// OfflineMessageManager manages offline message storage and retrieval
+type OfflineMessageManager interface {
+	SaveMessage(ctx context.Context, msg state.OfflineMessage) error
+	RetrieveMessages(ctx context.Context, recipient state.IdentScreenName) ([]state.OfflineMessage, error)
+	DeleteMessages(ctx context.Context, recipient state.IdentScreenName) error
+}
+
+// BuddyBroadcaster broadcasts buddy presence updates
+type BuddyBroadcaster interface {
+	BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error
+	BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error
+}
+
+// ProfileManager manages user profiles
+type ProfileManager interface {
+	SetProfile(ctx context.Context, screenName state.IdentScreenName, profile string) error
+	Profile(ctx context.Context, screenName state.IdentScreenName) (string, error)
+}
+
+// UserManager defines methods for user authentication.
+type UserManager interface {
+	// AuthenticateUser verifies username and password
+	AuthenticateUser(ctx context.Context, username, password string) (*state.User, error)
+	// FindUserByScreenName finds a user by their screen name
+	FindUserByScreenName(ctx context.Context, screenName state.IdentScreenName) (*state.User, error)
+	// InsertUser creates a new user (for DISABLE_AUTH mode)
+	InsertUser(ctx context.Context, u state.User) error
+}
+
+// TokenStore manages authentication tokens.
+type TokenStore interface {
+	// StoreToken saves an authentication token for a user
+	StoreToken(ctx context.Context, token string, screenName state.IdentScreenName, expiresAt time.Time) error
+	// ValidateToken checks if a token is valid and returns the associated screen name
+	ValidateToken(ctx context.Context, token string) (state.IdentScreenName, error)
+	// DeleteToken removes a token
+	DeleteToken(ctx context.Context, token string) error
+}
+
+// Phase 3: Preference interfaces
+
+// PreferenceManager provides methods to manage user preferences.
+type PreferenceManager interface {
+	SetPreferences(ctx context.Context, screenName state.IdentScreenName, prefs map[string]interface{}) error
+	GetPreferences(ctx context.Context, screenName state.IdentScreenName) (map[string]interface{}, error)
+}
+
+// PermitDenyManager provides methods to manage permit/deny lists.
+type PermitDenyManager interface {
+	SetPDMode(ctx context.Context, screenName state.IdentScreenName, mode wire.FeedbagPDMode) error
+	GetPDMode(ctx context.Context, screenName state.IdentScreenName) (wire.FeedbagPDMode, error)
+	GetPermitList(ctx context.Context, screenName state.IdentScreenName) ([]state.IdentScreenName, error)
+	GetDenyList(ctx context.Context, screenName state.IdentScreenName) ([]state.IdentScreenName, error)
+	AddPermitBuddy(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) error
+	RemovePermitBuddy(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) error
+	AddDenyBuddy(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) error
+	RemoveDenyBuddy(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) error
+}
+
+// Phase 4: OSCAR Bridge interfaces
+
+// OSCARBridgeStore manages the persistence of OSCAR bridge sessions.
+type OSCARBridgeStore interface {
+	SaveBridgeSession(ctx context.Context, webSessionID string, oscarCookie []byte, bosHost string, bosPort int) error
+	SaveBridgeSessionWithDetails(ctx context.Context, session *state.OSCARBridgeSession) error
+	GetBridgeSession(ctx context.Context, webSessionID string) (*state.OSCARBridgeSession, error)
+	DeleteBridgeSession(ctx context.Context, webSessionID string) error
+}
+
+// OSCARConfig provides configuration for OSCAR services.
+type OSCARConfig interface {
+	GetBOSAddress() (host string, port int)
+	GetSSLBOSAddress() (host string, port int)
+	IsSSLAvailable() bool
+	IsAuthDisabled() bool
+}

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

@@ -0,0 +1,255 @@
+package types
+
+import (
+	"context"
+	"errors"
+	"sync"
+	"sync/atomic"
+	"time"
+)
+
+// EventType defines the type of WebAPI event.
+type EventType string
+
+const (
+	// Event types that can be subscribed to
+	EventTypeBuddyList    EventType = "buddylist"
+	EventTypePresence     EventType = "presence"
+	EventTypeIM           EventType = "im"
+	EventTypeSentIM       EventType = "sentIM"
+	EventTypeTyping       EventType = "typing"
+	EventTypeStatus       EventType = "status"
+	EventTypeOfflineIM    EventType = "offlineIM"
+	EventTypeSessionEnded EventType = "sessionEnded"
+	EventTypeRateLimit    EventType = "rateLimit"
+)
+
+// Event represents an event to be delivered to a web client.
+type Event struct {
+	Type      EventType   `json:"type"`
+	SeqNum    uint64      `json:"seqNum"`
+	Timestamp int64       `json:"timestamp"`
+	Data      interface{} `json:"data"`
+}
+
+// PresenceEvent represents a presence change event.
+type PresenceEvent struct {
+	AimID      string `json:"aimId"`
+	State      string `json:"state"` // "online", "offline", "away", "idle"
+	StatusMsg  string `json:"statusMsg,omitempty"`
+	AwayMsg    string `json:"awayMsg,omitempty"`
+	IdleTime   int    `json:"idleTime,omitempty"`   // Minutes idle
+	OnlineTime int64  `json:"onlineTime,omitempty"` // Unix timestamp
+	UserType   string `json:"userType"`             // "aim", "icq", "admin"
+}
+
+// IMEvent represents an instant message event.
+type IMEvent struct {
+	From      string  `json:"from"`
+	Message   string  `json:"message"`
+	Timestamp float64 `json:"timestamp"` // float64 for AMF3 encoding
+	AutoResp  bool    `json:"autoResponse,omitempty"`
+}
+
+// SentIMEvent represents a sent instant message event.
+type SentIMEvent struct {
+	Sender    UserInfo `json:"sender"` // Sender user info
+	Dest      UserInfo `json:"dest"`   // Destination user info
+	Message   string   `json:"message"`
+	Timestamp float64  `json:"timestamp"` // float64 for AMF3 encoding
+	AutoResp  bool     `json:"autoResponse,omitempty"`
+}
+
+// UserInfo represents basic user information in events.
+type UserInfo struct {
+	AimID      string  `json:"aimId"`
+	DisplayID  string  `json:"displayId,omitempty"`
+	UserType   string  `json:"userType,omitempty"`
+	State      string  `json:"state,omitempty"`
+	OnlineTime float64 `json:"onlineTime,omitempty"` // float64 for AMF3 encoding
+}
+
+// TypingEvent represents a typing notification event.
+type TypingEvent struct {
+	From   string `json:"from"`
+	Typing bool   `json:"typing"`
+}
+
+// BuddyListEvent represents a buddy list change event.
+type BuddyListEvent struct {
+	Action string      `json:"action"` // "add", "remove", "update"
+	Buddy  interface{} `json:"buddy"`
+	Group  string      `json:"group,omitempty"`
+}
+
+// EventQueue manages a queue of events for a WebAPI session.
+type EventQueue struct {
+	events   []Event
+	seqNum   uint64
+	maxSize  int
+	mu       sync.RWMutex
+	waitChan chan struct{}
+	closed   bool
+	closedMu sync.RWMutex
+}
+
+// NewEventQueue creates a new event queue with the specified maximum size.
+func NewEventQueue(maxSize int) *EventQueue {
+	return &EventQueue{
+		events:   make([]Event, 0),
+		maxSize:  maxSize,
+		waitChan: make(chan struct{}, 1),
+	}
+}
+
+// Push adds an event to the queue.
+func (q *EventQueue) Push(eventType EventType, data interface{}) {
+	q.closedMu.RLock()
+	if q.closed {
+		q.closedMu.RUnlock()
+		return
+	}
+	q.closedMu.RUnlock()
+
+	q.mu.Lock()
+	defer q.mu.Unlock()
+
+	// Increment sequence number atomically
+	seqNum := atomic.AddUint64(&q.seqNum, 1)
+
+	event := Event{
+		Type:      eventType,
+		SeqNum:    seqNum,
+		Timestamp: time.Now().Unix(),
+		Data:      data,
+	}
+
+	// Add event to queue
+	q.events = append(q.events, event)
+
+	// If queue exceeds max size, remove oldest events
+	if len(q.events) > q.maxSize {
+		// Keep only the most recent maxSize events
+		q.events = q.events[len(q.events)-q.maxSize:]
+	}
+
+	// Signal any waiting fetchers
+	select {
+	case q.waitChan <- struct{}{}:
+	default:
+		// Channel already has a signal
+	}
+}
+
+// Fetch retrieves events from the queue, optionally waiting for new events.
+func (q *EventQueue) Fetch(ctx context.Context, lastSeqNum uint64, timeout time.Duration) ([]Event, error) {
+	q.closedMu.RLock()
+	if q.closed {
+		q.closedMu.RUnlock()
+		return nil, errors.New("event queue is closed")
+	}
+	q.closedMu.RUnlock()
+
+	// First, check if we have any events newer than lastSeqNum
+	q.mu.RLock()
+	events := q.getEventsAfter(lastSeqNum)
+	q.mu.RUnlock()
+
+	if len(events) > 0 {
+		return events, nil
+	}
+
+	// No events available, wait for new ones or timeout
+	timeoutChan := time.After(timeout)
+
+	for {
+		select {
+		case <-q.waitChan:
+			// New events may be available
+			q.mu.RLock()
+			events = q.getEventsAfter(lastSeqNum)
+			q.mu.RUnlock()
+
+			if len(events) > 0 {
+				return events, nil
+			}
+			// False alarm, keep waiting
+
+		case <-timeoutChan:
+			// Timeout reached, return empty array
+			return []Event{}, nil
+
+		case <-ctx.Done():
+			// Context cancelled
+			return nil, ctx.Err()
+		}
+	}
+}
+
+// getEventsAfter returns all events with sequence number greater than the specified value.
+// Must be called with at least a read lock held.
+func (q *EventQueue) getEventsAfter(seqNum uint64) []Event {
+	var result []Event
+
+	for _, event := range q.events {
+		if event.SeqNum > seqNum {
+			result = append(result, event)
+		}
+	}
+
+	return result
+}
+
+// GetAllEvents returns all events in the queue (for debugging).
+func (q *EventQueue) GetAllEvents() []Event {
+	q.mu.RLock()
+	defer q.mu.RUnlock()
+
+	result := make([]Event, len(q.events))
+	copy(result, q.events)
+	return result
+}
+
+// Clear removes all events from the queue.
+func (q *EventQueue) Clear() {
+	q.mu.Lock()
+	defer q.mu.Unlock()
+
+	q.events = make([]Event, 0)
+}
+
+// Size returns the current number of events in the queue.
+func (q *EventQueue) Size() int {
+	q.mu.RLock()
+	defer q.mu.RUnlock()
+
+	return len(q.events)
+}
+
+// Close closes the event queue, unblocking any waiting fetchers.
+func (q *EventQueue) Close() {
+	q.closedMu.Lock()
+	defer q.closedMu.Unlock()
+
+	if q.closed {
+		return
+	}
+
+	q.closed = true
+
+	// Send multiple signals to unblock all potential waiters
+	for i := 0; i < 10; i++ {
+		select {
+		case q.waitChan <- struct{}{}:
+		default:
+			break
+		}
+	}
+}
+
+// IsClosed returns whether the queue is closed.
+func (q *EventQueue) IsClosed() bool {
+	q.closedMu.RLock()
+	defer q.closedMu.RUnlock()
+	return q.closed
+}

+ 4 - 0
state/migrations/0016_webapi_tokens.down.sql

@@ -0,0 +1,4 @@
+-- Drop Web API tokens table and indices
+DROP INDEX IF EXISTS idx_webapi_tokens_screen_name;
+DROP INDEX IF EXISTS idx_webapi_tokens_expires_at;
+DROP TABLE IF EXISTS webapi_tokens;

+ 13 - 0
state/migrations/0016_webapi_tokens.up.sql

@@ -0,0 +1,13 @@
+-- Create table for storing Web API authentication tokens
+CREATE TABLE IF NOT EXISTS webapi_tokens (
+    token TEXT PRIMARY KEY,
+    screen_name TEXT NOT NULL,
+    expires_at TIMESTAMP NOT NULL,
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Index for cleaning up expired tokens
+CREATE INDEX IF NOT EXISTS idx_webapi_tokens_expires_at ON webapi_tokens(expires_at);
+
+-- Index for looking up tokens by screen name
+CREATE INDEX IF NOT EXISTS idx_webapi_tokens_screen_name ON webapi_tokens(screen_name);

+ 3 - 0
state/migrations/0017_web_preferences.down.sql

@@ -0,0 +1,3 @@
+-- Drop the web_preferences table and its index
+DROP INDEX IF EXISTS idx_web_preferences_screen_name;
+DROP TABLE IF EXISTS web_preferences;

+ 33 - 0
state/migrations/0017_web_preferences.up.sql

@@ -0,0 +1,33 @@
+-- Create table for Web API user preferences
+CREATE TABLE IF NOT EXISTS web_preferences
+(
+    screen_name         VARCHAR(16) PRIMARY KEY,
+    preferences         TEXT,        -- JSON object of preference key-value pairs
+    created_at          INTEGER NOT NULL,
+    updated_at          INTEGER NOT NULL
+);
+
+-- Create index for efficient lookups
+CREATE INDEX idx_web_preferences_screen_name ON web_preferences(screen_name);
+
+-- Ensure buddyListMode table exists (for PD mode storage)
+-- This should already exist from migration 0010, but we'll add IF NOT EXISTS for safety
+CREATE TABLE IF NOT EXISTS buddyListMode
+(
+    screenName       VARCHAR(16),
+    clientSidePDMode INTEGER DEFAULT 0,
+    useFeedbag       BOOLEAN DEFAULT false,
+    PRIMARY KEY (screenName)
+);
+
+-- Ensure clientSideBuddyList table exists (for permit/deny lists)
+-- This should already exist from migration 0010, but we'll add IF NOT EXISTS for safety
+CREATE TABLE IF NOT EXISTS clientSideBuddyList
+(
+    me       VARCHAR(16),
+    them     VARCHAR(16),
+    isBuddy  BOOLEAN DEFAULT false,
+    isPermit BOOLEAN DEFAULT false,
+    isDeny   BOOLEAN DEFAULT false,
+    PRIMARY KEY (me, them)
+);

+ 5 - 0
state/migrations/0018_oscar_bridge_sessions.down.sql

@@ -0,0 +1,5 @@
+-- Drop the OSCAR bridge sessions table and its indexes
+
+DROP INDEX IF EXISTS idx_oscar_bridge_screen_name;
+DROP INDEX IF EXISTS idx_oscar_bridge_last_accessed;
+DROP TABLE IF EXISTS oscar_bridge_sessions;

+ 36 - 0
state/migrations/0018_oscar_bridge_sessions.up.sql

@@ -0,0 +1,36 @@
+-- Create table for storing WebAPI to OSCAR bridge sessions
+-- This table maps WebAPI sessions to OSCAR authentication cookies
+-- and tracks connection details for bridged sessions
+
+CREATE TABLE IF NOT EXISTS oscar_bridge_sessions (
+    -- WebAPI session identifier (aimsid)
+    web_session_id VARCHAR(64) PRIMARY KEY,
+    
+    -- OSCAR authentication cookie (hex encoded)
+    oscar_cookie BLOB NOT NULL,
+    
+    -- BOS server connection details
+    bos_host VARCHAR(255) NOT NULL,
+    bos_port INTEGER NOT NULL,
+    use_ssl BOOLEAN DEFAULT FALSE,
+    
+    -- Screen name associated with the session
+    screen_name VARCHAR(97) NOT NULL,
+    
+    -- Session metadata
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    last_accessed TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    
+    -- Optional: Track client info
+    client_name VARCHAR(255),
+    client_version VARCHAR(50)
+);
+
+-- Create index for quick lookups by screen name
+CREATE INDEX idx_oscar_bridge_screen_name ON oscar_bridge_sessions(screen_name);
+
+-- Create index for cleanup of old sessions
+CREATE INDEX idx_oscar_bridge_last_accessed ON oscar_bridge_sessions(last_accessed);
+
+-- Optional: Create a trigger to auto-update last_accessed on SELECT/UPDATE
+-- (SQLite doesn't support this directly, would need application logic)

+ 7 - 0
state/migrations/0019_api_analytics.down.sql

@@ -0,0 +1,7 @@
+-- Rollback Migration: 0018_api_analytics
+-- Description: Remove Web API usage analytics tables
+-- Date: 2024-12-28
+
+DROP TABLE IF EXISTS api_quotas;
+DROP TABLE IF EXISTS api_usage_stats;
+DROP TABLE IF EXISTS api_usage_logs;

+ 61 - 0
state/migrations/0019_api_analytics.up.sql

@@ -0,0 +1,61 @@
+-- Migration: 0018_api_analytics
+-- Description: Create tables for Web API usage analytics and tracking
+-- Date: 2024-12-28
+
+-- Create API usage logs table for detailed request tracking
+CREATE TABLE IF NOT EXISTS api_usage_logs (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    dev_id VARCHAR(255) NOT NULL,
+    endpoint VARCHAR(255) NOT NULL,
+    method VARCHAR(10) NOT NULL,
+    timestamp INTEGER NOT NULL,
+    response_time_ms INTEGER,
+    status_code INTEGER,
+    ip_address VARCHAR(45),
+    user_agent TEXT,
+    screen_name VARCHAR(16), -- User making the request (if authenticated)
+    error_message TEXT, -- Store error details if request failed
+    request_size INTEGER, -- Size of request in bytes
+    response_size INTEGER -- Size of response in bytes
+);
+
+-- Create indexes for efficient querying
+CREATE INDEX idx_usage_dev_id ON api_usage_logs(dev_id);
+CREATE INDEX idx_usage_timestamp ON api_usage_logs(timestamp);
+CREATE INDEX idx_usage_endpoint ON api_usage_logs(endpoint);
+CREATE INDEX idx_usage_status ON api_usage_logs(status_code);
+CREATE INDEX idx_usage_screen_name ON api_usage_logs(screen_name);
+
+-- Create aggregated statistics table for performance
+CREATE TABLE IF NOT EXISTS api_usage_stats (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    dev_id VARCHAR(255) NOT NULL,
+    endpoint VARCHAR(255) NOT NULL,
+    period_type VARCHAR(10) NOT NULL, -- 'hour', 'day', 'month'
+    period_start INTEGER NOT NULL,
+    request_count INTEGER DEFAULT 0,
+    error_count INTEGER DEFAULT 0,
+    total_response_time_ms INTEGER DEFAULT 0,
+    avg_response_time_ms INTEGER DEFAULT 0,
+    total_request_bytes INTEGER DEFAULT 0,
+    total_response_bytes INTEGER DEFAULT 0,
+    unique_users INTEGER DEFAULT 0,
+    UNIQUE(dev_id, endpoint, period_type, period_start)
+);
+
+-- Create indexes for aggregated stats
+CREATE INDEX idx_stats_dev_id ON api_usage_stats(dev_id);
+CREATE INDEX idx_stats_period ON api_usage_stats(period_type, period_start);
+CREATE INDEX idx_stats_endpoint ON api_usage_stats(endpoint);
+
+-- Create table for tracking API key quotas and limits
+CREATE TABLE IF NOT EXISTS api_quotas (
+    dev_id VARCHAR(255) PRIMARY KEY,
+    daily_limit INTEGER DEFAULT 10000,
+    monthly_limit INTEGER DEFAULT 300000,
+    daily_used INTEGER DEFAULT 0,
+    monthly_used INTEGER DEFAULT 0,
+    last_reset_daily INTEGER NOT NULL,
+    last_reset_monthly INTEGER NOT NULL,
+    overage_allowed BOOLEAN DEFAULT FALSE
+);

+ 7 - 0
state/migrations/0020_buddy_feeds.down.sql

@@ -0,0 +1,7 @@
+-- Rollback Migration: 0019_buddy_feeds
+-- Description: Remove buddy feed tables
+-- Date: 2024-12-28
+
+DROP TABLE IF EXISTS buddy_feed_subscriptions;
+DROP TABLE IF EXISTS buddy_feed_items;
+DROP TABLE IF EXISTS buddy_feeds;

+ 58 - 0
state/migrations/0020_buddy_feeds.up.sql

@@ -0,0 +1,58 @@
+-- Migration: 0019_buddy_feeds
+-- Description: Create tables for buddy feed functionality
+-- Date: 2024-12-28
+
+-- Create buddy feeds table for storing user feed configurations
+CREATE TABLE IF NOT EXISTS buddy_feeds (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    screen_name VARCHAR(16) NOT NULL,
+    feed_type VARCHAR(50) NOT NULL, -- 'rss', 'atom', 'status', 'blog', 'social'
+    title TEXT,
+    description TEXT,
+    link TEXT,
+    published_at INTEGER NOT NULL,
+    created_at INTEGER NOT NULL,
+    updated_at INTEGER NOT NULL,
+    is_active BOOLEAN DEFAULT TRUE
+);
+
+-- Create indexes for efficient querying
+CREATE INDEX idx_buddy_feeds_screen_name ON buddy_feeds(screen_name);
+CREATE INDEX idx_buddy_feeds_published ON buddy_feeds(published_at);
+CREATE INDEX idx_buddy_feeds_type ON buddy_feeds(feed_type);
+CREATE INDEX idx_buddy_feeds_active ON buddy_feeds(is_active);
+
+-- Create buddy feed items table for individual feed entries
+CREATE TABLE IF NOT EXISTS buddy_feed_items (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    feed_id INTEGER NOT NULL,
+    title TEXT NOT NULL,
+    description TEXT,
+    link TEXT,
+    guid TEXT,
+    author VARCHAR(16), -- Screen name of the author
+    categories TEXT, -- JSON array of categories
+    published_at INTEGER NOT NULL,
+    created_at INTEGER NOT NULL,
+    FOREIGN KEY (feed_id) REFERENCES buddy_feeds(id) ON DELETE CASCADE
+);
+
+-- Create indexes for feed items
+CREATE INDEX idx_feed_items_feed_id ON buddy_feed_items(feed_id);
+CREATE INDEX idx_feed_items_published ON buddy_feed_items(published_at);
+CREATE INDEX idx_feed_items_guid ON buddy_feed_items(guid);
+
+-- Create buddy feed subscriptions table for tracking who follows which feeds
+CREATE TABLE IF NOT EXISTS buddy_feed_subscriptions (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    subscriber_screen_name VARCHAR(16) NOT NULL,
+    feed_id INTEGER NOT NULL,
+    subscribed_at INTEGER NOT NULL,
+    last_checked_at INTEGER,
+    FOREIGN KEY (feed_id) REFERENCES buddy_feeds(id) ON DELETE CASCADE,
+    UNIQUE(subscriber_screen_name, feed_id)
+);
+
+-- Create indexes for subscriptions
+CREATE INDEX idx_feed_subs_subscriber ON buddy_feed_subscriptions(subscriber_screen_name);
+CREATE INDEX idx_feed_subs_feed_id ON buddy_feed_subscriptions(feed_id);

+ 6 - 0
state/migrations/0021_vanity_urls.down.sql

@@ -0,0 +1,6 @@
+-- Rollback Migration: 0020_vanity_urls
+-- Description: Remove vanity URL tables
+-- Date: 2024-12-28
+
+DROP TABLE IF EXISTS vanity_url_redirects;
+DROP TABLE IF EXISTS vanity_urls;

+ 38 - 0
state/migrations/0021_vanity_urls.up.sql

@@ -0,0 +1,38 @@
+-- Migration: 0020_vanity_urls
+-- Description: Create tables for vanity URL management
+-- Date: 2024-12-28
+
+-- Create vanity URLs table for custom user URLs
+CREATE TABLE IF NOT EXISTS vanity_urls (
+    screen_name VARCHAR(16) PRIMARY KEY,
+    vanity_url VARCHAR(255) UNIQUE NOT NULL,
+    display_name VARCHAR(100),
+    bio TEXT,
+    location VARCHAR(100),
+    website VARCHAR(255),
+    created_at INTEGER NOT NULL,
+    updated_at INTEGER NOT NULL,
+    is_active BOOLEAN DEFAULT TRUE,
+    click_count INTEGER DEFAULT 0,
+    last_accessed INTEGER
+);
+
+-- Create indexes for efficient lookups
+CREATE INDEX idx_vanity_urls_url ON vanity_urls(vanity_url);
+CREATE INDEX idx_vanity_urls_active ON vanity_urls(is_active);
+CREATE INDEX idx_vanity_urls_created ON vanity_urls(created_at);
+
+-- Create vanity URL redirects table for tracking and analytics
+CREATE TABLE IF NOT EXISTS vanity_url_redirects (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    vanity_url VARCHAR(255) NOT NULL,
+    accessed_at INTEGER NOT NULL,
+    ip_address VARCHAR(45),
+    user_agent TEXT,
+    referer TEXT,
+    FOREIGN KEY (vanity_url) REFERENCES vanity_urls(vanity_url) ON DELETE CASCADE
+);
+
+-- Create index for redirect analytics
+CREATE INDEX idx_vanity_redirects_url ON vanity_url_redirects(vanity_url);
+CREATE INDEX idx_vanity_redirects_time ON vanity_url_redirects(accessed_at);

+ 16 - 0
state/migrations/0022_web_chat_rooms.down.sql

@@ -0,0 +1,16 @@
+-- Rollback migration 0021: Web API Chat Rooms Support
+DROP TABLE IF EXISTS web_chat_messages;
+DROP TABLE IF EXISTS web_chat_participants;
+DROP TABLE IF EXISTS web_chat_sessions;
+DROP TABLE IF EXISTS web_chat_rooms;
+
+
+
+
+
+
+
+
+
+
+

+ 82 - 0
state/migrations/0022_web_chat_rooms.up.sql

@@ -0,0 +1,82 @@
+-- Migration 0021: Web API Chat Rooms Support
+-- This migration adds tables for Web API chat room functionality
+
+-- Chat rooms table
+CREATE TABLE IF NOT EXISTS web_chat_rooms (
+    room_id VARCHAR(255) PRIMARY KEY,
+    room_name VARCHAR(255) NOT NULL,
+    description TEXT,
+    room_type VARCHAR(50) DEFAULT 'userCreated',
+    category_id VARCHAR(50),
+    creator_screen_name VARCHAR(16) NOT NULL,
+    created_at INTEGER NOT NULL,
+    closed_at INTEGER,
+    max_participants INTEGER DEFAULT 100
+);
+
+-- Create indexes for web_chat_rooms table
+CREATE INDEX IF NOT EXISTS idx_web_chat_rooms_name ON web_chat_rooms(room_name);
+CREATE INDEX IF NOT EXISTS idx_web_chat_rooms_creator ON web_chat_rooms(creator_screen_name);
+CREATE INDEX IF NOT EXISTS idx_web_chat_rooms_created ON web_chat_rooms(created_at);
+CREATE INDEX IF NOT EXISTS idx_web_chat_rooms_closed ON web_chat_rooms(closed_at);
+
+-- Chat sessions table (maps users to chat rooms)
+CREATE TABLE IF NOT EXISTS web_chat_sessions (
+    chat_sid VARCHAR(255) PRIMARY KEY,
+    aimsid VARCHAR(255) NOT NULL,
+    room_id VARCHAR(255) NOT NULL,
+    screen_name VARCHAR(16) NOT NULL,
+    instance_id INTEGER NOT NULL,
+    joined_at INTEGER NOT NULL,
+    left_at INTEGER,
+    FOREIGN KEY (room_id) REFERENCES web_chat_rooms(room_id) ON DELETE CASCADE
+);
+
+-- Create indexes for web_chat_sessions table
+CREATE INDEX IF NOT EXISTS idx_web_chat_sessions_aimsid ON web_chat_sessions(aimsid);
+CREATE INDEX IF NOT EXISTS idx_web_chat_sessions_room ON web_chat_sessions(room_id);
+CREATE INDEX IF NOT EXISTS idx_web_chat_sessions_user ON web_chat_sessions(screen_name);
+CREATE INDEX IF NOT EXISTS idx_web_chat_sessions_joined ON web_chat_sessions(joined_at);
+
+-- Chat messages table
+CREATE TABLE IF NOT EXISTS web_chat_messages (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    room_id VARCHAR(255) NOT NULL,
+    screen_name VARCHAR(16) NOT NULL,
+    message TEXT NOT NULL,
+    whisper_target VARCHAR(16),
+    timestamp INTEGER NOT NULL,
+    FOREIGN KEY (room_id) REFERENCES web_chat_rooms(room_id) ON DELETE CASCADE
+);
+
+-- Create indexes for web_chat_messages table
+CREATE INDEX IF NOT EXISTS idx_web_chat_messages_room ON web_chat_messages(room_id);
+CREATE INDEX IF NOT EXISTS idx_web_chat_messages_timestamp ON web_chat_messages(timestamp);
+CREATE INDEX IF NOT EXISTS idx_web_chat_messages_user ON web_chat_messages(screen_name);
+
+-- Chat participants table (current participants in each room)
+CREATE TABLE IF NOT EXISTS web_chat_participants (
+    room_id VARCHAR(255) NOT NULL,
+    screen_name VARCHAR(16) NOT NULL,
+    chat_sid VARCHAR(255) NOT NULL,
+    joined_at INTEGER NOT NULL,
+    typing_status VARCHAR(20) DEFAULT 'none',
+    typing_updated_at INTEGER,
+    PRIMARY KEY (room_id, screen_name),
+    FOREIGN KEY (room_id) REFERENCES web_chat_rooms(room_id) ON DELETE CASCADE
+);
+
+-- Create indexes for web_chat_participants table
+CREATE INDEX IF NOT EXISTS idx_web_chat_participants_room ON web_chat_participants(room_id);
+CREATE INDEX IF NOT EXISTS idx_web_chat_participants_user ON web_chat_participants(screen_name);
+
+
+
+
+
+
+
+
+
+
+

+ 7 - 0
state/migrations/0023_web_api_keys.down.sql

@@ -0,0 +1,7 @@
+-- Drop indexes
+DROP INDEX IF EXISTS idx_web_api_keys_is_active;
+DROP INDEX IF EXISTS idx_web_api_keys_dev_key;
+
+-- Drop table
+DROP TABLE IF EXISTS web_api_keys;
+

+ 18 - 0
state/migrations/0023_web_api_keys.up.sql

@@ -0,0 +1,18 @@
+-- Create table for Web API authentication keys
+CREATE TABLE IF NOT EXISTS web_api_keys
+(
+    dev_id          VARCHAR(255) PRIMARY KEY,
+    dev_key         VARCHAR(255) UNIQUE NOT NULL,
+    app_name        VARCHAR(255) NOT NULL,
+    created_at      INTEGER NOT NULL,
+    last_used       INTEGER,
+    is_active       BOOLEAN DEFAULT 1,
+    rate_limit      INTEGER DEFAULT 60,
+    allowed_origins TEXT, -- JSON array of allowed CORS origins
+    capabilities    TEXT  -- JSON array of enabled features/endpoints
+);
+
+-- Create indexes for efficient lookups
+CREATE INDEX idx_web_api_keys_dev_key ON web_api_keys(dev_key);
+CREATE INDEX idx_web_api_keys_is_active ON web_api_keys(is_active);
+

+ 7 - 0
state/session.go

@@ -192,6 +192,13 @@ func (s *Session) SetUserStatusBitmask(bitmask uint32) {
 	s.userStatusBitmask = bitmask
 }
 
+// UserStatusBitmask returns the user status bitmask.
+func (s *Session) UserStatusBitmask() uint32 {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.userStatusBitmask
+}
+
 // IncrementWarning increments the user's warning level and scales rate limits accordingly.
 // The incr parameter is the warning increment (negative to decrease), and classID specifies
 // which rate limit class to scale. The incr param is a percentage represented as an integer

+ 1 - 0
state/session_manager_test.go

@@ -483,6 +483,7 @@ func TestInMemoryChatSessionManager_RemoveSession_DoubleLogin(t *testing.T) {
 		// room for the new session
 		chatSess2, err := sm.AddSession(context.Background(), "chat-room-1", "user-screen-name-1")
 		assert.NoError(t, err)
+		assert.NotNil(t, chatSess2)
 		chatSess2.SetSignonComplete()
 		assert.Equal(t, chatSess1.DisplayScreenName(), chatSess2.DisplayScreenName())
 		wg.Done()

+ 351 - 0
state/web_api_store.go

@@ -0,0 +1,351 @@
+package state
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"time"
+)
+
+var (
+	// ErrDupAPIKey is returned when attempting to insert a duplicate API key.
+	ErrDupAPIKey = errors.New("API key already exists")
+	// ErrNoAPIKey is returned when an API key is not found.
+	ErrNoAPIKey = errors.New("API key not found")
+)
+
+// WebAPIKey represents a Web API authentication key.
+type WebAPIKey struct {
+	DevID          string     `json:"dev_id"`
+	DevKey         string     `json:"dev_key"`
+	AppName        string     `json:"app_name"`
+	CreatedAt      time.Time  `json:"created_at"`
+	LastUsed       *time.Time `json:"last_used,omitempty"`
+	IsActive       bool       `json:"is_active"`
+	RateLimit      int        `json:"rate_limit"`
+	AllowedOrigins []string   `json:"allowed_origins"`
+	Capabilities   []string   `json:"capabilities"`
+}
+
+// WebAPIKeyUpdate represents fields that can be updated for an API key.
+type WebAPIKeyUpdate struct {
+	AppName        *string   `json:"app_name,omitempty"`
+	IsActive       *bool     `json:"is_active,omitempty"`
+	RateLimit      *int      `json:"rate_limit,omitempty"`
+	AllowedOrigins *[]string `json:"allowed_origins,omitempty"`
+	Capabilities   *[]string `json:"capabilities,omitempty"`
+}
+
+// CreateAPIKey inserts a new API key into the database.
+func (f SQLiteUserStore) CreateAPIKey(ctx context.Context, key WebAPIKey) error {
+	originsJSON, err := json.Marshal(key.AllowedOrigins)
+	if err != nil {
+		return fmt.Errorf("failed to marshal allowed origins: %w", err)
+	}
+
+	capabilitiesJSON, err := json.Marshal(key.Capabilities)
+	if err != nil {
+		return fmt.Errorf("failed to marshal capabilities: %w", err)
+	}
+
+	q := `
+		INSERT INTO web_api_keys (dev_id, dev_key, app_name, created_at, is_active, rate_limit, allowed_origins, capabilities)
+		VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+		ON CONFLICT (dev_id) DO NOTHING
+	`
+
+	result, err := f.db.ExecContext(ctx,
+		q,
+		key.DevID,
+		key.DevKey,
+		key.AppName,
+		key.CreatedAt.Unix(),
+		key.IsActive,
+		key.RateLimit,
+		string(originsJSON),
+		string(capabilitiesJSON),
+	)
+	if err != nil {
+		return err
+	}
+
+	rowsAffected, err := result.RowsAffected()
+	if err != nil {
+		return err
+	}
+	if rowsAffected == 0 {
+		return ErrDupAPIKey
+	}
+
+	return nil
+}
+
+// GetAPIKeyByDevKey retrieves an API key by its dev_key value.
+func (f *SQLiteUserStore) GetAPIKeyByDevKey(ctx context.Context, devKey string) (*WebAPIKey, error) {
+	q := `
+		SELECT dev_id, dev_key, app_name, created_at, last_used, is_active, rate_limit, allowed_origins, capabilities
+		FROM web_api_keys
+		WHERE dev_key = ? AND is_active = 1
+	`
+
+	var key WebAPIKey
+	var createdAt, lastUsed sql.NullInt64
+	var originsJSON, capabilitiesJSON string
+
+	err := f.db.QueryRowContext(ctx, q, devKey).Scan(
+		&key.DevID,
+		&key.DevKey,
+		&key.AppName,
+		&createdAt,
+		&lastUsed,
+		&key.IsActive,
+		&key.RateLimit,
+		&originsJSON,
+		&capabilitiesJSON,
+	)
+
+	if err == sql.ErrNoRows {
+		return nil, ErrNoAPIKey
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	key.CreatedAt = time.Unix(createdAt.Int64, 0)
+	if lastUsed.Valid {
+		t := time.Unix(lastUsed.Int64, 0)
+		key.LastUsed = &t
+	}
+
+	if err := json.Unmarshal([]byte(originsJSON), &key.AllowedOrigins); err != nil {
+		return nil, fmt.Errorf("failed to unmarshal allowed origins: %w", err)
+	}
+
+	if err := json.Unmarshal([]byte(capabilitiesJSON), &key.Capabilities); err != nil {
+		return nil, fmt.Errorf("failed to unmarshal capabilities: %w", err)
+	}
+
+	return &key, nil
+}
+
+// GetAPIKeyByDevID retrieves an API key by its dev_id value.
+func (f SQLiteUserStore) GetAPIKeyByDevID(ctx context.Context, devID string) (*WebAPIKey, error) {
+	q := `
+		SELECT dev_id, dev_key, app_name, created_at, last_used, is_active, rate_limit, allowed_origins, capabilities
+		FROM web_api_keys
+		WHERE dev_id = ?
+	`
+
+	var key WebAPIKey
+	var createdAt, lastUsed sql.NullInt64
+	var originsJSON, capabilitiesJSON string
+
+	err := f.db.QueryRowContext(ctx, q, devID).Scan(
+		&key.DevID,
+		&key.DevKey,
+		&key.AppName,
+		&createdAt,
+		&lastUsed,
+		&key.IsActive,
+		&key.RateLimit,
+		&originsJSON,
+		&capabilitiesJSON,
+	)
+
+	if err == sql.ErrNoRows {
+		return nil, ErrNoAPIKey
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	key.CreatedAt = time.Unix(createdAt.Int64, 0)
+	if lastUsed.Valid {
+		t := time.Unix(lastUsed.Int64, 0)
+		key.LastUsed = &t
+	}
+
+	if err := json.Unmarshal([]byte(originsJSON), &key.AllowedOrigins); err != nil {
+		return nil, fmt.Errorf("failed to unmarshal allowed origins: %w", err)
+	}
+
+	if err := json.Unmarshal([]byte(capabilitiesJSON), &key.Capabilities); err != nil {
+		return nil, fmt.Errorf("failed to unmarshal capabilities: %w", err)
+	}
+
+	return &key, nil
+}
+
+// ListAPIKeys retrieves all API keys from the database.
+func (f SQLiteUserStore) ListAPIKeys(ctx context.Context) ([]WebAPIKey, error) {
+	q := `
+		SELECT dev_id, dev_key, app_name, created_at, last_used, is_active, rate_limit, allowed_origins, capabilities
+		FROM web_api_keys
+		ORDER BY created_at DESC
+	`
+
+	rows, err := f.db.QueryContext(ctx, q)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var keys []WebAPIKey
+	for rows.Next() {
+		var key WebAPIKey
+		var createdAt, lastUsed sql.NullInt64
+		var originsJSON, capabilitiesJSON string
+
+		err := rows.Scan(
+			&key.DevID,
+			&key.DevKey,
+			&key.AppName,
+			&createdAt,
+			&lastUsed,
+			&key.IsActive,
+			&key.RateLimit,
+			&originsJSON,
+			&capabilitiesJSON,
+		)
+		if err != nil {
+			return nil, err
+		}
+
+		key.CreatedAt = time.Unix(createdAt.Int64, 0)
+		if lastUsed.Valid {
+			t := time.Unix(lastUsed.Int64, 0)
+			key.LastUsed = &t
+		}
+
+		if err := json.Unmarshal([]byte(originsJSON), &key.AllowedOrigins); err != nil {
+			return nil, fmt.Errorf("failed to unmarshal allowed origins: %w", err)
+		}
+
+		if err := json.Unmarshal([]byte(capabilitiesJSON), &key.Capabilities); err != nil {
+			return nil, fmt.Errorf("failed to unmarshal capabilities: %w", err)
+		}
+
+		keys = append(keys, key)
+	}
+
+	if err = rows.Err(); err != nil {
+		return nil, err
+	}
+
+	return keys, nil
+}
+
+// UpdateAPIKey updates an existing API key's fields.
+func (f SQLiteUserStore) UpdateAPIKey(ctx context.Context, devID string, updates WebAPIKeyUpdate) error {
+	// Build dynamic UPDATE query based on provided fields
+	var setClauses []string
+	var args []interface{}
+
+	if updates.AppName != nil {
+		setClauses = append(setClauses, "app_name = ?")
+		args = append(args, *updates.AppName)
+	}
+
+	if updates.IsActive != nil {
+		setClauses = append(setClauses, "is_active = ?")
+		args = append(args, *updates.IsActive)
+	}
+
+	if updates.RateLimit != nil {
+		setClauses = append(setClauses, "rate_limit = ?")
+		args = append(args, *updates.RateLimit)
+	}
+
+	if updates.AllowedOrigins != nil {
+		originsJSON, err := json.Marshal(*updates.AllowedOrigins)
+		if err != nil {
+			return fmt.Errorf("failed to marshal allowed origins: %w", err)
+		}
+		setClauses = append(setClauses, "allowed_origins = ?")
+		args = append(args, string(originsJSON))
+	}
+
+	if updates.Capabilities != nil {
+		capabilitiesJSON, err := json.Marshal(*updates.Capabilities)
+		if err != nil {
+			return fmt.Errorf("failed to marshal capabilities: %w", err)
+		}
+		setClauses = append(setClauses, "capabilities = ?")
+		args = append(args, string(capabilitiesJSON))
+	}
+
+	if len(setClauses) == 0 {
+		return nil // No updates to perform
+	}
+
+	// Add WHERE clause argument
+	args = append(args, devID)
+
+	q := fmt.Sprintf(`
+		UPDATE web_api_keys
+		SET %s
+		WHERE dev_id = ?
+	`, joinStrings(setClauses, ", "))
+
+	result, err := f.db.ExecContext(ctx, q, args...)
+	if err != nil {
+		return err
+	}
+
+	rowsAffected, err := result.RowsAffected()
+	if err != nil {
+		return err
+	}
+	if rowsAffected == 0 {
+		return ErrNoAPIKey
+	}
+
+	return nil
+}
+
+// DeleteAPIKey removes an API key from the database.
+func (f SQLiteUserStore) DeleteAPIKey(ctx context.Context, devID string) error {
+	q := `
+		DELETE FROM web_api_keys WHERE dev_id = ?
+	`
+	result, err := f.db.ExecContext(ctx, q, devID)
+	if err != nil {
+		return err
+	}
+
+	rowsAffected, err := result.RowsAffected()
+	if err != nil {
+		return err
+	}
+	if rowsAffected == 0 {
+		return ErrNoAPIKey
+	}
+
+	return nil
+}
+
+// UpdateLastUsed updates the last_used timestamp for an API key.
+func (f *SQLiteUserStore) UpdateLastUsed(ctx context.Context, devKey string) error {
+	q := `
+		UPDATE web_api_keys
+		SET last_used = ?
+		WHERE dev_key = ?
+	`
+
+	_, err := f.db.ExecContext(ctx, q, time.Now().Unix(), devKey)
+	return err
+}
+
+// joinStrings is a helper function to join strings with a separator.
+func joinStrings(strs []string, sep string) string {
+	if len(strs) == 0 {
+		return ""
+	}
+	result := strs[0]
+	for i := 1; i < len(strs); i++ {
+		result += sep + strs[i]
+	}
+	return result
+}

+ 429 - 0
state/webapi_analytics.go

@@ -0,0 +1,429 @@
+package state
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"strings"
+	"sync"
+	"time"
+)
+
+// APIUsageLog represents a single API request log entry.
+type APIUsageLog struct {
+	ID             int64     `json:"id"`
+	DevID          string    `json:"dev_id"`
+	Endpoint       string    `json:"endpoint"`
+	Method         string    `json:"method"`
+	Timestamp      time.Time `json:"timestamp"`
+	ResponseTimeMs int       `json:"response_time_ms"`
+	StatusCode     int       `json:"status_code"`
+	IPAddress      string    `json:"ip_address"`
+	UserAgent      string    `json:"user_agent"`
+	ScreenName     string    `json:"screen_name,omitempty"`
+	ErrorMessage   string    `json:"error_message,omitempty"`
+	RequestSize    int       `json:"request_size"`
+	ResponseSize   int       `json:"response_size"`
+}
+
+// APIUsageStats represents aggregated API usage statistics.
+type APIUsageStats struct {
+	DevID              string    `json:"dev_id"`
+	Endpoint           string    `json:"endpoint"`
+	PeriodType         string    `json:"period_type"`
+	PeriodStart        time.Time `json:"period_start"`
+	RequestCount       int       `json:"request_count"`
+	ErrorCount         int       `json:"error_count"`
+	TotalResponseTime  int       `json:"total_response_time_ms"`
+	AvgResponseTime    int       `json:"avg_response_time_ms"`
+	TotalRequestBytes  int64     `json:"total_request_bytes"`
+	TotalResponseBytes int64     `json:"total_response_bytes"`
+	UniqueUsers        int       `json:"unique_users"`
+}
+
+// APIQuota represents API usage quotas for a developer.
+type APIQuota struct {
+	DevID            string    `json:"dev_id"`
+	DailyLimit       int       `json:"daily_limit"`
+	MonthlyLimit     int       `json:"monthly_limit"`
+	DailyUsed        int       `json:"daily_used"`
+	MonthlyUsed      int       `json:"monthly_used"`
+	LastResetDaily   time.Time `json:"last_reset_daily"`
+	LastResetMonthly time.Time `json:"last_reset_monthly"`
+	OverageAllowed   bool      `json:"overage_allowed"`
+}
+
+// APIAnalytics provides analytics tracking for the Web API.
+type APIAnalytics struct {
+	db        *sql.DB
+	logger    *slog.Logger
+	batchSize int
+	buffer    []APIUsageLog
+	bufferMu  sync.Mutex
+	ticker    *time.Ticker
+	done      chan bool
+}
+
+// NewAPIAnalytics creates a new API analytics instance.
+func NewAPIAnalytics(db *sql.DB, logger *slog.Logger) *APIAnalytics {
+	analytics := &APIAnalytics{
+		db:        db,
+		logger:    logger,
+		batchSize: 100,
+		buffer:    make([]APIUsageLog, 0, 100),
+		ticker:    time.NewTicker(5 * time.Second),
+		done:      make(chan bool),
+	}
+
+	// Start background worker for batch processing
+	go analytics.batchProcessor()
+
+	return analytics
+}
+
+// LogRequest logs an API request asynchronously.
+func (a *APIAnalytics) LogRequest(ctx context.Context, log APIUsageLog) {
+	a.bufferMu.Lock()
+	defer a.bufferMu.Unlock()
+
+	a.buffer = append(a.buffer, log)
+
+	// Flush if buffer is full
+	if len(a.buffer) >= a.batchSize {
+		go a.flush(context.Background())
+	}
+}
+
+// LogHTTPRequest logs an HTTP request with timing information.
+func (a *APIAnalytics) LogHTTPRequest(ctx context.Context, r *http.Request, statusCode int, responseTime time.Duration, responseSize int, errorMsg string) {
+	// Extract IP address
+	ip := r.RemoteAddr
+	if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
+		ip = strings.Split(forwarded, ",")[0]
+	}
+
+	// Get request size
+	requestSize := 0
+	if r.ContentLength > 0 {
+		requestSize = int(r.ContentLength)
+	}
+
+	// Extract dev_id from context (set by auth middleware)
+	devID := ""
+	if val := r.Context().Value("dev_id"); val != nil {
+		devID = val.(string)
+	}
+
+	// Extract screen name if available
+	screenName := ""
+	if val := r.Context().Value("screen_name"); val != nil {
+		screenName = val.(string)
+	}
+
+	log := APIUsageLog{
+		DevID:          devID,
+		Endpoint:       r.URL.Path,
+		Method:         r.Method,
+		Timestamp:      time.Now(),
+		ResponseTimeMs: int(responseTime.Milliseconds()),
+		StatusCode:     statusCode,
+		IPAddress:      ip,
+		UserAgent:      r.UserAgent(),
+		ScreenName:     screenName,
+		ErrorMessage:   errorMsg,
+		RequestSize:    requestSize,
+		ResponseSize:   responseSize,
+	}
+
+	a.LogRequest(ctx, log)
+}
+
+// batchProcessor processes buffered logs in batches.
+func (a *APIAnalytics) batchProcessor() {
+	for {
+		select {
+		case <-a.ticker.C:
+			a.flush(context.Background())
+		case <-a.done:
+			a.flush(context.Background()) // Final flush
+			return
+		}
+	}
+}
+
+// flush writes buffered logs to the database.
+func (a *APIAnalytics) flush(ctx context.Context) {
+	a.bufferMu.Lock()
+	if len(a.buffer) == 0 {
+		a.bufferMu.Unlock()
+		return
+	}
+
+	// Copy buffer and clear it
+	logs := make([]APIUsageLog, len(a.buffer))
+	copy(logs, a.buffer)
+	a.buffer = a.buffer[:0]
+	a.bufferMu.Unlock()
+
+	// Insert logs in a transaction
+	tx, err := a.db.Begin()
+	if err != nil {
+		a.logger.Error("failed to begin transaction for analytics", "error", err)
+		return
+	}
+	defer tx.Rollback()
+
+	stmt, err := tx.Prepare(`
+		INSERT INTO api_usage_logs (
+			dev_id, endpoint, method, timestamp, response_time_ms,
+			status_code, ip_address, user_agent, screen_name,
+			error_message, request_size, response_size
+		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+	`)
+	if err != nil {
+		a.logger.Error("failed to prepare analytics insert statement", "error", err)
+		return
+	}
+	defer stmt.Close()
+
+	for _, log := range logs {
+		_, err := stmt.Exec(
+			log.DevID, log.Endpoint, log.Method, log.Timestamp.Unix(),
+			log.ResponseTimeMs, log.StatusCode, log.IPAddress, log.UserAgent,
+			nullString(log.ScreenName), nullString(log.ErrorMessage),
+			log.RequestSize, log.ResponseSize,
+		)
+		if err != nil {
+			a.logger.Error("failed to insert analytics log", "error", err)
+			continue
+		}
+	}
+
+	if err := tx.Commit(); err != nil {
+		a.logger.Error("failed to commit analytics transaction", "error", err)
+	}
+}
+
+// GetUsageStats retrieves aggregated usage statistics for a developer.
+func (a *APIAnalytics) GetUsageStats(ctx context.Context, devID string, periodType string, startTime, endTime time.Time) ([]APIUsageStats, error) {
+	query := `
+		SELECT 
+			dev_id, endpoint, COUNT(*) as request_count,
+			SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) as error_count,
+			SUM(response_time_ms) as total_response_time,
+			AVG(response_time_ms) as avg_response_time,
+			SUM(request_size) as total_request_bytes,
+			SUM(response_size) as total_response_bytes,
+			COUNT(DISTINCT screen_name) as unique_users
+		FROM api_usage_logs
+		WHERE dev_id = ? AND timestamp >= ? AND timestamp <= ?
+		GROUP BY dev_id, endpoint
+		ORDER BY request_count DESC
+	`
+
+	rows, err := a.db.QueryContext(ctx, query, devID, startTime.Unix(), endTime.Unix())
+	if err != nil {
+		return nil, fmt.Errorf("failed to query usage stats: %w", err)
+	}
+	defer rows.Close()
+
+	var stats []APIUsageStats
+	for rows.Next() {
+		var s APIUsageStats
+		err := rows.Scan(
+			&s.DevID, &s.Endpoint, &s.RequestCount,
+			&s.ErrorCount, &s.TotalResponseTime, &s.AvgResponseTime,
+			&s.TotalRequestBytes, &s.TotalResponseBytes, &s.UniqueUsers,
+		)
+		if err != nil {
+			return nil, fmt.Errorf("failed to scan usage stats: %w", err)
+		}
+		s.PeriodType = periodType
+		s.PeriodStart = startTime
+		stats = append(stats, s)
+	}
+
+	return stats, nil
+}
+
+// GetTopEndpoints retrieves the most used endpoints for a developer.
+func (a *APIAnalytics) GetTopEndpoints(ctx context.Context, devID string, limit int) ([]struct {
+	Endpoint string `json:"endpoint"`
+	Count    int    `json:"count"`
+}, error) {
+	query := `
+		SELECT endpoint, COUNT(*) as count
+		FROM api_usage_logs
+		WHERE dev_id = ? AND timestamp >= ?
+		GROUP BY endpoint
+		ORDER BY count DESC
+		LIMIT ?
+	`
+
+	// Look at last 24 hours
+	since := time.Now().Add(-24 * time.Hour).Unix()
+
+	rows, err := a.db.QueryContext(ctx, query, devID, since, limit)
+	if err != nil {
+		return nil, fmt.Errorf("failed to query top endpoints: %w", err)
+	}
+	defer rows.Close()
+
+	var endpoints []struct {
+		Endpoint string `json:"endpoint"`
+		Count    int    `json:"count"`
+	}
+
+	for rows.Next() {
+		var e struct {
+			Endpoint string `json:"endpoint"`
+			Count    int    `json:"count"`
+		}
+		if err := rows.Scan(&e.Endpoint, &e.Count); err != nil {
+			return nil, fmt.Errorf("failed to scan endpoint: %w", err)
+		}
+		endpoints = append(endpoints, e)
+	}
+
+	return endpoints, nil
+}
+
+// CheckQuota checks if a developer has exceeded their usage quota.
+func (a *APIAnalytics) CheckQuota(ctx context.Context, devID string) (bool, *APIQuota, error) {
+	// Get or create quota record
+	quota, err := a.getOrCreateQuota(ctx, devID)
+	if err != nil {
+		return false, nil, err
+	}
+
+	// Check if quotas need to be reset
+	now := time.Now()
+	needsUpdate := false
+
+	// Reset daily quota if needed
+	if now.Sub(quota.LastResetDaily) >= 24*time.Hour {
+		quota.DailyUsed = 0
+		quota.LastResetDaily = now.Truncate(24 * time.Hour)
+		needsUpdate = true
+	}
+
+	// Reset monthly quota if needed
+	if now.Month() != quota.LastResetMonthly.Month() || now.Year() != quota.LastResetMonthly.Year() {
+		quota.MonthlyUsed = 0
+		quota.LastResetMonthly = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
+		needsUpdate = true
+	}
+
+	// Update quota if needed
+	if needsUpdate {
+		if err := a.updateQuota(ctx, quota); err != nil {
+			return false, nil, err
+		}
+	}
+
+	// Check if within limits
+	withinLimits := (quota.DailyUsed < quota.DailyLimit && quota.MonthlyUsed < quota.MonthlyLimit) || quota.OverageAllowed
+
+	return withinLimits, quota, nil
+}
+
+// IncrementQuotaUsage increments the usage counters for a developer.
+func (a *APIAnalytics) IncrementQuotaUsage(ctx context.Context, devID string) error {
+	query := `
+		UPDATE api_quotas
+		SET daily_used = daily_used + 1,
+		    monthly_used = monthly_used + 1
+		WHERE dev_id = ?
+	`
+
+	_, err := a.db.ExecContext(ctx, query, devID)
+	return err
+}
+
+// getOrCreateQuota retrieves or creates a quota record for a developer.
+func (a *APIAnalytics) getOrCreateQuota(ctx context.Context, devID string) (*APIQuota, error) {
+	quota := &APIQuota{DevID: devID}
+
+	query := `
+		SELECT daily_limit, monthly_limit, daily_used, monthly_used,
+		       last_reset_daily, last_reset_monthly, overage_allowed
+		FROM api_quotas
+		WHERE dev_id = ?
+	`
+
+	err := a.db.QueryRowContext(ctx, query, devID).Scan(
+		&quota.DailyLimit, &quota.MonthlyLimit,
+		&quota.DailyUsed, &quota.MonthlyUsed,
+		&quota.LastResetDaily, &quota.LastResetMonthly,
+		&quota.OverageAllowed,
+	)
+
+	if err == sql.ErrNoRows {
+		// Create default quota
+		now := time.Now()
+		quota = &APIQuota{
+			DevID:            devID,
+			DailyLimit:       10000,
+			MonthlyLimit:     300000,
+			DailyUsed:        0,
+			MonthlyUsed:      0,
+			LastResetDaily:   now.Truncate(24 * time.Hour),
+			LastResetMonthly: time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()),
+			OverageAllowed:   false,
+		}
+
+		insertQuery := `
+			INSERT INTO api_quotas (
+				dev_id, daily_limit, monthly_limit, daily_used, monthly_used,
+				last_reset_daily, last_reset_monthly, overage_allowed
+			) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+		`
+
+		_, err = a.db.ExecContext(ctx, insertQuery,
+			quota.DevID, quota.DailyLimit, quota.MonthlyLimit,
+			quota.DailyUsed, quota.MonthlyUsed,
+			quota.LastResetDaily.Unix(), quota.LastResetMonthly.Unix(),
+			quota.OverageAllowed,
+		)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create quota: %w", err)
+		}
+	} else if err != nil {
+		return nil, fmt.Errorf("failed to get quota: %w", err)
+	}
+
+	return quota, nil
+}
+
+// updateQuota updates a quota record.
+func (a *APIAnalytics) updateQuota(ctx context.Context, quota *APIQuota) error {
+	query := `
+		UPDATE api_quotas
+		SET daily_used = ?, monthly_used = ?,
+		    last_reset_daily = ?, last_reset_monthly = ?
+		WHERE dev_id = ?
+	`
+
+	_, err := a.db.ExecContext(ctx, query,
+		quota.DailyUsed, quota.MonthlyUsed,
+		quota.LastResetDaily.Unix(), quota.LastResetMonthly.Unix(),
+		quota.DevID,
+	)
+	return err
+}
+
+// Close stops the analytics processor.
+func (a *APIAnalytics) Close() {
+	close(a.done)
+	a.ticker.Stop()
+}
+
+// nullString returns a sql.NullString for the given string.
+func nullString(s string) sql.NullString {
+	if s == "" {
+		return sql.NullString{Valid: false}
+	}
+	return sql.NullString{String: s, Valid: true}
+}

+ 114 - 0
state/webapi_auth.go

@@ -0,0 +1,114 @@
+package state
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"time"
+)
+
+// WebAPITokenStore manages authentication tokens for Web API sessions.
+type WebAPITokenStore struct {
+	store *SQLiteUserStore
+}
+
+// NewWebAPITokenStore creates a new token store.
+func (s *SQLiteUserStore) NewWebAPITokenStore() *WebAPITokenStore {
+	return &WebAPITokenStore{store: s}
+}
+
+// StoreToken saves an authentication token for a user.
+func (s *WebAPITokenStore) StoreToken(ctx context.Context, token string, screenName IdentScreenName, expiresAt time.Time) error {
+	query := `
+		INSERT INTO webapi_tokens (token, screen_name, expires_at, created_at)
+		VALUES (?, ?, ?, ?)
+		ON CONFLICT(token) DO UPDATE SET
+			screen_name = excluded.screen_name,
+			expires_at = excluded.expires_at
+	`
+	_, err := s.store.db.ExecContext(ctx, query, token, screenName.String(), expiresAt, time.Now())
+	if err != nil {
+		return fmt.Errorf("failed to store token: %w", err)
+	}
+	return nil
+}
+
+// ValidateToken checks if a token is valid and returns the associated screen name.
+func (s *WebAPITokenStore) ValidateToken(ctx context.Context, token string) (IdentScreenName, error) {
+	var screenNameStr string
+	var expiresAt time.Time
+
+	query := `
+		SELECT screen_name, expires_at 
+		FROM webapi_tokens 
+		WHERE token = ?
+	`
+	err := s.store.db.QueryRowContext(ctx, query, token).Scan(&screenNameStr, &expiresAt)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return NewIdentScreenName(""), errors.New("invalid token")
+		}
+		return NewIdentScreenName(""), fmt.Errorf("failed to validate token: %w", err)
+	}
+
+	// Check if token has expired
+	if time.Now().After(expiresAt) {
+		// Clean up expired token
+		s.DeleteToken(ctx, token)
+		return NewIdentScreenName(""), errors.New("token expired")
+	}
+
+	return NewIdentScreenName(screenNameStr), nil
+}
+
+// DeleteToken removes a token.
+func (s *WebAPITokenStore) DeleteToken(ctx context.Context, token string) error {
+	query := `DELETE FROM webapi_tokens WHERE token = ?`
+	_, err := s.store.db.ExecContext(ctx, query, token)
+	if err != nil {
+		return fmt.Errorf("failed to delete token: %w", err)
+	}
+	return nil
+}
+
+// CleanupExpiredTokens removes all expired tokens from the database.
+func (s *WebAPITokenStore) CleanupExpiredTokens(ctx context.Context) error {
+	query := `DELETE FROM webapi_tokens WHERE expires_at < ?`
+	_, err := s.store.db.ExecContext(ctx, query, time.Now())
+	if err != nil {
+		return fmt.Errorf("failed to cleanup expired tokens: %w", err)
+	}
+	return nil
+}
+
+// AuthenticateUser verifies username and password.
+// This implementation uses the existing user store for authentication.
+func (u *SQLiteUserStore) AuthenticateUser(ctx context.Context, username, password string) (*User, error) {
+	// Convert username to IdentScreenName for lookup
+	identSN := NewIdentScreenName(username)
+
+	// Try to find the user
+	user, err := u.User(ctx, identSN)
+	if err != nil {
+		return nil, fmt.Errorf("user not found: %w", err)
+	}
+
+	// In development mode with DISABLE_AUTH=true, accept any password
+	// In production, this would verify the password hash
+	// For now, we'll accept any non-empty password if the user exists
+	if password == "" {
+		return nil, errors.New("password required")
+	}
+
+	// TODO: In production, verify password hash here
+	// For development with DISABLE_AUTH, we just check if user exists
+
+	return user, nil
+}
+
+// FindUserByScreenName finds a user by their screen name.
+// This is just an alias for the User method to satisfy the UserManager interface.
+func (u *SQLiteUserStore) FindUserByScreenName(ctx context.Context, screenName IdentScreenName) (*User, error) {
+	return u.User(ctx, screenName)
+}

+ 324 - 0
state/webapi_buddyfeed.go

@@ -0,0 +1,324 @@
+package state
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"strings"
+	"time"
+)
+
+// BuddyFeed represents a user's feed configuration.
+type BuddyFeed struct {
+	ID          int64     `json:"id"`
+	ScreenName  string    `json:"screenName"`
+	FeedType    string    `json:"feedType"`
+	Title       string    `json:"title"`
+	Description string    `json:"description"`
+	Link        string    `json:"link"`
+	PublishedAt time.Time `json:"publishedAt"`
+	CreatedAt   time.Time `json:"createdAt"`
+	UpdatedAt   time.Time `json:"updatedAt"`
+	IsActive    bool      `json:"isActive"`
+}
+
+// BuddyFeedItem represents an individual feed entry.
+type BuddyFeedItem struct {
+	ID          int64     `json:"id"`
+	FeedID      int64     `json:"feedId"`
+	Title       string    `json:"title"`
+	Description string    `json:"description"`
+	Link        string    `json:"link"`
+	GUID        string    `json:"guid"`
+	Author      string    `json:"author"`
+	Categories  []string  `json:"categories"`
+	PublishedAt time.Time `json:"publishedAt"`
+	CreatedAt   time.Time `json:"createdAt"`
+}
+
+// BuddyFeedSubscription represents a feed subscription.
+type BuddyFeedSubscription struct {
+	ID                   int64      `json:"id"`
+	SubscriberScreenName string     `json:"subscriberScreenName"`
+	FeedID               int64      `json:"feedId"`
+	SubscribedAt         time.Time  `json:"subscribedAt"`
+	LastCheckedAt        *time.Time `json:"lastCheckedAt"`
+}
+
+// BuddyFeedManager manages buddy feed operations.
+type BuddyFeedManager struct {
+	db     *sql.DB
+	logger *slog.Logger
+}
+
+// NewBuddyFeedManager creates a new buddy feed manager.
+func NewBuddyFeedManager(db *sql.DB, logger *slog.Logger) *BuddyFeedManager {
+	return &BuddyFeedManager{
+		db:     db,
+		logger: logger,
+	}
+}
+
+// CreateFeed creates a new buddy feed.
+func (m *BuddyFeedManager) CreateFeed(ctx context.Context, feed BuddyFeed) (*BuddyFeed, error) {
+	now := time.Now()
+	query := `
+		INSERT INTO buddy_feeds (
+			screen_name, feed_type, title, description, link,
+			published_at, created_at, updated_at, is_active
+		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+		RETURNING id
+	`
+
+	var id int64
+	err := m.db.QueryRowContext(ctx, query,
+		feed.ScreenName, feed.FeedType, feed.Title, feed.Description, feed.Link,
+		feed.PublishedAt.Unix(), now.Unix(), now.Unix(), feed.IsActive,
+	).Scan(&id)
+
+	if err != nil {
+		return nil, fmt.Errorf("failed to create feed: %w", err)
+	}
+
+	feed.ID = id
+	feed.CreatedAt = now
+	feed.UpdatedAt = now
+
+	return &feed, nil
+}
+
+// GetUserFeed retrieves the feed configuration for a specific user.
+func (m *BuddyFeedManager) GetUserFeed(ctx context.Context, screenName string) (*BuddyFeed, error) {
+	var feed BuddyFeed
+	query := `
+		SELECT id, screen_name, feed_type, title, description, link,
+		       published_at, created_at, updated_at, is_active
+		FROM buddy_feeds
+		WHERE screen_name = ? AND is_active = 1
+		ORDER BY published_at DESC
+		LIMIT 1
+	`
+
+	err := m.db.QueryRowContext(ctx, query, screenName).Scan(
+		&feed.ID, &feed.ScreenName, &feed.FeedType, &feed.Title,
+		&feed.Description, &feed.Link, &feed.PublishedAt,
+		&feed.CreatedAt, &feed.UpdatedAt, &feed.IsActive,
+	)
+
+	if err == sql.ErrNoRows {
+		return nil, nil // No feed found
+	}
+	if err != nil {
+		return nil, fmt.Errorf("failed to get user feed: %w", err)
+	}
+
+	return &feed, nil
+}
+
+// GetBuddyListFeedItems retrieves aggregated feed items for a user's buddy list.
+func (m *BuddyFeedManager) GetBuddyListFeedItems(ctx context.Context, buddies []IdentScreenName, limit int) ([]BuddyFeedItem, error) {
+	if limit <= 0 {
+		limit = 100 // Default limit
+	}
+
+	if len(buddies) == 0 {
+		return []BuddyFeedItem{}, nil
+	}
+
+	// Build placeholders for IN clause
+	placeholders := make([]string, len(buddies))
+	args := make([]interface{}, len(buddies)+1)
+	for i, buddy := range buddies {
+		placeholders[i] = "?"
+		args[i] = buddy.String()
+	}
+	args[len(buddies)] = limit
+
+	// Query to get feed items from all buddies, sorted by published date
+	query := fmt.Sprintf(`
+		SELECT i.id, i.feed_id, i.title, i.description, i.link, i.guid,
+		       i.author, i.categories, i.published_at, i.created_at
+		FROM buddy_feed_items i
+		JOIN buddy_feeds f ON i.feed_id = f.id
+		WHERE f.screen_name IN (%s) AND f.is_active = 1
+		ORDER BY i.published_at DESC
+		LIMIT ?
+	`, strings.Join(placeholders, ","))
+
+	rows, err := m.db.QueryContext(ctx, query, args...)
+	if err != nil {
+		return nil, fmt.Errorf("failed to query buddy list feed items: %w", err)
+	}
+	defer rows.Close()
+
+	return m.scanFeedItems(rows)
+}
+
+// GetUserFeedItems retrieves feed items for a specific user.
+func (m *BuddyFeedManager) GetUserFeedItems(ctx context.Context, screenName string, limit int) ([]BuddyFeedItem, error) {
+	query := `
+		SELECT i.id, i.feed_id, i.title, i.description, i.link, i.guid,
+		       i.author, i.categories, i.published_at, i.created_at
+		FROM buddy_feed_items i
+		JOIN buddy_feeds f ON i.feed_id = f.id
+		WHERE f.screen_name = ? AND f.is_active = 1
+		ORDER BY i.published_at DESC
+		LIMIT ?
+	`
+
+	rows, err := m.db.QueryContext(ctx, query, screenName, limit)
+	if err != nil {
+		return nil, fmt.Errorf("failed to query feed items: %w", err)
+	}
+	defer rows.Close()
+
+	var items []BuddyFeedItem
+	for rows.Next() {
+		var item BuddyFeedItem
+		var categoriesJSON sql.NullString
+		var publishedAt, createdAt int64
+
+		err := rows.Scan(
+			&item.ID, &item.FeedID, &item.Title, &item.Description,
+			&item.Link, &item.GUID, &item.Author, &categoriesJSON,
+			&publishedAt, &createdAt,
+		)
+		if err != nil {
+			return nil, fmt.Errorf("failed to scan feed item: %w", err)
+		}
+
+		item.PublishedAt = time.Unix(publishedAt, 0)
+		item.CreatedAt = time.Unix(createdAt, 0)
+
+		if categoriesJSON.Valid {
+			json.Unmarshal([]byte(categoriesJSON.String), &item.Categories)
+		}
+
+		items = append(items, item)
+	}
+
+	return items, nil
+}
+
+// GetFeedItems retrieves items for a specific feed.
+func (m *BuddyFeedManager) GetFeedItems(ctx context.Context, feedID int64, limit int) ([]BuddyFeedItem, error) {
+	query := `
+		SELECT id, feed_id, title, description, link, guid,
+		       author, categories, published_at, created_at
+		FROM buddy_feed_items
+		WHERE feed_id = ?
+		ORDER BY published_at DESC
+		LIMIT ?
+	`
+
+	rows, err := m.db.QueryContext(ctx, query, feedID, limit)
+	if err != nil {
+		return nil, fmt.Errorf("failed to query feed items: %w", err)
+	}
+	defer rows.Close()
+
+	return m.scanFeedItems(rows)
+}
+
+// scanFeedItems is a helper to scan feed items from database rows.
+func (m *BuddyFeedManager) scanFeedItems(rows *sql.Rows) ([]BuddyFeedItem, error) {
+	var items []BuddyFeedItem
+	for rows.Next() {
+		var item BuddyFeedItem
+		var categoriesJSON sql.NullString
+		var publishedAt, createdAt int64
+
+		err := rows.Scan(
+			&item.ID, &item.FeedID, &item.Title, &item.Description,
+			&item.Link, &item.GUID, &item.Author, &categoriesJSON,
+			&publishedAt, &createdAt,
+		)
+		if err != nil {
+			return nil, fmt.Errorf("failed to scan feed item: %w", err)
+		}
+
+		item.PublishedAt = time.Unix(publishedAt, 0)
+		item.CreatedAt = time.Unix(createdAt, 0)
+
+		if categoriesJSON.Valid {
+			json.Unmarshal([]byte(categoriesJSON.String), &item.Categories)
+		}
+
+		items = append(items, item)
+	}
+
+	return items, nil
+}
+
+// AddFeedItem adds a new item to a feed.
+func (m *BuddyFeedManager) AddFeedItem(ctx context.Context, feedID int64, item BuddyFeedItem) (*BuddyFeedItem, error) {
+	categoriesJSON, _ := json.Marshal(item.Categories)
+	now := time.Now()
+
+	query := `
+		INSERT INTO buddy_feed_items (
+			feed_id, title, description, link, guid,
+			author, categories, published_at, created_at
+		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+		RETURNING id
+	`
+
+	var id int64
+	err := m.db.QueryRowContext(ctx, query,
+		feedID, item.Title, item.Description, item.Link, item.GUID,
+		item.Author, string(categoriesJSON), item.PublishedAt.Unix(), now.Unix(),
+	).Scan(&id)
+
+	if err != nil {
+		return nil, fmt.Errorf("failed to add feed item: %w", err)
+	}
+
+	item.ID = id
+	item.FeedID = feedID
+	item.CreatedAt = now
+
+	// Update feed's updated_at timestamp
+	updateQuery := `UPDATE buddy_feeds SET updated_at = ? WHERE id = ?`
+	m.db.ExecContext(ctx, updateQuery, now.Unix(), feedID)
+
+	return &item, nil
+}
+
+// GetOrCreateFeedForUser gets an existing feed or creates a new one for a user.
+func (m *BuddyFeedManager) GetOrCreateFeedForUser(ctx context.Context, screenName string, feedType string) (int64, error) {
+	var feedID int64
+	query := `SELECT id FROM buddy_feeds WHERE screen_name = ? AND is_active = 1 LIMIT 1`
+	err := m.db.QueryRowContext(ctx, query, screenName).Scan(&feedID)
+
+	if err == nil {
+		return feedID, nil
+	}
+
+	if err != sql.ErrNoRows {
+		return 0, fmt.Errorf("failed to query feed: %w", err)
+	}
+
+	// Create new feed
+	if feedType == "" {
+		feedType = "status"
+	}
+
+	feed := BuddyFeed{
+		ScreenName:  screenName,
+		FeedType:    feedType,
+		Title:       fmt.Sprintf("%s's Feed", screenName),
+		Description: fmt.Sprintf("Updates from %s", screenName),
+		Link:        fmt.Sprintf("/buddyfeed/getUser?u=%s", screenName),
+		PublishedAt: time.Now(),
+		IsActive:    true,
+	}
+
+	createdFeed, err := m.CreateFeed(ctx, feed)
+	if err != nil {
+		return 0, fmt.Errorf("failed to create feed: %w", err)
+	}
+
+	return createdFeed.ID, nil
+}

+ 711 - 0
state/webapi_chat.go

@@ -0,0 +1,711 @@
+package state
+
+import (
+	"context"
+	"crypto/rand"
+	"database/sql"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"log/slog"
+	"sync"
+	"time"
+)
+
+// ChatRoomType represents the type of chat room
+type ChatRoomType string
+
+const (
+	ChatRoomTypeUserCreated ChatRoomType = "userCreated"
+)
+
+// ChatEventType represents the type of chat event
+type ChatEventType string
+
+const (
+	ChatEventUserInRoom  ChatEventType = "userInRoom"
+	ChatEventUserEntered ChatEventType = "userEntered"
+	ChatEventUserLeft    ChatEventType = "userLeft"
+	ChatEventMessage     ChatEventType = "message"
+	ChatEventTyping      ChatEventType = "typing"
+	ChatEventClosed      ChatEventType = "closed"
+)
+
+// WebAPIChatRoom represents a chat room for Web API
+type WebAPIChatRoom struct {
+	RoomID            string       `json:"roomId"`
+	RoomName          string       `json:"roomName"`
+	Description       string       `json:"description,omitempty"`
+	RoomType          ChatRoomType `json:"roomType"`
+	CategoryID        string       `json:"categoryId,omitempty"`
+	CreatorScreenName string       `json:"-"` // Internal only
+	CreatedAt         int64        `json:"-"`
+	ClosedAt          *int64       `json:"-"`
+	MaxParticipants   int          `json:"-"`
+	InstanceID        int          `json:"instanceId"`
+}
+
+// ChatSession represents a user's session in a chat room
+type ChatSession struct {
+	ChatSID    string
+	AIMSid     string
+	RoomID     string
+	ScreenName string
+	InstanceID int
+	JoinedAt   int64
+	LeftAt     *int64
+}
+
+// ChatMessage represents a message sent in a chat room
+type ChatMessage struct {
+	ID            int64
+	RoomID        string
+	ScreenName    string
+	Message       string
+	WhisperTarget string
+	Timestamp     int64
+}
+
+// ChatParticipant represents a participant in a chat room
+type ChatParticipant struct {
+	RoomID          string
+	ScreenName      string
+	ChatSID         string
+	JoinedAt        int64
+	TypingStatus    string
+	TypingUpdatedAt *int64
+}
+
+// ChatEventData represents data for a chat event
+type ChatEventData struct {
+	ChatSID   string        `json:"chatsid"`
+	EventType ChatEventType `json:"eventType"`
+	EventData interface{}   `json:"eventData"`
+}
+
+// ChatMessageEventData represents chat message event data
+type ChatMessageEventData struct {
+	ScreenName    string `json:"screenName"`
+	Message       string `json:"message"`
+	Timestamp     int64  `json:"timestamp"`
+	WhisperTarget string `json:"whisperTarget,omitempty"`
+}
+
+// ChatUserEventData represents user join/leave event data
+type ChatUserEventData struct {
+	ScreenName string `json:"screenName"`
+	Timestamp  int64  `json:"timestamp"`
+}
+
+// ChatTypingEventData represents typing status event data
+type ChatTypingEventData struct {
+	ScreenName   string `json:"screenName"`
+	TypingStatus string `json:"typingStatus"`
+}
+
+// ChatParticipantList represents a list of participants in the room
+type ChatParticipantList struct {
+	Participants []string `json:"participants"`
+}
+
+// WebAPIChatManager manages Web API chat rooms
+type WebAPIChatManager struct {
+	store    *SQLiteUserStore
+	logger   *slog.Logger
+	sessions *WebAPISessionManager
+	mu       sync.RWMutex
+	// In-memory cache for active rooms
+	activeRooms map[string]*WebAPIChatRoom
+	// Track typing timeouts
+	typingTimers map[string]*time.Timer
+}
+
+// NewWebAPIChatManager creates a new WebAPIChatManager
+func (s *SQLiteUserStore) NewWebAPIChatManager(logger *slog.Logger, sessions *WebAPISessionManager) *WebAPIChatManager {
+	return &WebAPIChatManager{
+		store:        s,
+		logger:       logger,
+		sessions:     sessions,
+		activeRooms:  make(map[string]*WebAPIChatRoom),
+		typingTimers: make(map[string]*time.Timer),
+	}
+}
+
+// CreateAndJoinChat creates a new chat room or joins an existing one
+func (m *WebAPIChatManager) CreateAndJoinChat(ctx context.Context, aimsid, roomID, roomName, screenName string) (*ChatSession, *WebAPIChatRoom, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	var room *WebAPIChatRoom
+	var err error
+
+	// Determine which identifier to use
+	if roomID != "" {
+		room, err = m.getRoomByID(ctx, roomID)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to get room by ID: %w", err)
+		}
+	} else if roomName != "" {
+		room, err = m.getRoomByName(ctx, roomName)
+		if err != nil && !errors.Is(err, sql.ErrNoRows) {
+			return nil, nil, fmt.Errorf("failed to get room by name: %w", err)
+		}
+		// If room doesn't exist, create it
+		if room == nil {
+			room, err = m.createRoom(ctx, roomName, screenName)
+			if err != nil {
+				return nil, nil, fmt.Errorf("failed to create room: %w", err)
+			}
+		}
+	} else {
+		return nil, nil, errors.New("either roomId or roomName must be provided")
+	}
+
+	// Check if user is already in the room
+	existingSession, _ := m.getUserSessionInRoom(ctx, aimsid, room.RoomID)
+	if existingSession != nil {
+		return existingSession, room, nil
+	}
+
+	// Check room capacity
+	count, err := m.getParticipantCount(ctx, room.RoomID)
+	if err != nil {
+		return nil, nil, fmt.Errorf("failed to get participant count: %w", err)
+	}
+	if count >= room.MaxParticipants {
+		return nil, nil, errors.New("room is at maximum capacity")
+	}
+
+	// Create chat session
+	session := &ChatSession{
+		ChatSID:    m.generateChatSID(),
+		AIMSid:     aimsid,
+		RoomID:     room.RoomID,
+		ScreenName: screenName,
+		InstanceID: room.InstanceID,
+		JoinedAt:   time.Now().Unix(),
+	}
+
+	// Insert session into database
+	_, err = m.store.db.ExecContext(ctx, `
+		INSERT INTO web_chat_sessions (chat_sid, aimsid, room_id, screen_name, instance_id, joined_at)
+		VALUES (?, ?, ?, ?, ?, ?)`,
+		session.ChatSID, session.AIMSid, session.RoomID, session.ScreenName, session.InstanceID, session.JoinedAt)
+	if err != nil {
+		return nil, nil, fmt.Errorf("failed to create chat session: %w", err)
+	}
+
+	// Add participant to room
+	_, err = m.store.db.ExecContext(ctx, `
+		INSERT INTO web_chat_participants (room_id, screen_name, chat_sid, joined_at, typing_status)
+		VALUES (?, ?, ?, ?, 'none')`,
+		room.RoomID, screenName, session.ChatSID, session.JoinedAt)
+	if err != nil {
+		return nil, nil, fmt.Errorf("failed to add participant: %w", err)
+	}
+
+	// Broadcast user joined event
+	// Note: Broadcasting doesn't need context as it's fire-and-forget
+	m.broadcastChatEvent(room.RoomID, ChatEventData{
+		ChatSID:   session.ChatSID,
+		EventType: ChatEventUserEntered,
+		EventData: ChatUserEventData{
+			ScreenName: screenName,
+			Timestamp:  session.JoinedAt,
+		},
+	})
+
+	// Send current participant list to the new user
+	participants, _ := m.getParticipants(ctx, room.RoomID)
+	m.sendChatEventToUser(aimsid, ChatEventData{
+		ChatSID:   session.ChatSID,
+		EventType: ChatEventUserInRoom,
+		EventData: ChatParticipantList{
+			Participants: participants,
+		},
+	})
+
+	return session, room, nil
+}
+
+// SendMessage sends a message to a chat room
+func (m *WebAPIChatManager) SendMessage(ctx context.Context, chatsid, message, whisperTarget string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	// Get session
+	session, err := m.getSessionByChatSID(ctx, chatsid)
+	if err != nil {
+		return fmt.Errorf("invalid chat session: %w", err)
+	}
+
+	// Verify user is still in room
+	if session.LeftAt != nil {
+		return errors.New("user has left the chat room")
+	}
+
+	// Store message in database
+	timestamp := time.Now().Unix()
+	_, err = m.store.db.ExecContext(ctx, `
+		INSERT INTO web_chat_messages (room_id, screen_name, message, whisper_target, timestamp)
+		VALUES (?, ?, ?, ?, ?)`,
+		session.RoomID, session.ScreenName, message, whisperTarget, timestamp)
+	if err != nil {
+		return fmt.Errorf("failed to store message: %w", err)
+	}
+
+	// Broadcast message event
+	eventData := ChatMessageEventData{
+		ScreenName:    session.ScreenName,
+		Message:       message,
+		Timestamp:     timestamp,
+		WhisperTarget: whisperTarget,
+	}
+
+	if whisperTarget != "" {
+		// For whispers, only send to sender and target
+		m.sendChatEventToUser(session.AIMSid, ChatEventData{
+			ChatSID:   chatsid,
+			EventType: ChatEventMessage,
+			EventData: eventData,
+		})
+		// Find target's session and send to them
+		targetSession, _ := m.getUserSessionInRoomByScreenName(ctx, session.RoomID, whisperTarget)
+		if targetSession != nil {
+			m.sendChatEventToUser(targetSession.AIMSid, ChatEventData{
+				ChatSID:   targetSession.ChatSID,
+				EventType: ChatEventMessage,
+				EventData: eventData,
+			})
+		}
+	} else {
+		// Broadcast to all participants
+		m.broadcastChatEvent(session.RoomID, ChatEventData{
+			ChatSID:   chatsid,
+			EventType: ChatEventMessage,
+			EventData: eventData,
+		})
+	}
+
+	return nil
+}
+
+// SetTyping sets the typing status for a user in a chat room
+func (m *WebAPIChatManager) SetTyping(ctx context.Context, chatsid, typingStatus string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	// Get session
+	session, err := m.getSessionByChatSID(ctx, chatsid)
+	if err != nil {
+		return fmt.Errorf("invalid chat session: %w", err)
+	}
+
+	// Verify user is still in room
+	if session.LeftAt != nil {
+		return errors.New("user has left the chat room")
+	}
+
+	// Update typing status
+	now := time.Now().Unix()
+	_, err = m.store.db.ExecContext(ctx, `
+		UPDATE web_chat_participants 
+		SET typing_status = ?, typing_updated_at = ?
+		WHERE room_id = ? AND screen_name = ?`,
+		typingStatus, now, session.RoomID, session.ScreenName)
+	if err != nil {
+		return fmt.Errorf("failed to update typing status: %w", err)
+	}
+
+	// Cancel existing typing timer for this user
+	timerKey := fmt.Sprintf("%s:%s", session.RoomID, session.ScreenName)
+	if timer, exists := m.typingTimers[timerKey]; exists {
+		timer.Stop()
+		delete(m.typingTimers, timerKey)
+	}
+
+	// If status is "typing" or "typed", set a timer to reset it
+	if typingStatus == "typing" || typingStatus == "typed" {
+		timer := time.AfterFunc(10*time.Second, func() {
+			m.mu.Lock()
+			defer m.mu.Unlock()
+			// Reset typing status to none
+			// Using background context here since this is an async timer callback
+			// and the original context may have expired
+			m.store.db.ExecContext(context.Background(), `
+				UPDATE web_chat_participants 
+				SET typing_status = 'none', typing_updated_at = ?
+				WHERE room_id = ? AND screen_name = ?`,
+				time.Now().Unix(), session.RoomID, session.ScreenName)
+			// Broadcast the reset
+			m.broadcastChatEvent(session.RoomID, ChatEventData{
+				ChatSID:   chatsid,
+				EventType: ChatEventTyping,
+				EventData: ChatTypingEventData{
+					ScreenName:   session.ScreenName,
+					TypingStatus: "none",
+				},
+			})
+			delete(m.typingTimers, timerKey)
+		})
+		m.typingTimers[timerKey] = timer
+	}
+
+	// Broadcast typing event
+	m.broadcastChatEvent(session.RoomID, ChatEventData{
+		ChatSID:   chatsid,
+		EventType: ChatEventTyping,
+		EventData: ChatTypingEventData{
+			ScreenName:   session.ScreenName,
+			TypingStatus: typingStatus,
+		},
+	})
+
+	return nil
+}
+
+// LeaveChat removes a user from a chat room
+func (m *WebAPIChatManager) LeaveChat(ctx context.Context, chatsid string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	// Get session
+	session, err := m.getSessionByChatSID(ctx, chatsid)
+	if err != nil {
+		return fmt.Errorf("invalid chat session: %w", err)
+	}
+
+	// Mark session as left
+	now := time.Now().Unix()
+	_, err = m.store.db.ExecContext(ctx, `
+		UPDATE web_chat_sessions 
+		SET left_at = ?
+		WHERE chat_sid = ?`,
+		now, chatsid)
+	if err != nil {
+		return fmt.Errorf("failed to update session: %w", err)
+	}
+
+	// Remove from participants
+	_, err = m.store.db.ExecContext(ctx, `
+		DELETE FROM web_chat_participants
+		WHERE room_id = ? AND screen_name = ?`,
+		session.RoomID, session.ScreenName)
+	if err != nil {
+		return fmt.Errorf("failed to remove participant: %w", err)
+	}
+
+	// Cancel any typing timer
+	timerKey := fmt.Sprintf("%s:%s", session.RoomID, session.ScreenName)
+	if timer, exists := m.typingTimers[timerKey]; exists {
+		timer.Stop()
+		delete(m.typingTimers, timerKey)
+	}
+
+	// Broadcast user left event
+	// Note: Broadcasting doesn't need context as it's fire-and-forget
+	m.broadcastChatEvent(session.RoomID, ChatEventData{
+		ChatSID:   chatsid,
+		EventType: ChatEventUserLeft,
+		EventData: ChatUserEventData{
+			ScreenName: session.ScreenName,
+			Timestamp:  now,
+		},
+	})
+
+	// Check if room should be closed (no participants left)
+	count, _ := m.getParticipantCount(ctx, session.RoomID)
+	if count == 0 {
+		m.closeRoom(ctx, session.RoomID)
+	}
+
+	return nil
+}
+
+// Helper methods
+
+func (m *WebAPIChatManager) getRoomByID(ctx context.Context, roomID string) (*WebAPIChatRoom, error) {
+	var room WebAPIChatRoom
+	err := m.store.db.QueryRowContext(ctx, `
+		SELECT room_id, room_name, description, room_type, category_id, 
+		       creator_screen_name, created_at, closed_at, max_participants
+		FROM web_chat_rooms
+		WHERE room_id = ? AND closed_at IS NULL`,
+		roomID).Scan(
+		&room.RoomID, &room.RoomName, &room.Description, &room.RoomType,
+		&room.CategoryID, &room.CreatorScreenName, &room.CreatedAt,
+		&room.ClosedAt, &room.MaxParticipants)
+	if err != nil {
+		return nil, err
+	}
+	room.InstanceID = m.generateInstanceID()
+	return &room, nil
+}
+
+func (m *WebAPIChatManager) getRoomByName(ctx context.Context, roomName string) (*WebAPIChatRoom, error) {
+	var room WebAPIChatRoom
+	err := m.store.db.QueryRowContext(ctx, `
+		SELECT room_id, room_name, description, room_type, category_id, 
+		       creator_screen_name, created_at, closed_at, max_participants
+		FROM web_chat_rooms
+		WHERE room_name = ? AND closed_at IS NULL`,
+		roomName).Scan(
+		&room.RoomID, &room.RoomName, &room.Description, &room.RoomType,
+		&room.CategoryID, &room.CreatorScreenName, &room.CreatedAt,
+		&room.ClosedAt, &room.MaxParticipants)
+	if err != nil {
+		return nil, err
+	}
+	room.InstanceID = m.generateInstanceID()
+	return &room, nil
+}
+
+func (m *WebAPIChatManager) createRoom(ctx context.Context, roomName, creatorScreenName string) (*WebAPIChatRoom, error) {
+	room := &WebAPIChatRoom{
+		RoomID:            m.generateRoomID(),
+		RoomName:          roomName,
+		Description:       fmt.Sprintf("Chat room created by %s", creatorScreenName),
+		RoomType:          ChatRoomTypeUserCreated,
+		CreatorScreenName: creatorScreenName,
+		CreatedAt:         time.Now().Unix(),
+		MaxParticipants:   100,
+		InstanceID:        m.generateInstanceID(),
+	}
+
+	_, err := m.store.db.ExecContext(ctx, `
+		INSERT INTO web_chat_rooms (room_id, room_name, description, room_type, 
+		                            category_id, creator_screen_name, created_at, max_participants)
+		VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+		room.RoomID, room.RoomName, room.Description, room.RoomType,
+		room.CategoryID, room.CreatorScreenName, room.CreatedAt, room.MaxParticipants)
+	if err != nil {
+		return nil, err
+	}
+
+	// Cache the room
+	m.activeRooms[room.RoomID] = room
+
+	return room, nil
+}
+
+func (m *WebAPIChatManager) getSessionByChatSID(ctx context.Context, chatsid string) (*ChatSession, error) {
+	var session ChatSession
+	err := m.store.db.QueryRowContext(ctx, `
+		SELECT chat_sid, aimsid, room_id, screen_name, instance_id, joined_at, left_at
+		FROM web_chat_sessions
+		WHERE chat_sid = ?`,
+		chatsid).Scan(
+		&session.ChatSID, &session.AIMSid, &session.RoomID,
+		&session.ScreenName, &session.InstanceID, &session.JoinedAt, &session.LeftAt)
+	if err != nil {
+		return nil, err
+	}
+	return &session, nil
+}
+
+func (m *WebAPIChatManager) getUserSessionInRoom(ctx context.Context, aimsid, roomID string) (*ChatSession, error) {
+	var session ChatSession
+	err := m.store.db.QueryRowContext(ctx, `
+		SELECT chat_sid, aimsid, room_id, screen_name, instance_id, joined_at, left_at
+		FROM web_chat_sessions
+		WHERE aimsid = ? AND room_id = ? AND left_at IS NULL`,
+		aimsid, roomID).Scan(
+		&session.ChatSID, &session.AIMSid, &session.RoomID,
+		&session.ScreenName, &session.InstanceID, &session.JoinedAt, &session.LeftAt)
+	if err != nil {
+		return nil, err
+	}
+	return &session, nil
+}
+
+func (m *WebAPIChatManager) getUserSessionInRoomByScreenName(ctx context.Context, roomID, screenName string) (*ChatSession, error) {
+	var session ChatSession
+	err := m.store.db.QueryRowContext(ctx, `
+		SELECT chat_sid, aimsid, room_id, screen_name, instance_id, joined_at, left_at
+		FROM web_chat_sessions
+		WHERE room_id = ? AND screen_name = ? AND left_at IS NULL`,
+		roomID, screenName).Scan(
+		&session.ChatSID, &session.AIMSid, &session.RoomID,
+		&session.ScreenName, &session.InstanceID, &session.JoinedAt, &session.LeftAt)
+	if err != nil {
+		return nil, err
+	}
+	return &session, nil
+}
+
+func (m *WebAPIChatManager) getParticipantCount(ctx context.Context, roomID string) (int, error) {
+	var count int
+	err := m.store.db.QueryRowContext(ctx, `
+		SELECT COUNT(*) FROM web_chat_participants WHERE room_id = ?`,
+		roomID).Scan(&count)
+	return count, err
+}
+
+func (m *WebAPIChatManager) getParticipants(ctx context.Context, roomID string) ([]string, error) {
+	rows, err := m.store.db.QueryContext(ctx, `
+		SELECT screen_name FROM web_chat_participants WHERE room_id = ?`,
+		roomID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var participants []string
+	for rows.Next() {
+		var screenName string
+		if err := rows.Scan(&screenName); err != nil {
+			continue
+		}
+		participants = append(participants, screenName)
+	}
+	return participants, nil
+}
+
+func (m *WebAPIChatManager) closeRoom(ctx context.Context, roomID string) {
+	now := time.Now().Unix()
+	m.store.db.ExecContext(ctx, `
+		UPDATE web_chat_rooms SET closed_at = ? WHERE room_id = ?`,
+		now, roomID)
+
+	// Remove from cache
+	delete(m.activeRooms, roomID)
+
+	// Broadcast room closed event
+	// Note: Broadcasting doesn't need context as it's fire-and-forget
+	m.broadcastChatEvent(roomID, ChatEventData{
+		EventType: ChatEventClosed,
+	})
+}
+
+func (m *WebAPIChatManager) broadcastChatEvent(roomID string, event ChatEventData) {
+	// Get all active sessions in the room
+	rows, err := m.store.db.Query(`
+		SELECT aimsid, chat_sid FROM web_chat_sessions 
+		WHERE room_id = ? AND left_at IS NULL`,
+		roomID)
+	if err != nil {
+		m.logger.Error("failed to get sessions for broadcast", "error", err, "roomID", roomID)
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var aimsid, chatsid string
+		if err := rows.Scan(&aimsid, &chatsid); err != nil {
+			continue
+		}
+		// Update event with the recipient's chat session ID if not set
+		if event.ChatSID == "" {
+			event.ChatSID = chatsid
+		}
+		m.sendChatEventToUser(aimsid, event)
+	}
+}
+
+func (m *WebAPIChatManager) sendChatEventToUser(aimsid string, event ChatEventData) {
+	// Get the user's Web API session
+	// Using background context for async event sending
+	session, err := m.sessions.GetSession(context.Background(), aimsid)
+	if err != nil {
+		m.logger.Error("failed to get session for chat event", "error", err, "aimsid", aimsid)
+		return
+	}
+
+	// Queue the chat event
+	session.EventQueue.Push("chat", event)
+}
+
+func (m *WebAPIChatManager) generateRoomID() string {
+	b := make([]byte, 16)
+	rand.Read(b)
+	return hex.EncodeToString(b)
+}
+
+func (m *WebAPIChatManager) generateChatSID() string {
+	b := make([]byte, 16)
+	rand.Read(b)
+	return hex.EncodeToString(b)
+}
+
+func (m *WebAPIChatManager) generateInstanceID() int {
+	// In production, this might be based on server instance or other factors
+	// For now, use a simple random number
+	return int(time.Now().Unix() % 1000000)
+}
+
+// GetRecentMessages returns recent messages from a chat room (for history)
+func (m *WebAPIChatManager) GetRecentMessages(ctx context.Context, roomID string, limit int) ([]*ChatMessage, error) {
+	rows, err := m.store.db.QueryContext(ctx, `
+		SELECT id, room_id, screen_name, message, whisper_target, timestamp
+		FROM web_chat_messages
+		WHERE room_id = ?
+		ORDER BY timestamp DESC
+		LIMIT ?`,
+		roomID, limit)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var messages []*ChatMessage
+	for rows.Next() {
+		var msg ChatMessage
+		err := rows.Scan(&msg.ID, &msg.RoomID, &msg.ScreenName,
+			&msg.Message, &msg.WhisperTarget, &msg.Timestamp)
+		if err != nil {
+			continue
+		}
+		messages = append(messages, &msg)
+	}
+
+	// Reverse to get chronological order
+	for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
+		messages[i], messages[j] = messages[j], messages[i]
+	}
+
+	return messages, nil
+}
+
+// CleanupInactiveSessions removes sessions that have been inactive for too long
+func (m *WebAPIChatManager) CleanupInactiveSessions(ctx context.Context) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	// Mark sessions as left if they've been inactive for more than 30 minutes
+	cutoff := time.Now().Add(-30 * time.Minute).Unix()
+
+	rows, err := m.store.db.QueryContext(ctx, `
+		SELECT chat_sid, room_id, screen_name 
+		FROM web_chat_sessions 
+		WHERE left_at IS NULL AND joined_at < ?`,
+		cutoff)
+	if err != nil {
+		m.logger.Error("failed to get inactive sessions", "error", err)
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var chatsid, roomID, screenName string
+		if err := rows.Scan(&chatsid, &roomID, &screenName); err != nil {
+			continue
+		}
+
+		// Mark as left
+		now := time.Now().Unix()
+		m.store.db.ExecContext(ctx, `UPDATE web_chat_sessions SET left_at = ? WHERE chat_sid = ?`, now, chatsid)
+		m.store.db.ExecContext(ctx, `DELETE FROM web_chat_participants WHERE room_id = ? AND screen_name = ?`,
+			roomID, screenName)
+
+		// Broadcast user left
+		// Note: Broadcasting doesn't need context as it's fire-and-forget
+		m.broadcastChatEvent(roomID, ChatEventData{
+			ChatSID:   chatsid,
+			EventType: ChatEventUserLeft,
+			EventData: ChatUserEventData{
+				ScreenName: screenName,
+				Timestamp:  now,
+			},
+		})
+	}
+}

+ 383 - 0
state/webapi_oscar_bridge.go

@@ -0,0 +1,383 @@
+package state
+
+import (
+	"context"
+	"database/sql"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"time"
+)
+
+// OSCARBridgeStore manages the persistence of OSCAR bridge sessions in the database.
+// It provides methods to store, retrieve, and manage the mapping between WebAPI
+// sessions and OSCAR authentication cookies.
+type OSCARBridgeStore struct {
+	store *SQLiteUserStore
+}
+
+// NewOSCARBridgeStore creates a new OSCAR bridge store instance.
+func (s *SQLiteUserStore) NewOSCARBridgeStore() *OSCARBridgeStore {
+	return &OSCARBridgeStore{store: s}
+}
+
+// OSCARBridgeSession represents a bridge between WebAPI and OSCAR sessions.
+type OSCARBridgeSession struct {
+	WebSessionID  string    // WebAPI session identifier
+	OSCARCookie   []byte    // OSCAR authentication cookie
+	BOSHost       string    // BOS server hostname
+	BOSPort       int       // BOS server port
+	UseSSL        bool      // Whether to use SSL connection
+	ScreenName    string    // Screen name associated with the session
+	ClientName    string    // Client application name
+	ClientVersion string    // Client application version
+	CreatedAt     time.Time // Bridge creation timestamp
+	LastAccessed  time.Time // Last access timestamp
+}
+
+// SaveBridgeSession stores the mapping between WebAPI and OSCAR sessions.
+func (s *OSCARBridgeStore) SaveBridgeSession(ctx context.Context, webSessionID string,
+	oscarCookie []byte, bosHost string, bosPort int) error {
+
+	query := `
+		INSERT INTO oscar_bridge_sessions 
+		(web_session_id, oscar_cookie, bos_host, bos_port, screen_name, created_at, last_accessed)
+		VALUES (?, ?, ?, ?, ?, ?, ?)
+		ON CONFLICT(web_session_id) DO UPDATE SET
+			oscar_cookie = excluded.oscar_cookie,
+			bos_host = excluded.bos_host,
+			bos_port = excluded.bos_port,
+			last_accessed = excluded.last_accessed
+	`
+
+	now := time.Now()
+	// Note: We'll need to get the screen name from the session manager
+	// For now, using a placeholder
+	screenName := "" // This should be passed from the handler
+
+	_, err := s.store.db.ExecContext(ctx, query,
+		webSessionID, oscarCookie, bosHost, bosPort, screenName, now, now)
+	if err != nil {
+		return fmt.Errorf("failed to save bridge session: %w", err)
+	}
+
+	return nil
+}
+
+// SaveBridgeSessionWithDetails stores a complete bridge session with all details.
+func (s *OSCARBridgeStore) SaveBridgeSessionWithDetails(ctx context.Context, session *OSCARBridgeSession) error {
+	query := `
+		INSERT INTO oscar_bridge_sessions 
+		(web_session_id, oscar_cookie, bos_host, bos_port, use_ssl, screen_name, 
+		 client_name, client_version, created_at, last_accessed)
+		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+		ON CONFLICT(web_session_id) DO UPDATE SET
+			oscar_cookie = excluded.oscar_cookie,
+			bos_host = excluded.bos_host,
+			bos_port = excluded.bos_port,
+			use_ssl = excluded.use_ssl,
+			last_accessed = excluded.last_accessed
+	`
+
+	_, err := s.store.db.ExecContext(ctx, query,
+		session.WebSessionID, session.OSCARCookie, session.BOSHost, session.BOSPort,
+		session.UseSSL, session.ScreenName, session.ClientName, session.ClientVersion,
+		session.CreatedAt, session.LastAccessed)
+	if err != nil {
+		return fmt.Errorf("failed to save bridge session: %w", err)
+	}
+
+	return nil
+}
+
+// GetBridgeSession retrieves bridge session details by WebAPI session ID.
+func (s *OSCARBridgeStore) GetBridgeSession(ctx context.Context, webSessionID string) (*OSCARBridgeSession, error) {
+	query := `
+		SELECT web_session_id, oscar_cookie, bos_host, bos_port, use_ssl, screen_name,
+		       client_name, client_version, created_at, last_accessed
+		FROM oscar_bridge_sessions
+		WHERE web_session_id = ?
+	`
+
+	var session OSCARBridgeSession
+	var clientName, clientVersion sql.NullString
+
+	err := s.store.db.QueryRowContext(ctx, query, webSessionID).Scan(
+		&session.WebSessionID,
+		&session.OSCARCookie,
+		&session.BOSHost,
+		&session.BOSPort,
+		&session.UseSSL,
+		&session.ScreenName,
+		&clientName,
+		&clientVersion,
+		&session.CreatedAt,
+		&session.LastAccessed,
+	)
+
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return nil, fmt.Errorf("bridge session not found")
+		}
+		return nil, fmt.Errorf("failed to get bridge session: %w", err)
+	}
+
+	// Handle nullable fields
+	if clientName.Valid {
+		session.ClientName = clientName.String
+	}
+	if clientVersion.Valid {
+		session.ClientVersion = clientVersion.String
+	}
+
+	// Update last accessed time
+	go s.touchSession(context.Background(), webSessionID)
+
+	return &session, nil
+}
+
+// GetBridgeSessionByScreenName retrieves bridge sessions by screen name.
+func (s *OSCARBridgeStore) GetBridgeSessionByScreenName(ctx context.Context, screenName string) ([]*OSCARBridgeSession, error) {
+	query := `
+		SELECT web_session_id, oscar_cookie, bos_host, bos_port, use_ssl, screen_name,
+		       client_name, client_version, created_at, last_accessed
+		FROM oscar_bridge_sessions
+		WHERE screen_name = ?
+		ORDER BY last_accessed DESC
+	`
+
+	rows, err := s.store.db.QueryContext(ctx, query, screenName)
+	if err != nil {
+		return nil, fmt.Errorf("failed to query bridge sessions: %w", err)
+	}
+	defer rows.Close()
+
+	var sessions []*OSCARBridgeSession
+
+	for rows.Next() {
+		var session OSCARBridgeSession
+		var clientName, clientVersion sql.NullString
+
+		err := rows.Scan(
+			&session.WebSessionID,
+			&session.OSCARCookie,
+			&session.BOSHost,
+			&session.BOSPort,
+			&session.UseSSL,
+			&session.ScreenName,
+			&clientName,
+			&clientVersion,
+			&session.CreatedAt,
+			&session.LastAccessed,
+		)
+
+		if err != nil {
+			return nil, fmt.Errorf("failed to scan bridge session: %w", err)
+		}
+
+		// Handle nullable fields
+		if clientName.Valid {
+			session.ClientName = clientName.String
+		}
+		if clientVersion.Valid {
+			session.ClientVersion = clientVersion.String
+		}
+
+		sessions = append(sessions, &session)
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, fmt.Errorf("error iterating bridge sessions: %w", err)
+	}
+
+	return sessions, nil
+}
+
+// DeleteBridgeSession removes a bridge session.
+func (s *OSCARBridgeStore) DeleteBridgeSession(ctx context.Context, webSessionID string) error {
+	query := `DELETE FROM oscar_bridge_sessions WHERE web_session_id = ?`
+
+	result, err := s.store.db.ExecContext(ctx, query, webSessionID)
+	if err != nil {
+		return fmt.Errorf("failed to delete bridge session: %w", err)
+	}
+
+	rowsAffected, err := result.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("failed to get rows affected: %w", err)
+	}
+
+	if rowsAffected == 0 {
+		return fmt.Errorf("bridge session not found")
+	}
+
+	return nil
+}
+
+// CleanupExpiredSessions removes bridge sessions that haven't been accessed recently.
+func (s *OSCARBridgeStore) CleanupExpiredSessions(ctx context.Context, maxAge time.Duration) (int, error) {
+	cutoff := time.Now().Add(-maxAge)
+
+	query := `DELETE FROM oscar_bridge_sessions WHERE last_accessed < ?`
+
+	result, err := s.store.db.ExecContext(ctx, query, cutoff)
+	if err != nil {
+		return 0, fmt.Errorf("failed to cleanup expired sessions: %w", err)
+	}
+
+	rowsAffected, err := result.RowsAffected()
+	if err != nil {
+		return 0, fmt.Errorf("failed to get rows affected: %w", err)
+	}
+
+	return int(rowsAffected), nil
+}
+
+// touchSession updates the last accessed time for a session (internal helper).
+func (s *OSCARBridgeStore) touchSession(ctx context.Context, webSessionID string) {
+	query := `UPDATE oscar_bridge_sessions SET last_accessed = ? WHERE web_session_id = ?`
+	s.store.db.ExecContext(ctx, query, time.Now(), webSessionID)
+}
+
+// GetAllBridgeSessions returns all active bridge sessions (for monitoring/admin).
+func (s *OSCARBridgeStore) GetAllBridgeSessions(ctx context.Context) ([]*OSCARBridgeSession, error) {
+	query := `
+		SELECT web_session_id, oscar_cookie, bos_host, bos_port, use_ssl, screen_name,
+		       client_name, client_version, created_at, last_accessed
+		FROM oscar_bridge_sessions
+		ORDER BY last_accessed DESC
+	`
+
+	rows, err := s.store.db.QueryContext(ctx, query)
+	if err != nil {
+		return nil, fmt.Errorf("failed to query all bridge sessions: %w", err)
+	}
+	defer rows.Close()
+
+	var sessions []*OSCARBridgeSession
+
+	for rows.Next() {
+		var session OSCARBridgeSession
+		var clientName, clientVersion sql.NullString
+
+		err := rows.Scan(
+			&session.WebSessionID,
+			&session.OSCARCookie,
+			&session.BOSHost,
+			&session.BOSPort,
+			&session.UseSSL,
+			&session.ScreenName,
+			&clientName,
+			&clientVersion,
+			&session.CreatedAt,
+			&session.LastAccessed,
+		)
+
+		if err != nil {
+			return nil, fmt.Errorf("failed to scan bridge session: %w", err)
+		}
+
+		// Handle nullable fields
+		if clientName.Valid {
+			session.ClientName = clientName.String
+		}
+		if clientVersion.Valid {
+			session.ClientVersion = clientVersion.String
+		}
+
+		sessions = append(sessions, &session)
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, fmt.Errorf("error iterating bridge sessions: %w", err)
+	}
+
+	return sessions, nil
+}
+
+// GetStatistics returns statistics about bridge sessions.
+func (s *OSCARBridgeStore) GetStatistics(ctx context.Context) (map[string]interface{}, error) {
+	stats := make(map[string]interface{})
+
+	// Total sessions
+	var totalCount int
+	err := s.store.db.QueryRowContext(ctx,
+		`SELECT COUNT(*) FROM oscar_bridge_sessions`).Scan(&totalCount)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get total count: %w", err)
+	}
+	stats["total_sessions"] = totalCount
+
+	// Active sessions (accessed in last hour)
+	var activeCount int
+	oneHourAgo := time.Now().Add(-time.Hour)
+	err = s.store.db.QueryRowContext(ctx,
+		`SELECT COUNT(*) FROM oscar_bridge_sessions WHERE last_accessed > ?`,
+		oneHourAgo).Scan(&activeCount)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get active count: %w", err)
+	}
+	stats["active_sessions"] = activeCount
+
+	// SSL vs non-SSL
+	var sslCount int
+	err = s.store.db.QueryRowContext(ctx,
+		`SELECT COUNT(*) FROM oscar_bridge_sessions WHERE use_ssl = true`).Scan(&sslCount)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get SSL count: %w", err)
+	}
+	stats["ssl_sessions"] = sslCount
+	stats["non_ssl_sessions"] = totalCount - sslCount
+
+	return stats, nil
+}
+
+// ValidateOSCARCookie checks if an OSCAR cookie exists in the bridge store.
+// This can be used to validate incoming OSCAR connections.
+func (s *OSCARBridgeStore) ValidateOSCARCookie(ctx context.Context, cookie []byte) (*OSCARBridgeSession, error) {
+	// Convert cookie to hex for comparison
+	cookieHex := hex.EncodeToString(cookie)
+
+	query := `
+		SELECT web_session_id, oscar_cookie, bos_host, bos_port, use_ssl, screen_name,
+		       client_name, client_version, created_at, last_accessed
+		FROM oscar_bridge_sessions
+		WHERE hex(oscar_cookie) = ?
+	`
+
+	var session OSCARBridgeSession
+	var clientName, clientVersion sql.NullString
+
+	err := s.store.db.QueryRowContext(ctx, query, cookieHex).Scan(
+		&session.WebSessionID,
+		&session.OSCARCookie,
+		&session.BOSHost,
+		&session.BOSPort,
+		&session.UseSSL,
+		&session.ScreenName,
+		&clientName,
+		&clientVersion,
+		&session.CreatedAt,
+		&session.LastAccessed,
+	)
+
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return nil, fmt.Errorf("cookie not found")
+		}
+		return nil, fmt.Errorf("failed to validate cookie: %w", err)
+	}
+
+	// Handle nullable fields
+	if clientName.Valid {
+		session.ClientName = clientName.String
+	}
+	if clientVersion.Valid {
+		session.ClientVersion = clientVersion.String
+	}
+
+	// Update last accessed time
+	go s.touchSession(context.Background(), session.WebSessionID)
+
+	return &session, nil
+}

+ 197 - 0
state/webapi_preferences.go

@@ -0,0 +1,197 @@
+package state
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"errors"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// WebPreferenceManager handles Web API user preferences.
+type WebPreferenceManager struct {
+	store *SQLiteUserStore
+}
+
+// NewWebPreferenceManager creates a new WebPreferenceManager.
+func (s *SQLiteUserStore) NewWebPreferenceManager() *WebPreferenceManager {
+	return &WebPreferenceManager{store: s}
+}
+
+// SetPreferences stores user preferences in the database.
+func (m *WebPreferenceManager) SetPreferences(ctx context.Context, screenName IdentScreenName, prefs map[string]interface{}) error {
+	prefsJSON, err := json.Marshal(prefs)
+	if err != nil {
+		return err
+	}
+
+	now := time.Now().Unix()
+	q := `
+		INSERT INTO web_preferences (screen_name, preferences, created_at, updated_at)
+		VALUES (?, ?, ?, ?)
+		ON CONFLICT (screen_name)
+		DO UPDATE SET preferences = excluded.preferences, updated_at = excluded.updated_at
+	`
+	_, err = m.store.db.ExecContext(ctx, q, screenName.String(), string(prefsJSON), now, now)
+	return err
+}
+
+// GetPreferences retrieves user preferences from the database.
+func (m *WebPreferenceManager) GetPreferences(ctx context.Context, screenName IdentScreenName) (map[string]interface{}, error) {
+	q := `
+		SELECT preferences
+		FROM web_preferences
+		WHERE screen_name = ?
+	`
+	var prefsJSON string
+	err := m.store.db.QueryRowContext(ctx, q, screenName.String()).Scan(&prefsJSON)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			// Return empty preferences if none exist
+			return make(map[string]interface{}), nil
+		}
+		return nil, err
+	}
+
+	var prefs map[string]interface{}
+	if err := json.Unmarshal([]byte(prefsJSON), &prefs); err != nil {
+		return nil, err
+	}
+
+	return prefs, nil
+}
+
+// WebPermitDenyManager handles Web API permit/deny list management.
+type WebPermitDenyManager struct {
+	store *SQLiteUserStore
+}
+
+// NewWebPermitDenyManager creates a new WebPermitDenyManager.
+func (s *SQLiteUserStore) NewWebPermitDenyManager() *WebPermitDenyManager {
+	return &WebPermitDenyManager{store: s}
+}
+
+// SetPDMode sets the permit/deny mode for a user.
+func (m *WebPermitDenyManager) SetPDMode(ctx context.Context, screenName IdentScreenName, mode wire.FeedbagPDMode) error {
+	q := `
+		INSERT INTO buddyListMode (screenName, clientSidePDMode)
+		VALUES (?, ?)
+		ON CONFLICT (screenName)
+		DO UPDATE SET clientSidePDMode = excluded.clientSidePDMode
+	`
+	_, err := m.store.db.ExecContext(ctx, q, screenName.String(), int(mode))
+	return err
+}
+
+// GetPDMode retrieves the permit/deny mode for a user.
+func (m *WebPermitDenyManager) GetPDMode(ctx context.Context, screenName IdentScreenName) (wire.FeedbagPDMode, error) {
+	q := `
+		SELECT clientSidePDMode
+		FROM buddyListMode
+		WHERE screenName = ?
+	`
+	var mode int
+	err := m.store.db.QueryRowContext(ctx, q, screenName.String()).Scan(&mode)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			// Default to PermitAll if not set
+			return wire.FeedbagPDModePermitAll, nil
+		}
+		return 0, err
+	}
+	return wire.FeedbagPDMode(mode), nil
+}
+
+// GetPermitList retrieves the permit list for a user.
+func (m *WebPermitDenyManager) GetPermitList(ctx context.Context, screenName IdentScreenName) ([]IdentScreenName, error) {
+	q := `
+		SELECT them
+		FROM clientSideBuddyList
+		WHERE me = ? AND isPermit = 1
+	`
+	rows, err := m.store.db.QueryContext(ctx, q, screenName.String())
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var users []IdentScreenName
+	for rows.Next() {
+		var user string
+		if err := rows.Scan(&user); err != nil {
+			return nil, err
+		}
+		users = append(users, NewIdentScreenName(user))
+	}
+	return users, rows.Err()
+}
+
+// GetDenyList retrieves the deny list for a user.
+func (m *WebPermitDenyManager) GetDenyList(ctx context.Context, screenName IdentScreenName) ([]IdentScreenName, error) {
+	q := `
+		SELECT them
+		FROM clientSideBuddyList
+		WHERE me = ? AND isDeny = 1
+	`
+	rows, err := m.store.db.QueryContext(ctx, q, screenName.String())
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var users []IdentScreenName
+	for rows.Next() {
+		var user string
+		if err := rows.Scan(&user); err != nil {
+			return nil, err
+		}
+		users = append(users, NewIdentScreenName(user))
+	}
+	return users, rows.Err()
+}
+
+// AddPermitBuddy adds a user to the permit list.
+func (m *WebPermitDenyManager) AddPermitBuddy(ctx context.Context, me IdentScreenName, them IdentScreenName) error {
+	q := `
+		INSERT INTO clientSideBuddyList (me, them, isPermit)
+		VALUES (?, ?, 1)
+		ON CONFLICT (me, them) DO UPDATE SET isPermit = 1
+	`
+	_, err := m.store.db.ExecContext(ctx, q, me.String(), them.String())
+	return err
+}
+
+// RemovePermitBuddy removes a user from the permit list.
+func (m *WebPermitDenyManager) RemovePermitBuddy(ctx context.Context, me IdentScreenName, them IdentScreenName) error {
+	q := `
+		UPDATE clientSideBuddyList
+		SET isPermit = 0
+		WHERE me = ? AND them = ?
+	`
+	_, err := m.store.db.ExecContext(ctx, q, me.String(), them.String())
+	return err
+}
+
+// AddDenyBuddy adds a user to the deny list.
+func (m *WebPermitDenyManager) AddDenyBuddy(ctx context.Context, me IdentScreenName, them IdentScreenName) error {
+	q := `
+		INSERT INTO clientSideBuddyList (me, them, isDeny)
+		VALUES (?, ?, 1)
+		ON CONFLICT (me, them) DO UPDATE SET isDeny = 1
+	`
+	_, err := m.store.db.ExecContext(ctx, q, me.String(), them.String())
+	return err
+}
+
+// RemoveDenyBuddy removes a user from the deny list.
+func (m *WebPermitDenyManager) RemoveDenyBuddy(ctx context.Context, me IdentScreenName, them IdentScreenName) error {
+	q := `
+		UPDATE clientSideBuddyList
+		SET isDeny = 0
+		WHERE me = ? AND them = ?
+	`
+	_, err := m.store.db.ExecContext(ctx, q, me.String(), them.String())
+	return err
+}

+ 454 - 0
state/webapi_session.go

@@ -0,0 +1,454 @@
+package state
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	"errors"
+	"log/slog"
+	"sync"
+	"time"
+
+	"github.com/mk6i/retro-aim-server/server/webapi/types"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+var (
+	// ErrNoWebAPISession is returned when a WebAPI session is not found.
+	ErrNoWebAPISession = errors.New("WebAPI session not found")
+	// ErrWebAPISessionExpired is returned when a WebAPI session has expired.
+	ErrWebAPISessionExpired = errors.New("WebAPI session expired")
+)
+
+// WebAPISession represents an active Web AIM API session.
+type WebAPISession struct {
+	AimSID          string            // Unique session ID for web client
+	ScreenName      DisplayScreenName // User identity
+	OSCARSession    *Session          // Bridge to existing OSCAR session
+	Events          []string          // Subscribed event types
+	EventQueue      *types.EventQueue // Per-session event queue
+	DevID           string            // Developer ID that created this session
+	ClientName      string            // Client application name
+	ClientVersion   string            // Client application version
+	CreatedAt       time.Time         // Session creation time
+	LastAccessed    time.Time         // Last activity time
+	ExpiresAt       time.Time         // Session expiration time
+	FetchTimeout    int               // Long-polling timeout in milliseconds
+	TimeToNextFetch int               // Suggested delay before next fetch
+	RemoteAddr      string            // Client IP address
+	logger          *slog.Logger      // Logger for debugging
+}
+
+// IsExpired checks if the session has expired.
+func (s *WebAPISession) IsExpired() bool {
+	return time.Now().After(s.ExpiresAt)
+}
+
+// Touch updates the last accessed time and extends expiration if needed.
+func (s *WebAPISession) Touch() {
+	s.LastAccessed = time.Now()
+	// Extend expiration by 60 minutes from last access
+	newExpiry := s.LastAccessed.Add(60 * time.Minute)
+	if newExpiry.After(s.ExpiresAt) {
+		s.ExpiresAt = newExpiry
+	}
+}
+
+// IsSubscribedTo checks if the session is subscribed to a specific event type.
+func (s *WebAPISession) IsSubscribedTo(eventType string) bool {
+	for _, event := range s.Events {
+		if event == eventType {
+			return true
+		}
+	}
+	return false
+}
+
+// StartListeningToOSCARSession starts a goroutine that listens to the OSCAR session's
+// message channel and converts SNAC messages into WebAPI events.
+func (s *WebAPISession) StartListeningToOSCARSession() {
+	if s.OSCARSession == nil {
+		return
+	}
+
+	// Start goroutine to listen for OSCAR messages
+	go func() {
+		msgCh := s.OSCARSession.ReceiveMessage()
+		for {
+			select {
+			case msg, ok := <-msgCh:
+				if !ok {
+					// Channel closed, OSCAR session ended
+					return
+				}
+				s.handleSNACMessage(msg)
+			case <-s.OSCARSession.Closed():
+				// OSCAR session closed
+				return
+			}
+		}
+	}()
+}
+
+// handleSNACMessage converts a SNAC message into WebAPI events and pushes them to the event queue.
+func (s *WebAPISession) handleSNACMessage(msg wire.SNACMessage) {
+	if s.EventQueue == nil {
+		return
+	}
+
+	// Convert SNAC message to WebAPI events based on food group and subgroup
+	switch msg.Frame.FoodGroup {
+	case wire.ICBM:
+		s.handleICBMMessage(msg)
+	case wire.Buddy:
+		s.handleBuddyMessage(msg)
+	}
+}
+
+// handleICBMMessage handles ICBM (instant messaging) SNAC messages.
+func (s *WebAPISession) handleICBMMessage(msg wire.SNACMessage) {
+	switch msg.Frame.SubGroup {
+	case wire.ICBMChannelMsgToClient:
+		s.handleIncomingIM(msg)
+	case wire.ICBMClientEvent:
+		s.handleTypingNotification(msg)
+	}
+}
+
+// handleIncomingIM handles incoming instant messages.
+func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
+	if !s.IsSubscribedTo("im") {
+		return
+	}
+
+	body, ok := msg.Body.(wire.SNAC_0x04_0x07_ICBMChannelMsgToClient)
+	if !ok {
+		return
+	}
+
+	// Extract message text from TLV data
+	var messageText string
+	if msgData, hasMsg := body.TLVRestBlock.Bytes(wire.ICBMTLVAOLIMData); hasMsg {
+		if text, err := wire.UnmarshalICBMMessageText(msgData); err == nil {
+			messageText = text
+		}
+	}
+
+	if messageText == "" {
+		return
+	}
+
+	// Check if it's an auto-response (channel 2)
+	autoResponse := body.ChannelID == 0x0002
+
+	// Create IM event
+	imEvent := types.IMEvent{
+		From:      body.ScreenName,
+		Message:   messageText,
+		Timestamp: float64(time.Now().Unix()),
+		AutoResp:  autoResponse,
+	}
+
+	s.EventQueue.Push(types.EventTypeIM, imEvent)
+}
+
+// handleTypingNotification handles typing notifications.
+func (s *WebAPISession) handleTypingNotification(msg wire.SNACMessage) {
+	if !s.IsSubscribedTo("typing") {
+		return
+	}
+
+	body, ok := msg.Body.(wire.SNAC_0x04_0x14_ICBMClientEvent)
+	if !ok {
+		return
+	}
+
+	// Event types: 0=stopped typing, 1=text typed, 2=typing
+	isTyping := body.Event == 1 || body.Event == 2
+
+	typingEvent := types.TypingEvent{
+		From:   body.ScreenName,
+		Typing: isTyping,
+	}
+
+	s.EventQueue.Push(types.EventTypeTyping, typingEvent)
+}
+
+// handleBuddyMessage handles buddy/presence SNAC messages.
+func (s *WebAPISession) handleBuddyMessage(msg wire.SNACMessage) {
+	switch msg.Frame.SubGroup {
+	case wire.BuddyArrived:
+		s.handleBuddyArrived(msg)
+	case wire.BuddyDeparted:
+		s.handleBuddyDeparted(msg)
+	}
+}
+
+// handleBuddyArrived handles when a buddy comes online.
+func (s *WebAPISession) handleBuddyArrived(msg wire.SNACMessage) {
+	if !s.IsSubscribedTo("presence") {
+		return
+	}
+
+	body, ok := msg.Body.(wire.SNAC_0x03_0x0B_BuddyArrived)
+	if !ok {
+		return
+	}
+
+	presenceEvent := types.PresenceEvent{
+		AimID:    body.ScreenName,
+		State:    "online",
+		UserType: "aim",
+	}
+
+	s.EventQueue.Push(types.EventTypePresence, presenceEvent)
+}
+
+// handleBuddyDeparted handles when a buddy goes offline.
+func (s *WebAPISession) handleBuddyDeparted(msg wire.SNACMessage) {
+	if !s.IsSubscribedTo("presence") {
+		return
+	}
+
+	body, ok := msg.Body.(wire.SNAC_0x03_0x0C_BuddyDeparted)
+	if !ok {
+		return
+	}
+
+	presenceEvent := types.PresenceEvent{
+		AimID:    body.ScreenName,
+		State:    "offline",
+		UserType: "aim",
+	}
+
+	s.EventQueue.Push(types.EventTypePresence, presenceEvent)
+}
+
+// WebAPISessionManager manages Web API sessions with thread-safe operations.
+type WebAPISessionManager struct {
+	sessions      map[string]*WebAPISession          // Keyed by aimsid
+	byUser        map[IdentScreenName]*WebAPISession // Keyed by screen name
+	mu            sync.RWMutex
+	cleanupTicker *time.Ticker
+	stopCleanup   chan struct{}
+}
+
+// NewWebAPISessionManager creates a new WebAPI session manager.
+func NewWebAPISessionManager() *WebAPISessionManager {
+	mgr := &WebAPISessionManager{
+		sessions:    make(map[string]*WebAPISession),
+		byUser:      make(map[IdentScreenName]*WebAPISession),
+		stopCleanup: make(chan struct{}),
+	}
+
+	// Start cleanup goroutine to remove expired sessions
+	mgr.cleanupTicker = time.NewTicker(1 * time.Minute)
+	go mgr.cleanupExpiredSessions()
+
+	return mgr
+}
+
+// CreateSession creates a new WebAPI session.
+func (m *WebAPISessionManager) CreateSession(ctx context.Context, screenName DisplayScreenName, devID string, events []string, oscarSession *Session, logger *slog.Logger) (*WebAPISession, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	// Check if user already has an active session
+	identName := screenName.IdentScreenName()
+	if existing, exists := m.byUser[identName]; exists {
+		// Remove the old session
+		delete(m.sessions, existing.AimSID)
+	}
+
+	// Generate unique session ID
+	aimsid, err := generateSessionID()
+	if err != nil {
+		return nil, err
+	}
+
+	now := time.Now()
+	session := &WebAPISession{
+		AimSID:          aimsid,
+		ScreenName:      screenName,
+		OSCARSession:    oscarSession,
+		Events:          events,
+		EventQueue:      types.NewEventQueue(1000), // Max 1000 events per session
+		DevID:           devID,
+		CreatedAt:       now,
+		LastAccessed:    now,
+		ExpiresAt:       now.Add(60 * time.Minute), // 60 minute initial expiry
+		FetchTimeout:    60000,                     // 60 seconds default for better stability
+		TimeToNextFetch: 500,                       // 500ms suggested delay
+		logger:          logger,
+	}
+
+	m.sessions[aimsid] = session
+	m.byUser[identName] = session
+
+	// Start listening to OSCAR session message channel
+	session.StartListeningToOSCARSession()
+
+	return session, nil
+}
+
+// GetSession retrieves a session by aimsid.
+func (m *WebAPISessionManager) GetSession(ctx context.Context, aimsid string) (*WebAPISession, error) {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	session, exists := m.sessions[aimsid]
+	if !exists {
+		return nil, ErrNoWebAPISession
+	}
+
+	if session.IsExpired() {
+		return nil, ErrWebAPISessionExpired
+	}
+
+	return session, nil
+}
+
+// GetSessionByUser retrieves a session by screen name.
+func (m *WebAPISessionManager) GetSessionByUser(ctx context.Context, screenName IdentScreenName) (*WebAPISession, error) {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	session, exists := m.byUser[screenName]
+	if !exists {
+		return nil, ErrNoWebAPISession
+	}
+
+	if session.IsExpired() {
+		return nil, ErrWebAPISessionExpired
+	}
+
+	return session, nil
+}
+
+// RemoveSession removes a session by aimsid.
+func (m *WebAPISessionManager) RemoveSession(ctx context.Context, aimsid string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	session, exists := m.sessions[aimsid]
+	if !exists {
+		return ErrNoWebAPISession
+	}
+
+	delete(m.sessions, aimsid)
+	delete(m.byUser, session.ScreenName.IdentScreenName())
+
+	// Close the event queue to unblock any waiting fetches
+	if session.EventQueue != nil {
+		session.EventQueue.Close()
+	}
+
+	return nil
+}
+
+// TouchSession updates the last accessed time for a session.
+func (m *WebAPISessionManager) TouchSession(ctx context.Context, aimsid string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	session, exists := m.sessions[aimsid]
+	if !exists {
+		return ErrNoWebAPISession
+	}
+
+	session.Touch()
+	return nil
+}
+
+// GetAllSessions returns all active sessions (for monitoring/admin).
+func (m *WebAPISessionManager) GetAllSessions(ctx context.Context) []*WebAPISession {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	sessions := make([]*WebAPISession, 0, len(m.sessions))
+	for _, session := range m.sessions {
+		if !session.IsExpired() {
+			sessions = append(sessions, session)
+		}
+	}
+	return sessions
+}
+
+// GetSessionsByScreenName returns all sessions for a given screen name.
+func (m *WebAPISessionManager) GetSessionsByScreenName(ctx context.Context, screenName DisplayScreenName) []*WebAPISession {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	var sessions []*WebAPISession
+	identScreenName := screenName.IdentScreenName()
+
+	// Check both the byUser map and iterate through all sessions
+	// since a user might have multiple sessions
+	for _, session := range m.sessions {
+		if session.ScreenName.IdentScreenName() == identScreenName {
+			sessions = append(sessions, session)
+		}
+	}
+
+	return sessions
+}
+
+// cleanupExpiredSessions periodically removes expired sessions.
+func (m *WebAPISessionManager) cleanupExpiredSessions() {
+	for {
+		select {
+		case <-m.cleanupTicker.C:
+			m.mu.Lock()
+			now := time.Now()
+			var toRemove []string
+
+			for aimsid, session := range m.sessions {
+				if now.After(session.ExpiresAt) {
+					toRemove = append(toRemove, aimsid)
+				}
+			}
+
+			for _, aimsid := range toRemove {
+				session := m.sessions[aimsid]
+				delete(m.sessions, aimsid)
+				delete(m.byUser, session.ScreenName.IdentScreenName())
+				if session.EventQueue != nil {
+					session.EventQueue.Close()
+				}
+			}
+			m.mu.Unlock()
+
+		case <-m.stopCleanup:
+			m.cleanupTicker.Stop()
+			return
+		}
+	}
+}
+
+// Shutdown stops the session manager and cleans up resources.
+func (m *WebAPISessionManager) Shutdown(ctx context.Context) {
+	close(m.stopCleanup)
+
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	// Close all event queues
+	for _, session := range m.sessions {
+		if session.EventQueue != nil {
+			session.EventQueue.Close()
+		}
+	}
+
+	// Clear all sessions
+	m.sessions = make(map[string]*WebAPISession)
+	m.byUser = make(map[IdentScreenName]*WebAPISession)
+}
+
+// generateSessionID creates a cryptographically secure session ID.
+func generateSessionID() (string, error) {
+	bytes := make([]byte, 32) // 256 bits
+	if _, err := rand.Read(bytes); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(bytes), nil
+}

+ 419 - 0
state/webapi_vanity.go

@@ -0,0 +1,419 @@
+package state
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"log/slog"
+	"regexp"
+	"strings"
+	"time"
+)
+
+// VanityURL represents a user's vanity URL configuration.
+type VanityURL struct {
+	ScreenName   string     `json:"screenName"`
+	VanityURL    string     `json:"vanityUrl"`
+	DisplayName  string     `json:"displayName,omitempty"`
+	Bio          string     `json:"bio,omitempty"`
+	Location     string     `json:"location,omitempty"`
+	Website      string     `json:"website,omitempty"`
+	CreatedAt    time.Time  `json:"createdAt"`
+	UpdatedAt    time.Time  `json:"updatedAt"`
+	IsActive     bool       `json:"isActive"`
+	ClickCount   int        `json:"clickCount"`
+	LastAccessed *time.Time `json:"lastAccessed,omitempty"`
+}
+
+// VanityURLRedirect represents a vanity URL access record.
+type VanityURLRedirect struct {
+	ID         int64     `json:"id"`
+	VanityURL  string    `json:"vanityUrl"`
+	AccessedAt time.Time `json:"accessedAt"`
+	IPAddress  string    `json:"ipAddress,omitempty"`
+	UserAgent  string    `json:"userAgent,omitempty"`
+	Referer    string    `json:"referer,omitempty"`
+}
+
+// VanityInfo represents the response for vanity URL lookups.
+type VanityInfo struct {
+	ScreenName  string                 `json:"screenName"`
+	VanityURL   string                 `json:"vanityUrl"`
+	DisplayName string                 `json:"displayName,omitempty"`
+	Bio         string                 `json:"bio,omitempty"`
+	Location    string                 `json:"location,omitempty"`
+	Website     string                 `json:"website,omitempty"`
+	ProfileURL  string                 `json:"profileUrl"`
+	IsActive    bool                   `json:"isActive"`
+	Extra       map[string]interface{} `json:"extra,omitempty"`
+}
+
+// VanityURLManager manages vanity URL operations.
+type VanityURLManager struct {
+	db       *sql.DB
+	logger   *slog.Logger
+	baseURL  string   // Base URL for the service (e.g., "https://aim.example.com")
+	reserved []string // Reserved URLs that cannot be claimed
+}
+
+// NewVanityURLManager creates a new vanity URL manager.
+func NewVanityURLManager(db *sql.DB, logger *slog.Logger, baseURL string) *VanityURLManager {
+	return &VanityURLManager{
+		db:      db,
+		logger:  logger,
+		baseURL: baseURL,
+		reserved: []string{
+			"api", "admin", "help", "support", "about", "terms", "privacy",
+			"login", "logout", "register", "signup", "signin", "settings",
+			"profile", "user", "users", "aim", "aol", "webapi", "oscar",
+			"chat", "im", "message", "buddy", "buddies", "feed", "rss",
+		},
+	}
+}
+
+// CreateOrUpdateVanityURL creates or updates a vanity URL for a user.
+func (m *VanityURLManager) CreateOrUpdateVanityURL(ctx context.Context, screenName string, vanityURL string, info map[string]interface{}) error {
+	// Validate vanity URL
+	if err := m.validateVanityURL(vanityURL); err != nil {
+		return err
+	}
+
+	// Check if URL is reserved
+	if m.isReserved(vanityURL) {
+		return fmt.Errorf("vanity URL '%s' is reserved", vanityURL)
+	}
+
+	// Extract optional fields from info
+	displayName, _ := info["displayName"].(string)
+	bio, _ := info["bio"].(string)
+	location, _ := info["location"].(string)
+	website, _ := info["website"].(string)
+
+	now := time.Now()
+
+	// Try to update existing record first
+	updateQuery := `
+		UPDATE vanity_urls
+		SET vanity_url = ?, display_name = ?, bio = ?, location = ?, 
+		    website = ?, updated_at = ?, is_active = ?
+		WHERE screen_name = ?
+	`
+
+	result, err := m.db.ExecContext(ctx, updateQuery,
+		vanityURL, displayName, bio, location, website,
+		now.Unix(), true, screenName,
+	)
+
+	if err != nil {
+		return fmt.Errorf("failed to update vanity URL: %w", err)
+	}
+
+	rowsAffected, _ := result.RowsAffected()
+	if rowsAffected > 0 {
+		m.logger.InfoContext(ctx, "updated vanity URL",
+			"screenName", screenName,
+			"vanityURL", vanityURL,
+		)
+		return nil
+	}
+
+	// Insert new record
+	insertQuery := `
+		INSERT INTO vanity_urls (
+			screen_name, vanity_url, display_name, bio, location,
+			website, created_at, updated_at, is_active, click_count
+		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+	`
+
+	_, err = m.db.ExecContext(ctx, insertQuery,
+		screenName, vanityURL, displayName, bio, location,
+		website, now.Unix(), now.Unix(), true, 0,
+	)
+
+	if err != nil {
+		if strings.Contains(err.Error(), "UNIQUE") {
+			return fmt.Errorf("vanity URL '%s' is already taken", vanityURL)
+		}
+		return fmt.Errorf("failed to create vanity URL: %w", err)
+	}
+
+	m.logger.InfoContext(ctx, "created vanity URL",
+		"screenName", screenName,
+		"vanityURL", vanityURL,
+	)
+
+	return nil
+}
+
+// GetVanityInfo retrieves vanity URL information.
+func (m *VanityURLManager) GetVanityInfo(ctx context.Context, vanityURL string) (*VanityInfo, error) {
+	// Clean the vanity URL
+	vanityURL = strings.ToLower(strings.TrimSpace(vanityURL))
+
+	query := `
+		SELECT screen_name, vanity_url, display_name, bio, location,
+		       website, created_at, updated_at, is_active, click_count, last_accessed
+		FROM vanity_urls
+		WHERE vanity_url = ? AND is_active = 1
+	`
+
+	var v VanityURL
+	var createdAt, updatedAt int64
+	var lastAccessed sql.NullInt64
+
+	err := m.db.QueryRowContext(ctx, query, vanityURL).Scan(
+		&v.ScreenName, &v.VanityURL, &v.DisplayName, &v.Bio, &v.Location,
+		&v.Website, &createdAt, &updatedAt, &v.IsActive, &v.ClickCount, &lastAccessed,
+	)
+
+	if err == sql.ErrNoRows {
+		return nil, fmt.Errorf("vanity URL not found: %s", vanityURL)
+	}
+	if err != nil {
+		return nil, fmt.Errorf("failed to get vanity info: %w", err)
+	}
+
+	v.CreatedAt = time.Unix(createdAt, 0)
+	v.UpdatedAt = time.Unix(updatedAt, 0)
+	if lastAccessed.Valid {
+		t := time.Unix(lastAccessed.Int64, 0)
+		v.LastAccessed = &t
+	}
+
+	// Create response info
+	info := &VanityInfo{
+		ScreenName:  v.ScreenName,
+		VanityURL:   v.VanityURL,
+		DisplayName: v.DisplayName,
+		Bio:         v.Bio,
+		Location:    v.Location,
+		Website:     v.Website,
+		ProfileURL:  m.buildProfileURL(v.VanityURL),
+		IsActive:    v.IsActive,
+		Extra: map[string]interface{}{
+			"createdAt":  v.CreatedAt.Unix(),
+			"clickCount": v.ClickCount,
+		},
+	}
+
+	// Update click count and last accessed asynchronously
+	go m.recordAccess(context.Background(), vanityURL)
+
+	return info, nil
+}
+
+// GetVanityInfoByScreenName retrieves vanity URL info by screen name.
+func (m *VanityURLManager) GetVanityInfoByScreenName(ctx context.Context, screenName string) (*VanityInfo, error) {
+	query := `
+		SELECT screen_name, vanity_url, display_name, bio, location,
+		       website, created_at, updated_at, is_active, click_count, last_accessed
+		FROM vanity_urls
+		WHERE screen_name = ? AND is_active = 1
+	`
+
+	var v VanityURL
+	var createdAt, updatedAt int64
+	var lastAccessed sql.NullInt64
+
+	err := m.db.QueryRowContext(ctx, query, screenName).Scan(
+		&v.ScreenName, &v.VanityURL, &v.DisplayName, &v.Bio, &v.Location,
+		&v.Website, &createdAt, &updatedAt, &v.IsActive, &v.ClickCount, &lastAccessed,
+	)
+
+	if err == sql.ErrNoRows {
+		return nil, nil // No vanity URL configured
+	}
+	if err != nil {
+		return nil, fmt.Errorf("failed to get vanity info: %w", err)
+	}
+
+	v.CreatedAt = time.Unix(createdAt, 0)
+	v.UpdatedAt = time.Unix(updatedAt, 0)
+	if lastAccessed.Valid {
+		t := time.Unix(lastAccessed.Int64, 0)
+		v.LastAccessed = &t
+	}
+
+	// Create response info
+	info := &VanityInfo{
+		ScreenName:  v.ScreenName,
+		VanityURL:   v.VanityURL,
+		DisplayName: v.DisplayName,
+		Bio:         v.Bio,
+		Location:    v.Location,
+		Website:     v.Website,
+		ProfileURL:  m.buildProfileURL(v.VanityURL),
+		IsActive:    v.IsActive,
+		Extra: map[string]interface{}{
+			"createdAt":  v.CreatedAt.Unix(),
+			"clickCount": v.ClickCount,
+		},
+	}
+
+	return info, nil
+}
+
+// DeleteVanityURL removes a user's vanity URL.
+func (m *VanityURLManager) DeleteVanityURL(ctx context.Context, screenName string) error {
+	query := `UPDATE vanity_urls SET is_active = 0, updated_at = ? WHERE screen_name = ?`
+
+	_, err := m.db.ExecContext(ctx, query, time.Now().Unix(), screenName)
+	if err != nil {
+		return fmt.Errorf("failed to delete vanity URL: %w", err)
+	}
+
+	m.logger.InfoContext(ctx, "deleted vanity URL", "screenName", screenName)
+	return nil
+}
+
+// CheckAvailability checks if a vanity URL is available.
+func (m *VanityURLManager) CheckAvailability(ctx context.Context, vanityURL string) (bool, error) {
+	// Validate format
+	if err := m.validateVanityURL(vanityURL); err != nil {
+		return false, err
+	}
+
+	// Check if reserved
+	if m.isReserved(vanityURL) {
+		return false, nil
+	}
+
+	// Check database
+	query := `SELECT COUNT(*) FROM vanity_urls WHERE vanity_url = ? AND is_active = 1`
+
+	var count int
+	err := m.db.QueryRowContext(ctx, query, vanityURL).Scan(&count)
+	if err != nil {
+		return false, fmt.Errorf("failed to check availability: %w", err)
+	}
+
+	return count == 0, nil
+}
+
+// GetPopularVanityURLs retrieves the most accessed vanity URLs.
+func (m *VanityURLManager) GetPopularVanityURLs(ctx context.Context, limit int) ([]VanityInfo, error) {
+	query := `
+		SELECT screen_name, vanity_url, display_name, bio, location,
+		       website, is_active, click_count
+		FROM vanity_urls
+		WHERE is_active = 1
+		ORDER BY click_count DESC
+		LIMIT ?
+	`
+
+	rows, err := m.db.QueryContext(ctx, query, limit)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get popular vanity URLs: %w", err)
+	}
+	defer rows.Close()
+
+	var results []VanityInfo
+	for rows.Next() {
+		var info VanityInfo
+		var displayName, bio, location, website sql.NullString
+
+		err := rows.Scan(
+			&info.ScreenName, &info.VanityURL, &displayName, &bio,
+			&location, &website, &info.IsActive, &info.Extra,
+		)
+		if err != nil {
+			return nil, fmt.Errorf("failed to scan vanity info: %w", err)
+		}
+
+		if displayName.Valid {
+			info.DisplayName = displayName.String
+		}
+		if bio.Valid {
+			info.Bio = bio.String
+		}
+		if location.Valid {
+			info.Location = location.String
+		}
+		if website.Valid {
+			info.Website = website.String
+		}
+
+		info.ProfileURL = m.buildProfileURL(info.VanityURL)
+		results = append(results, info)
+	}
+
+	return results, nil
+}
+
+// recordAccess records a vanity URL access.
+func (m *VanityURLManager) recordAccess(ctx context.Context, vanityURL string) {
+	// Update click count and last accessed time
+	updateQuery := `
+		UPDATE vanity_urls
+		SET click_count = click_count + 1, last_accessed = ?
+		WHERE vanity_url = ?
+	`
+
+	_, err := m.db.ExecContext(ctx, updateQuery, time.Now().Unix(), vanityURL)
+	if err != nil {
+		m.logger.Error("failed to record vanity URL access", "error", err, "vanityURL", vanityURL)
+	}
+}
+
+// LogRedirect logs a vanity URL redirect for analytics.
+func (m *VanityURLManager) LogRedirect(ctx context.Context, redirect VanityURLRedirect) error {
+	query := `
+		INSERT INTO vanity_url_redirects (vanity_url, accessed_at, ip_address, user_agent, referer)
+		VALUES (?, ?, ?, ?, ?)
+	`
+
+	_, err := m.db.ExecContext(ctx, query,
+		redirect.VanityURL, redirect.AccessedAt.Unix(),
+		redirect.IPAddress, redirect.UserAgent, redirect.Referer,
+	)
+
+	if err != nil {
+		return fmt.Errorf("failed to log redirect: %w", err)
+	}
+
+	return nil
+}
+
+// validateVanityURL validates the format of a vanity URL.
+func (m *VanityURLManager) validateVanityURL(vanityURL string) error {
+	// Clean and lowercase
+	vanityURL = strings.ToLower(strings.TrimSpace(vanityURL))
+
+	// Check length
+	if len(vanityURL) < 3 || len(vanityURL) > 30 {
+		return fmt.Errorf("vanity URL must be between 3 and 30 characters")
+	}
+
+	// Check format (alphanumeric, hyphens, underscores only)
+	validFormat := regexp.MustCompile(`^[a-z0-9_-]+$`)
+	if !validFormat.MatchString(vanityURL) {
+		return fmt.Errorf("vanity URL can only contain letters, numbers, hyphens, and underscores")
+	}
+
+	// Can't start or end with special characters
+	if strings.HasPrefix(vanityURL, "-") || strings.HasPrefix(vanityURL, "_") ||
+		strings.HasSuffix(vanityURL, "-") || strings.HasSuffix(vanityURL, "_") {
+		return fmt.Errorf("vanity URL cannot start or end with hyphens or underscores")
+	}
+
+	return nil
+}
+
+// isReserved checks if a vanity URL is in the reserved list.
+func (m *VanityURLManager) isReserved(vanityURL string) bool {
+	vanityURL = strings.ToLower(vanityURL)
+	for _, reserved := range m.reserved {
+		if vanityURL == reserved {
+			return true
+		}
+	}
+	return false
+}
+
+// buildProfileURL builds the full profile URL for a vanity URL.
+func (m *VanityURLManager) buildProfileURL(vanityURL string) string {
+	if m.baseURL == "" {
+		return fmt.Sprintf("/profile/%s", vanityURL)
+	}
+	return fmt.Sprintf("%s/profile/%s", strings.TrimRight(m.baseURL, "/"), vanityURL)
+}