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

Implement WebAPI server for retro-aim-server

This commit adds full WebAPI support to enable Gromit (Web AIM) client connectivity:

Core Infrastructure:
- API key generation, storage, and validation system
- AMF3 encoding/decoding with Gromit-specific workarounds
- WebAPI event handling and message bridging
- Session management and authentication
- Database migrations for WebAPI-specific tables

OSCAR Integration:
- WebAPIMessageBridge for intercepting OSCAR buddy/ICBM events
- WebAPIPresenceBridge for presence broadcasting
- Cross-client compatibility with TLV filtering
- Event queue system for async communication

Features Implemented:
- Authentication and session management
- Buddy list and feed management
- Messaging (IM) support
- Presence and status updates
- Chat room support
- Preference system
- Vanity URL support
- Analytics tracking

Testing & Fixes:
- Fixed nil pointer dereferences in session management
- Resolved race conditions in buddy broadcasting
- Added comprehensive test coverage
- Made BART import script cross-platform compatible

Documentation:
- Updated configuration files with WebAPI settings
- Added migration files for database schema
- Documented smiley pack importing process
ukozi 9 месяцев назад
Родитель
Сommit
8fd09b341c
100 измененных файлов с 12660 добавлено и 751 удалено
  1. 3 1
      .gitignore
  2. 4 4
      .mockery.yaml
  3. 12 0
      Makefile
  4. 2 6
      README.md
  5. 481 12
      api.yml
  6. 77 11
      cmd/server/factory.go
  7. 441 0
      cmd/webapi_keygen/main.go
  8. 1 1
      config/config.go
  9. 1 1
      config/settings.env
  10. 1 1
      config/ssl/settings.env
  11. 56 2
      docs/ADDITIONAL_SETUP.md
  12. 2 2
      docs/AIM_6_7.md
  13. 1 1
      docs/CLIENT.md
  14. 0 1
      docs/open_api/webapi.yml
  15. 4 4
      foodgroup/admin.go
  16. 7 5
      foodgroup/admin_test.go
  17. 57 14
      foodgroup/bart.go
  18. 427 21
      foodgroup/bart_test.go
  19. 15 15
      foodgroup/buddy.go
  20. 13 13
      foodgroup/buddy_test.go
  21. 42 25
      foodgroup/chat.go
  22. 63 0
      foodgroup/chat_test.go
  23. 7 7
      foodgroup/feedbag.go
  24. 15 12
      foodgroup/feedbag_test.go
  25. 23 15
      foodgroup/helpers_test.go
  26. 173 79
      foodgroup/icbm.go
  27. 307 62
      foodgroup/icbm_test.go
  28. 4 4
      foodgroup/locate.go
  29. 4 2
      foodgroup/locate_test.go
  30. 312 0
      foodgroup/mock_bart_item_manager_test.go
  31. 14 11
      foodgroup/mock_buddy_broadcaster_test.go
  32. 0 205
      foodgroup/mock_buddy_icon_manager_test.go
  33. 51 0
      foodgroup/mock_user_manager_manager_test.go
  34. 4 4
      foodgroup/oservice.go
  35. 10 6
      foodgroup/oservice_test.go
  36. 2 2
      foodgroup/permit_deny.go
  37. 16 7
      foodgroup/types.go
  38. 1 0
      go.mod
  39. 2 0
      go.sum
  40. 482 0
      scripts/import_bart.sh
  41. 37 10
      server/http/helpers_test.go
  42. 198 4
      server/http/mgmt_api.go
  43. 465 15
      server/http/mgmt_api_test.go
  44. 251 0
      server/http/mock_bart_asset_manager_test.go
  45. 0 95
      server/http/mock_buddy_icon_retriever_test.go
  46. 42 4
      server/http/types.go
  47. 255 0
      server/http/webapi_admin.go
  48. 21 1
      server/oscar/handler.go
  49. 82 1
      server/oscar/handler_test.go
  50. 74 15
      server/oscar/mock_bart_service_test.go
  51. 14 11
      server/oscar/mock_departure_notifier_test.go
  52. 81 34
      server/oscar/mock_icbm_service_test.go
  53. 6 0
      server/oscar/server.go
  54. 5 0
      server/oscar/server_test.go
  55. 5 3
      server/oscar/types.go
  56. 7 1
      server/toc/server.go
  57. 5 0
      server/toc/server_test.go
  58. 196 0
      server/webapi/adapters.go
  59. 27 0
      server/webapi/handler.go
  60. 443 0
      server/webapi/handlers/amf_encoder.go
  61. 489 0
      server/webapi/handlers/amf_encoder_test.go
  62. 197 0
      server/webapi/handlers/auth.go
  63. 293 0
      server/webapi/handlers/buddy_list_manager.go
  64. 353 0
      server/webapi/handlers/buddyfeed.go
  65. 195 0
      server/webapi/handlers/buddylist.go
  66. 319 0
      server/webapi/handlers/chat.go
  67. 395 0
      server/webapi/handlers/common.go
  68. 196 0
      server/webapi/handlers/events.go
  69. 53 0
      server/webapi/handlers/expressions.go
  70. 232 0
      server/webapi/handlers/feed_converter.go
  71. 416 0
      server/webapi/handlers/messaging.go
  72. 351 0
      server/webapi/handlers/oscar_bridge.go
  73. 482 0
      server/webapi/handlers/preference.go
  74. 665 0
      server/webapi/handlers/presence.go
  75. 555 0
      server/webapi/handlers/session.go
  76. 323 0
      server/webapi/handlers/vanity.go
  77. 212 0
      server/webapi/handlers/webapi_event_converter.go
  78. 493 0
      server/webapi/middleware/auth.go
  79. 140 0
      server/webapi/oscar_config.go
  80. 209 1
      server/webapi/server.go
  81. 105 0
      server/webapi/types.go
  82. 255 0
      server/webapi/types/events.go
  83. 5 0
      state/migrations/0014_warn_state.down.sql
  84. 5 0
      state/migrations/0014_warn_state.up.sql
  85. 1 0
      state/migrations/0015_bart_item_type.down.sql
  86. 1 0
      state/migrations/0015_bart_item_type.up.sql
  87. 4 0
      state/migrations/0016_webapi_tokens.down.sql
  88. 13 0
      state/migrations/0016_webapi_tokens.up.sql
  89. 3 0
      state/migrations/0017_web_preferences.down.sql
  90. 33 0
      state/migrations/0017_web_preferences.up.sql
  91. 5 0
      state/migrations/0018_oscar_bridge_sessions.down.sql
  92. 36 0
      state/migrations/0018_oscar_bridge_sessions.up.sql
  93. 7 0
      state/migrations/0019_api_analytics.down.sql
  94. 61 0
      state/migrations/0019_api_analytics.up.sql
  95. 7 0
      state/migrations/0020_buddy_feeds.down.sql
  96. 58 0
      state/migrations/0020_buddy_feeds.up.sql
  97. 6 0
      state/migrations/0021_vanity_urls.down.sql
  98. 38 0
      state/migrations/0021_vanity_urls.up.sql
  99. 16 0
      state/migrations/0022_web_chat_rooms.down.sql
  100. 82 0
      state/migrations/0022_web_chat_rooms.up.sql

+ 3 - 1
.gitignore

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

+ 4 - 4
.mockery.yaml

@@ -82,9 +82,9 @@ packages:
       AccountRetriever:
         config:
           filename: "mock_account_retriever_test.go"
-      BuddyIconRetriever:
+      BARTAssetManager:
         config:
-          filename: "mock_buddy_icon_retriever_test.go"
+          filename: "mock_bart_asset_manager_test.go"
       ChatRoomCreator:
         config:
           filename: "mock_chat_room_creator_test.go"
@@ -117,9 +117,9 @@ packages:
       AccountManager:
         config:
           filename: "mock_account_manager_test.go"
-      BuddyIconManager:
+      BARTItemManager:
         config:
-          filename: "mock_buddy_icon_manager_test.go"
+          filename: "mock_bart_item_manager_test.go"
       buddyBroadcaster:
         config:
           filename: "mock_buddy_broadcaster_test.go"

+ 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

+ 2 - 6
README.md

@@ -7,11 +7,7 @@
 
 </div>
 
-**Retro AIM Server** is an open-source instant messaging server that makes classic AIM and ICQ clients work again.
-
-<p align="center">
-  <img width="616" alt="screenshot of retro aim server running next to AIM" src="https://github.com/mk6i/retro-aim-server/assets/2894330/81ff419f-50fa-4961-bd2f-ac7dcac903b5">
-</p>
+**Retro AIM Server** is an open-source instant messaging server compatible with classic AIM and ICQ clients.
 
 | Disclaimer                                                                                                                                                                                                                           |
 |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
@@ -191,7 +187,7 @@ curl http://localhost:8080/chat/room/public
 ## 🔗 Acknowledgements
 
 - [aim-oscar-server](https://github.com/ox/aim-oscar-server) is another cool open source AIM server project.
-- [NINA Wiki](https://wiki.nina.chat/wiki/Main_Page) is an indispensable source for figuring out the OSCAR API.
+- [NINA Wiki](https://web.archive.org/web/20250910233011/https://wiki.nina.chat/wiki/Main_Page) is an indispensable source for figuring out the OSCAR API.
 - [libpurple](https://developer.pidgin.im/wiki/WhatIsLibpurple) is also an invaluable OSCAR reference (especially
   version [2.10.6-1](https://github.com/Tasssadar/libpurple)).
 

+ 481 - 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.
@@ -753,3 +754,471 @@ paths:
                 properties:
                   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
+      description: Retrieve a list of BART (Buddy ART) entries for a specific type.
+      parameters:
+        - name: type
+          in: query
+          description: The BART type ID (integer).
+          required: true
+          schema:
+            $ref: '#/components/schemas/BARTType'
+      responses:
+        '200':
+          description: Successful response containing BART entries for the specified type.
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    hash:
+                      type: string
+                      description: A hex value representing the BART asset hash.
+                    type:
+                      $ref: '#/components/schemas/BARTType'
+                      description: The BART type ID.
+                  required:
+                    - hash
+                    - type
+        '400':
+          description: Bad request. Missing or invalid type parameter.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '500':
+          description: Internal server error.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+
+  /bart/{hash}:
+    get:
+      summary: Get BART asset data
+      description: Retrieve the raw binary data for a specific BART asset.
+      parameters:
+        - name: hash
+          in: path
+          description: The hex hash value for the BART asset.
+          required: true
+          schema:
+            type: string
+            pattern: '^[0-9a-fA-F]+$'
+      responses:
+        '200':
+          description: Successful response containing the raw BART asset data.
+          content:
+            application/octet-stream:
+              schema:
+                type: string
+                format: binary
+        '400':
+          description: Bad request. Missing or invalid hash parameter.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '404':
+          description: BART asset not found.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '500':
+          description: Internal server error.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+    post:
+      summary: Upload a BART asset
+      description: Upload a Buddy ART asset with the specified hash and type.
+      parameters:
+        - name: hash
+          in: path
+          description: The hex hash value for the BART asset.
+          required: true
+          schema:
+            type: string
+            pattern: '^[0-9a-fA-F]+$'
+        - name: type
+          in: query
+          description: The BART type ID (integer).
+          required: true
+          schema:
+            $ref: '#/components/schemas/BARTType'
+      requestBody:
+        required: true
+        content:
+          application/octet-stream:
+            schema:
+              type: string
+              format: binary
+              description: Raw bytes of the BART asset
+      responses:
+        '201':
+          description: BART asset uploaded successfully.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  hash:
+                    type: string
+                    description: A hex value representing the BART asset hash.
+                  type:
+                    $ref: '#/components/schemas/BARTType'
+                    description: The BART type ID.
+                required:
+                  - hash
+                  - type
+        '400':
+          description: Bad request. Invalid type format, hash format, or failed to read request body.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '409':
+          description: Conflict. BART asset already exists.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '413':
+          description: Payload too large. Asset exceeds size limits for the specified type.
+          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 BART asset
+      description: Delete a BART asset with the specified hash.
+      parameters:
+        - name: hash
+          in: path
+          description: The hex hash value for the BART asset.
+          required: true
+          schema:
+            type: string
+            pattern: '^[0-9a-fA-F]+$'
+      responses:
+        '200':
+          description: BART asset deleted successfully.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '400':
+          description: Bad request. Missing or invalid hash parameter.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '404':
+          description: BART asset not found.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+        '500':
+          description: Internal server error.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/MessageResponse'
+
+components:
+  schemas:
+    MessageResponse:
+      type: object
+      properties:
+        message:
+          type: string
+          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]
+      description: |
+        BART (Buddy ART) type identifier. Valid type IDs and their corresponding names:
+        - 0: buddy_icon_small (GIF/JPG/BMP, <= 32 pixels and 2k)
+        - 1: buddy_icon (GIF/JPG/BMP, <= 64 pixels and 7k)
+        - 2: status_str (StringTLV format; DATA flag is always set)
+        - 3: arrive_sound (WAV/MP3/MID, <= 10K)
+        - 4: rich_text (byte array of rich text codes; DATA flag is always set)
+        - 5: superbuddy_icon (XML)
+        - 6: radio_station (Opaque struct; DATA flag is always set)
+        - 12: buddy_icon_big (SWF)
+        - 13: status_str_tod (Time when the status string is set)
+        - 15: current_av_track (XML file; Data flag should not be set)
+        - 96: depart_sound (WAV/MP3/MID, <= 10K)
+        - 129: im_chrome (GIF/JPG/BMP wallpaper)
+        - 131: im_sound (WAV/MP3, <= 10K)
+        - 136: im_chrome_xml (XML)
+        - 137: im_chrome_immers (Immersive Expressions)
+        - 1024: emoticon_set (Set of default Emoticons)
+        - 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"]

+ 77 - 11
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,20 +72,21 @@ 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,
 		c.sqLiteUserStore,
 		c.sqLiteUserStore,
 		c.inMemorySessionManager,
+		c.sqLiteUserStore,
 		c.snacRateLimits,
 		c.logger,
 	)
@@ -315,7 +318,7 @@ func OSCAR(deps Container) *oscar.Server {
 			PermitDenyService: permitDenyService,
 			StatsService:      statsService,
 			UserLookupService: userLookupService,
-			RouteLogger: middleware.RouteLogger{
+			RouteLogger: oscarmiddleware.RouteLogger{
 				Logger: logger,
 			},
 		}.Handle,
@@ -323,7 +326,8 @@ func OSCAR(deps Container) *oscar.Server {
 		deps.snacRateLimits,
 		oscar.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 		deps.Listeners,
-		deps.icbmSvc.DecayWarnLevel,
+		deps.icbmSvc.RestoreWarningLevel,
+		deps.icbmSvc.UpdateWarnLevel,
 	)
 }
 
@@ -342,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.
@@ -420,13 +439,36 @@ func TOC(deps Container) *toc.Server {
 			HTTPIPRateLimiter: toc.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
 		},
 		toc.NewIPRateLimiter(rate.Every(1*time.Minute), 10, 1*time.Minute),
-		deps.icbmSvc.DecayWarnLevel,
+		deps.icbmSvc.RestoreWarningLevel,
+		deps.icbmSvc.UpdateWarnLevel,
 	)
 }
 
 // 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,
@@ -490,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] + "..."
+}

+ 1 - 1
config/config.go

@@ -42,7 +42,7 @@ type Listener struct {
 //go:generate go run ../cmd/config_generator unix settings.env ssl
 type Config struct {
 	BOSListeners            []string `envconfig:"OSCAR_LISTENERS" required:"true" basic:"LOCAL://0.0.0.0:5190" ssl:"LOCAL://0.0.0.0:5190" description:"Network listeners for core OSCAR services. For multi-homed servers, allows users to connect from multiple networks. For example, you can allow both LAN and Internet clients to connect to the same server using different connection settings.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Listener names and ports must be unique\n\t- Listener names are user-defined\n\t- Each listener needs a listener in OSCAR_ADVERTISED_LISTENERS_PLAIN\n\nExamples:\n\t// Listen on all interfaces\n\tLAN://0.0.0.0:5190\n\t// Separate Internet and LAN config\n\tWAN://142.250.176.206:5190,LAN://192.168.1.10:5191"`
-	BOSAdvertisedHostsPlain []string `envconfig:"OSCAR_ADVERTISED_LISTENERS_PLAIN" required:"true" basic:"LOCAL://127.0.0.1:5190" ssl:"LOCAL://127.0.0.1:5190" description:"Hostnames published by the server that clients connect to for accessing various OSCAR services. These hostnames are NOT the bind addresses. For multi-homed use servers, allows clients to connect using separate hostnames per network.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Each listener config must correspond to a config in OSCAR_LISTENERS\n\t- Clients MUST be able to connect to these hostnames\n\nExamples:\n\t// Local LAN config, server behind NAT\n\tLAN://0.0.0.0:5190\n\t// Separate Internet and LAN config\n\tWAN://aim.example.com:5190,LAN://192.168.1.10:5191"`
+	BOSAdvertisedHostsPlain []string `envconfig:"OSCAR_ADVERTISED_LISTENERS_PLAIN" required:"true" basic:"LOCAL://127.0.0.1:5190" ssl:"LOCAL://127.0.0.1:5190" description:"Hostnames published by the server that clients connect to for accessing various OSCAR services. These hostnames are NOT the bind addresses. For multi-homed use servers, allows clients to connect using separate hostnames per network.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Each listener config must correspond to a config in OSCAR_LISTENERS\n\t- Clients MUST be able to connect to these hostnames\n\nExamples:\n\t// Local LAN config, server behind NAT\n\tLAN://192.168.1.10:5190\n\t// Separate Internet and LAN config\n\tWAN://aim.example.com:5190,LAN://192.168.1.10:5191"`
 	BOSAdvertisedHostsSSL   []string `envconfig:"OSCAR_ADVERTISED_LISTENERS_SSL" required:"false" basic:"" ssl:"LOCAL://ras.dev:5193" description:"Same as OSCAR_ADVERTISED_LISTENERS_PLAIN, except the hostname is for the server that terminates SSL."`
 	KerberosListeners       []string `envconfig:"KERBEROS_LISTENERS" required:"false" basic:"" ssl:"LOCAL://0.0.0.0:1088" description:"Network listeners for Kerberos authentication. See OSCAR_LISTENERS doc for more details.\n\nExamples:\n\t// Listen on all interfaces\n\tLAN://0.0.0.0:1088\n\t// Separate Internet and LAN config\n\tWAN://142.250.176.206:1088,LAN://192.168.1.10:1087"`
 	TOCListeners            []string `envconfig:"TOC_LISTENERS" required:"true" basic:"0.0.0.0:9898" ssl:"0.0.0.0:9898" description:"Network listeners for TOC protocol service.\n\nFormat: Comma-separated list of hostname:port pairs.\n\nExamples:\n\t// All interfaces\n\t0.0.0.0:9898\n\t// Multiple listeners\n\t0.0.0.0:9898,192.168.1.10:9899"`

+ 1 - 1
config/settings.env

@@ -28,7 +28,7 @@ export OSCAR_LISTENERS=LOCAL://0.0.0.0:5190
 # 
 # Examples:
 # 	// Local LAN config, server behind NAT
-# 	LAN://0.0.0.0:5190
+# 	LAN://192.168.1.10:5190
 # 	// Separate Internet and LAN config
 # 	WAN://aim.example.com:5190,LAN://192.168.1.10:5191
 export OSCAR_ADVERTISED_LISTENERS_PLAIN=LOCAL://127.0.0.1:5190

+ 1 - 1
config/ssl/settings.env

@@ -28,7 +28,7 @@ export OSCAR_LISTENERS=LOCAL://0.0.0.0:5190
 # 
 # Examples:
 # 	// Local LAN config, server behind NAT
-# 	LAN://0.0.0.0:5190
+# 	LAN://192.168.1.10:5190
 # 	// Separate Internet and LAN config
 # 	WAN://aim.example.com:5190,LAN://192.168.1.10:5191
 export OSCAR_ADVERTISED_LISTENERS_PLAIN=LOCAL://127.0.0.1:5190

+ 56 - 2
docs/ADDITIONAL_SETUP.md

@@ -3,7 +3,7 @@
 This guide covers some optional configuration steps to get the best experience from Retro AIM Server.
 
 - [Configure User Directory Keywords](#configure-user-directory-keywords)
-- [Configure SSL for Connecting AIM 6.2+](#configure-ssl-for-connecting-aim-62)
+- [Import AIM Smiley Packs](#import-aim-smiley-packs)
 
 ## Configure User Directory Keywords
 
@@ -128,4 +128,58 @@ keywords and keyword categories via the management API.
 4. **Restart**
 
    After creating or modifying keyword categories and keywords, users currently connected to the server must sign out
-   and back in again in order to see the updated keyword list.
+   and back in again in order to see the updated keyword list.
+
+## Import AIM Smiley Packs
+
+AIM emoticons (also called smileys) are the graphics that appear when users send messages with specific codes like `:)`
+or `:D`. Retro AIM Server includes a BART import utility that allows you to import smiley pack collections.
+
+This guide shows you how to install a smiley pack available
+from [The Internet Archive](https://archive.org/details/aol_instant_messenger_smiley_packs).
+
+1. **Download Smiley Pack**
+
+   First download the smiley pack archive.
+
+   ```bash
+   curl -L -o aim_bart_emoticons.zip "https://archive.org/download/aol_instant_messenger_smiley_packs/aim_bart_emoticons.zip"
+   ```
+
+   Then extract the archive:
+
+   ```bash
+   unzip aim_bart_emoticons.zip
+   ```
+
+   This creates an `aim_bart_emoticons/` directory containing 86 files (1 per pack) with hash names like `0201D213A4`,
+   `0201D205C8`, etc.
+
+2. **Import Emoticons**
+
+   From the root of the Retro AIM Server repository, run the BART import script to upload the smiley pack:
+
+    ```bash
+    ./scripts/import_bart.sh -t emoticon_set -u http://localhost:8080 /path/to/aim_bart_emoticons
+    ```
+
+   Replace `/path/to/aim_bart_emoticons` with the actual path to your extracted directory.
+
+3. **Verify Import Success**
+
+   Check that all emoticons were imported successfully:
+
+   ```bash
+   curl "http://localhost:8080/bart?type=1024"
+   ```
+
+4. **Send an Emoticon**
+
+   After importing, you can test the emoticons in AIM clients by sending messages with emoticon codes. For example, the
+   classic smiley emoticon can be tested with:
+
+   ```
+   <font sml="KwAAAeQ=">:)</font>
+   ```
+
+   This code references the smiley pack with hash `2B000001E4` that should now be available in your server.

+ 2 - 2
docs/AIM_6_7.md

@@ -16,7 +16,7 @@ Installation guides are available for the following versions:
 ### Installation
 
 1. Download AIM 6.x (recommended **AIM 6.1.46.1**) from
-   the [NINA wiki](https://wiki.nina.chat/wiki/Clients/AOL_Instant_Messenger#Windows).
+   the [NINA wiki](https://web.archive.org/web/20250910233232/https://wiki.nina.chat/wiki/Clients/AOL_Instant_Messenger#Windows).
 2. Run the installer and complete the installation.
 3. Close the AIM application.
 4. Open **Task Manager** and end the **AIM (32 bit)** process if it's still running.
@@ -74,7 +74,7 @@ To point the client to your Retro AIM Server:
 ### Installation
 
 1. Download an AIM 6.x or 7.x client (recommended **6.9.17.2** or **7.5.8.2**) from the  
-   [NINA wiki](https://wiki.nina.chat/wiki/Clients/AOL_Instant_Messenger#Windows).
+   [NINA wiki](https://web.archive.org/web/20250910233232/https://wiki.nina.chat/wiki/Clients/AOL_Instant_Messenger#Windows).
 2. Run the installer and complete the installation.
 3. Close the AIM application.
 4. Open **Task Manager** and end the **AIM (32 bit)** process if it's still running.

+ 1 - 1
docs/CLIENT.md

@@ -26,7 +26,7 @@ Get started with the [AIM for macOS project](https://github.com/mk6i/aim-for-mac
 
 ### Windows 10/11
 
-1. Download AIM 5.9.6089 (available on [NINA wiki](https://wiki.nina.chat/wiki/Clients/AOL_Instant_Messenger#Windows)).
+1. Download AIM 5.9.6089 (available on [NINA wiki](https://web.archive.org/web/20250910233232/https://wiki.nina.chat/wiki/Clients/AOL_Instant_Messenger#Windows)).
 2. Install AIM and exit out of the application post-installation.
 3. Open **Task Manager** and kill the **AOL Instant Messenger (32 bit)** process to make sure the application is
    actually terminated.

+ 0 - 1
docs/open_api/webapi.yml

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

+ 4 - 4
foodgroup/admin.go

@@ -13,7 +13,7 @@ import (
 // NewAdminService creates an instance of AdminService.
 func NewAdminService(
 	accountManager AccountManager,
-	buddyIconManager BuddyIconManager,
+	bartItemManager BARTItemManager,
 	relationshipFetcher RelationshipFetcher,
 	messageRelayer MessageRelayer,
 	sessionRetriever SessionRetriever,
@@ -21,7 +21,7 @@ func NewAdminService(
 ) *AdminService {
 	return &AdminService{
 		accountManager:   accountManager,
-		buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
+		buddyBroadcaster: newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		messageRelayer:   messageRelayer,
 		logger:           logger,
 	}
@@ -71,7 +71,7 @@ func (s AdminService) ConfirmRequest(ctx context.Context, sess *state.Session, f
 		return wire.SNACMessage{}, err
 	}
 	sess.ClearUserInfoFlag(wire.OServiceUserFlagUnconfirmed)
-	if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+	if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo()); err != nil {
 		return wire.SNACMessage{}, err
 	}
 	return getAdminConfirmReply(wire.AdminAcctConfirmStatusEmailSent), nil
@@ -210,7 +210,7 @@ func (s AdminService) InfoChangeRequest(ctx context.Context, sess *state.Session
 			return wire.SNACMessage{}, err
 		}
 		sess.SetDisplayScreenName(proposedName)
-		if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+		if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo()); err != nil {
 			return wire.SNACMessage{}, err
 		}
 		s.messageRelayer.RelayToScreenName(ctx, sess.IdentScreenName(), wire.SNACMessage{

+ 7 - 5
foodgroup/admin_test.go

@@ -84,7 +84,7 @@ func TestAdminService_ConfirmRequest(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("Chatting Chuck"),
+							screenName: state.DisplayScreenName("Chatting Chuck"),
 						},
 					},
 				},
@@ -192,7 +192,7 @@ func TestAdminService_ConfirmRequest(t *testing.T) {
 			}
 			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
 				buddyBroadcaster.EXPECT().
-					BroadcastBuddyArrived(mock.Anything, tc.userSession).
+					BroadcastBuddyArrived(mock.Anything, tc.userSession.IdentScreenName(), tc.userSession.TLVUserInfo()).
 					Return(params.err)
 			}
 			svc := AdminService{
@@ -485,7 +485,7 @@ func TestAdminService_InfoChangeRequest_ScreenName(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("Chatting Chuck"),
+							screenName: state.DisplayScreenName("Chatting Chuck"),
 						},
 					},
 				},
@@ -552,7 +552,7 @@ func TestAdminService_InfoChangeRequest_ScreenName(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("Chatting Chuck"),
+							screenName: state.DisplayScreenName("Chatting Chuck"),
 						},
 					},
 				},
@@ -716,7 +716,9 @@ func TestAdminService_InfoChangeRequest_ScreenName(t *testing.T) {
 
 			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
 				mockBuddyBroadcaster.EXPECT().
-					BroadcastBuddyArrived(mock.Anything, matchSession(params.screenName)).
+					BroadcastBuddyArrived(mock.Anything, state.NewIdentScreenName(params.screenName.String()), mock.MatchedBy(func(userInfo wire.TLVUserInfo) bool {
+						return userInfo.ScreenName == params.screenName.String()
+					})).
 					Return(params.err)
 			}
 

+ 57 - 14
foodgroup/bart.go

@@ -3,6 +3,7 @@ package foodgroup
 import (
 	"context"
 	"crypto/md5"
+	"errors"
 	"fmt"
 	"log/slog"
 
@@ -20,20 +21,20 @@ var blankGIF = []byte{
 
 func NewBARTService(
 	logger *slog.Logger,
-	buddyIconManager BuddyIconManager,
+	bartItemManager BARTItemManager,
 	messageRelayer MessageRelayer,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
 ) BARTService {
 	return BARTService{
-		buddyIconManager:       buddyIconManager,
-		buddyUpdateBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
+		bartItemManager:        bartItemManager,
+		buddyUpdateBroadcaster: newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		logger:                 logger,
 	}
 }
 
 type BARTService struct {
-	buddyIconManager       BuddyIconManager
+	bartItemManager        BARTItemManager
 	buddyUpdateBroadcaster buddyBroadcaster
 	logger                 *slog.Logger
 }
@@ -45,13 +46,15 @@ func (s BARTService) UpsertItem(ctx context.Context, sess *state.Session, inFram
 	}
 	hash := h.Sum(nil)
 
-	if err := s.buddyIconManager.SetBuddyIcon(ctx, hash, inBody.Data); err != nil {
-		return wire.SNACMessage{}, err
+	if err := s.bartItemManager.InsertBARTItem(ctx, hash, inBody.Data, inBody.Type); err != nil {
+		if !errors.Is(err, state.ErrBARTItemExists) {
+			return wire.SNACMessage{}, err
+		}
 	}
 
-	s.logger.DebugContext(ctx, "successfully uploaded buddy icon", "hash", fmt.Sprintf("%x", hash))
+	s.logger.DebugContext(ctx, "successfully uploaded BART item", "hash", fmt.Sprintf("%x", hash))
 
-	if err := s.buddyUpdateBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+	if err := s.buddyUpdateBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo()); err != nil {
 		return wire.SNACMessage{}, err
 	}
 
@@ -77,18 +80,18 @@ func (s BARTService) UpsertItem(ctx context.Context, sess *state.Session, inFram
 // RetrieveItem fetches a BART item from the data store. The item is selected
 // based on inBody.Hash. It's unclear what effect inBody.Flags is supposed to
 // have on the request.
-func (s BARTService) RetrieveItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error) {
-	var icon []byte
+func (s BARTService) RetrieveItem(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error) {
+	var item []byte
 	if inBody.HasClearIconHash() {
-		icon = blankGIF
+		item = blankGIF
 	} else {
 		var err error
-		if icon, err = s.buddyIconManager.BuddyIcon(ctx, inBody.Hash); err != nil {
+		if item, err = s.bartItemManager.BARTItem(ctx, inBody.Hash); err != nil {
 			return wire.SNACMessage{}, err
 		}
 	}
 
-	// todo... how to reply if requested icon doesn't exist
+	// todo... how to reply if requested item doesn't exist
 	return wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.BART,
@@ -98,7 +101,47 @@ func (s BARTService) RetrieveItem(ctx context.Context, sess *state.Session, inFr
 		Body: wire.SNAC_0x10_0x05_BARTDownloadReply{
 			ScreenName: inBody.ScreenName,
 			BARTID:     inBody.BARTID,
-			Data:       icon,
+			Data:       item,
 		},
 	}, nil
 }
+
+// RetrieveItemV2 implements the SNAC handler for SNAC(0x10,0x06) BARTDownload2Query.
+func (s BARTService) RetrieveItemV2(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x06_BARTDownload2Query) ([]wire.SNACMessage, error) {
+	var result []wire.SNACMessage
+
+	for _, id := range inBody.IDs {
+		var item []byte
+
+		if id.Type == wire.BARTTypesBuddyIcon && id.HasClearIconHash() {
+			item = blankGIF
+		} else {
+			var err error
+			if item, err = s.bartItemManager.BARTItem(ctx, id.Hash); err != nil {
+				return nil, err
+			}
+		}
+
+		result = append(result, wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.BART,
+				SubGroup:  wire.BARTDownload2Reply,
+				RequestID: inFrame.RequestID,
+			},
+			Body: wire.SNAC_0x10_0x07_BARTDownload2Reply{
+				ScreenName: inBody.ScreenName,
+				ReplyID: wire.BartQueryReplyID{
+					QueryID: id,
+					Code:    0x00, // found
+					ReplyID: wire.BARTID{
+						Type:     id.Type,
+						BARTInfo: id.BARTInfo,
+					},
+				},
+				Data: item,
+			},
+		})
+	}
+
+	return result, nil
+}

+ 427 - 21
foodgroup/bart_test.go

@@ -39,18 +39,19 @@ func TestBARTService_UpsertItem(t *testing.T) {
 				},
 			},
 			mockParams: mockParams{
-				buddyIconManagerParams: buddyIconManagerParams{
-					buddyIconManagerUpsertParams: buddyIconManagerUpsertParams{
+				bartItemManagerParams: bartItemManagerParams{
+					bartItemManagerUpsertParams: bartItemManagerUpsertParams{
 						{
 							itemHash: []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
 							payload:  []byte{'i', 't', 'e', 'm', 'd', 'a', 't', 'a'},
+							bartType: 1,
 						},
 					},
 				},
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("user_screen_name"),
+							screenName: state.DisplayScreenName("user_screen_name"),
 						},
 					},
 				},
@@ -77,19 +78,21 @@ func TestBARTService_UpsertItem(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			buddyIconManager := newMockBuddyIconManager(t)
-			for _, params := range tc.mockParams.buddyIconManagerUpsertParams {
-				buddyIconManager.EXPECT().
-					SetBuddyIcon(matchContext(), params.itemHash, params.payload).
+			bartItemManager := newMockBARTItemManager(t)
+			for _, params := range tc.mockParams.bartItemManagerUpsertParams {
+				bartItemManager.EXPECT().
+					InsertBARTItem(matchContext(), params.itemHash, params.payload, params.bartType).
 					Return(nil)
 			}
 			buddyUpdateBroadcaster := newMockbuddyBroadcaster(t)
 			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
 				buddyUpdateBroadcaster.EXPECT().
-					BroadcastBuddyArrived(mock.Anything, matchSession(params.screenName)).
+					BroadcastBuddyArrived(mock.Anything, state.NewIdentScreenName(params.screenName.String()), mock.MatchedBy(func(userInfo wire.TLVUserInfo) bool {
+						return userInfo.ScreenName == params.screenName.String()
+					})).
 					Return(params.err)
 			}
-			svc := NewBARTService(slog.Default(), buddyIconManager, nil, nil, nil)
+			svc := NewBARTService(slog.Default(), bartItemManager, nil, nil, nil)
 			svc.buddyUpdateBroadcaster = buddyUpdateBroadcaster
 
 			output, err := svc.UpsertItem(context.Background(), tc.userSession, tc.inputSNAC.Frame,
@@ -137,8 +140,8 @@ func TestBARTService_RetrieveItem(t *testing.T) {
 				},
 			},
 			mockParams: mockParams{
-				buddyIconManagerParams: buddyIconManagerParams{
-					buddyIconManagerRetrieveParams: buddyIconManagerRetrieveParams{
+				bartItemManagerParams: bartItemManagerParams{
+					bartItemManagerRetrieveParams: bartItemManagerRetrieveParams{
 						{
 							itemHash: []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
 							result:   []byte{'i', 't', 'e', 'm', 'd', 'a', 't', 'a'},
@@ -203,26 +206,429 @@ func TestBARTService_RetrieveItem(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:        "retrieve item with error",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x10_0x04_BARTDownloadQuery{
+					ScreenName: "user_screen_name",
+					Command:    1,
+					BARTID: wire.BARTID{
+						Type: wire.BARTTypesBuddyIcon,
+						BARTInfo: wire.BARTInfo{
+							Flags: wire.BARTFlagsKnown,
+							Hash:  []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				bartItemManagerParams: bartItemManagerParams{
+					bartItemManagerRetrieveParams: bartItemManagerRetrieveParams{
+						{
+							itemHash: []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+							result:   nil,
+							err:      assert.AnError,
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{},
+			expectErr:    assert.AnError,
+		},
 	}
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			buddyIconManager := newMockBuddyIconManager(t)
-			for _, params := range tc.mockParams.buddyIconManagerParams.buddyIconManagerRetrieveParams {
-				buddyIconManager.EXPECT().
-					BuddyIcon(matchContext(), params.itemHash).
-					Return(params.result, nil)
+			bartItemManager := newMockBARTItemManager(t)
+			for _, params := range tc.mockParams.bartItemManagerParams.bartItemManagerRetrieveParams {
+				bartItemManager.EXPECT().
+					BARTItem(matchContext(), params.itemHash).
+					Return(params.result, params.err)
 			}
 
-			svc := NewBARTService(slog.Default(), buddyIconManager, nil, nil, nil)
+			svc := NewBARTService(slog.Default(), bartItemManager, nil, nil, nil)
 
-			output, err := svc.RetrieveItem(context.Background(), tc.userSession, tc.inputSNAC.Frame,
-				tc.inputSNAC.Body.(wire.SNAC_0x10_0x04_BARTDownloadQuery))
+			output, err := svc.RetrieveItem(context.Background(), tc.inputSNAC.Frame, tc.inputSNAC.Body.(wire.SNAC_0x10_0x04_BARTDownloadQuery))
 
 			assert.ErrorIs(t, err, tc.expectErr)
-			if tc.expectErr != nil {
-				return
+			assert.Equal(t, output, tc.expectOutput)
+		})
+	}
+}
+
+func TestBARTService_RetrieveItemV2(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// userSession is the session of the user adding to feedbag
+		userSession *state.Session
+		// inputSNAC is the SNAC sent from the client to the server
+		inputSNAC wire.SNACMessage
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// expectOutput is the SNAC sent from the server to client
+		expectOutput []wire.SNACMessage
+		// expectErr is the expected error
+		expectErr error
+	}{
+		{
+			name:        "retrieve single buddy icon",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x10_0x06_BARTDownload2Query{
+					ScreenName: "user_screen_name",
+					IDs: []wire.BARTID{
+						{
+							Type: wire.BARTTypesBuddyIcon,
+							BARTInfo: wire.BARTInfo{
+								Flags: wire.BARTFlagsKnown,
+								Hash:  []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+							},
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				bartItemManagerParams: bartItemManagerParams{
+					bartItemManagerRetrieveParams: bartItemManagerRetrieveParams{
+						{
+							itemHash: []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+							result:   []byte{'i', 't', 'e', 'm', 'd', 'a', 't', 'a'},
+						},
+					},
+				},
+			},
+			expectOutput: []wire.SNACMessage{
+				{
+					Frame: wire.SNACFrame{
+						FoodGroup: wire.BART,
+						SubGroup:  wire.BARTDownload2Reply,
+						RequestID: 1234,
+					},
+					Body: wire.SNAC_0x10_0x07_BARTDownload2Reply{
+						ScreenName: "user_screen_name",
+						ReplyID: wire.BartQueryReplyID{
+							QueryID: wire.BARTID{
+								Type: wire.BARTTypesBuddyIcon,
+								BARTInfo: wire.BARTInfo{
+									Flags: wire.BARTFlagsKnown,
+									Hash:  []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+								},
+							},
+							Code: 0x00, // found
+							ReplyID: wire.BARTID{
+								Type: wire.BARTTypesBuddyIcon,
+								BARTInfo: wire.BARTInfo{
+									Flags: wire.BARTFlagsKnown,
+									Hash:  []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+								},
+							},
+						},
+						Data: []byte{'i', 't', 'e', 'm', 'd', 'a', 't', 'a'},
+					},
+				},
+			},
+		},
+		{
+			name:        "retrieve multiple buddy icons",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x10_0x06_BARTDownload2Query{
+					ScreenName: "user_screen_name",
+					IDs: []wire.BARTID{
+						{
+							Type: wire.BARTTypesBuddyIcon,
+							BARTInfo: wire.BARTInfo{
+								Flags: wire.BARTFlagsKnown,
+								Hash:  []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+							},
+						},
+						{
+							Type: wire.BARTTypesBuddyIcon,
+							BARTInfo: wire.BARTInfo{
+								Flags: wire.BARTFlagsKnown,
+								Hash:  []byte{0x5f, 0xea, 0xd2, 0xa7, 0x56, 0xec, 0x6b, 0xfd, 0xec, 0x06, 0xd8, 0xb3, 0x5f, 0x9f, 0xb1, 0xfe},
+							},
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				bartItemManagerParams: bartItemManagerParams{
+					bartItemManagerRetrieveParams: bartItemManagerRetrieveParams{
+						{
+							itemHash: []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+							result:   []byte{'f', 'i', 'r', 's', 't', 'i', 'c', 'o', 'n'},
+						},
+						{
+							itemHash: []byte{0x5f, 0xea, 0xd2, 0xa7, 0x56, 0xec, 0x6b, 0xfd, 0xec, 0x06, 0xd8, 0xb3, 0x5f, 0x9f, 0xb1, 0xfe},
+							result:   []byte{'s', 'e', 'c', 'o', 'n', 'd', 'i', 'c', 'o', 'n'},
+						},
+					},
+				},
+			},
+			expectOutput: []wire.SNACMessage{
+				{
+					Frame: wire.SNACFrame{
+						FoodGroup: wire.BART,
+						SubGroup:  wire.BARTDownload2Reply,
+						RequestID: 1234,
+					},
+					Body: wire.SNAC_0x10_0x07_BARTDownload2Reply{
+						ScreenName: "user_screen_name",
+						ReplyID: wire.BartQueryReplyID{
+							QueryID: wire.BARTID{
+								Type: wire.BARTTypesBuddyIcon,
+								BARTInfo: wire.BARTInfo{
+									Flags: wire.BARTFlagsKnown,
+									Hash:  []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+								},
+							},
+							Code: 0x00, // found
+							ReplyID: wire.BARTID{
+								Type: wire.BARTTypesBuddyIcon,
+								BARTInfo: wire.BARTInfo{
+									Flags: wire.BARTFlagsKnown,
+									Hash:  []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+								},
+							},
+						},
+						Data: []byte{'f', 'i', 'r', 's', 't', 'i', 'c', 'o', 'n'},
+					},
+				},
+				{
+					Frame: wire.SNACFrame{
+						FoodGroup: wire.BART,
+						SubGroup:  wire.BARTDownload2Reply,
+						RequestID: 1234,
+					},
+					Body: wire.SNAC_0x10_0x07_BARTDownload2Reply{
+						ScreenName: "user_screen_name",
+						ReplyID: wire.BartQueryReplyID{
+							QueryID: wire.BARTID{
+								Type: wire.BARTTypesBuddyIcon,
+								BARTInfo: wire.BARTInfo{
+									Flags: wire.BARTFlagsKnown,
+									Hash:  []byte{0x5f, 0xea, 0xd2, 0xa7, 0x56, 0xec, 0x6b, 0xfd, 0xec, 0x06, 0xd8, 0xb3, 0x5f, 0x9f, 0xb1, 0xfe},
+								},
+							},
+							Code: 0x00, // found
+							ReplyID: wire.BARTID{
+								Type: wire.BARTTypesBuddyIcon,
+								BARTInfo: wire.BARTInfo{
+									Flags: wire.BARTFlagsKnown,
+									Hash:  []byte{0x5f, 0xea, 0xd2, 0xa7, 0x56, 0xec, 0x6b, 0xfd, 0xec, 0x06, 0xd8, 0xb3, 0x5f, 0x9f, 0xb1, 0xfe},
+								},
+							},
+						},
+						Data: []byte{'s', 'e', 'c', 'o', 'n', 'd', 'i', 'c', 'o', 'n'},
+					},
+				},
+			},
+		},
+		{
+			name:        "retrieve single item with error",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x10_0x06_BARTDownload2Query{
+					ScreenName: "user_screen_name",
+					IDs: []wire.BARTID{
+						{
+							Type: wire.BARTTypesBuddyIcon,
+							BARTInfo: wire.BARTInfo{
+								Flags: wire.BARTFlagsKnown,
+								Hash:  []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+							},
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				bartItemManagerParams: bartItemManagerParams{
+					bartItemManagerRetrieveParams: bartItemManagerRetrieveParams{
+						{
+							itemHash: []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+							result:   nil,
+							err:      assert.AnError,
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+			expectErr:    assert.AnError,
+		},
+		{
+			name:        "retrieve multiple items with error on second item",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x10_0x06_BARTDownload2Query{
+					ScreenName: "user_screen_name",
+					IDs: []wire.BARTID{
+						{
+							Type: wire.BARTTypesBuddyIcon,
+							BARTInfo: wire.BARTInfo{
+								Flags: wire.BARTFlagsKnown,
+								Hash:  []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+							},
+						},
+						{
+							Type: wire.BARTTypesBuddyIcon,
+							BARTInfo: wire.BARTInfo{
+								Flags: wire.BARTFlagsKnown,
+								Hash:  []byte{0x5f, 0xea, 0xd2, 0xa7, 0x56, 0xec, 0x6b, 0xfd, 0xec, 0x06, 0xd8, 0xb3, 0x5f, 0x9f, 0xb1, 0xfe},
+							},
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				bartItemManagerParams: bartItemManagerParams{
+					bartItemManagerRetrieveParams: bartItemManagerRetrieveParams{
+						{
+							itemHash: []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+							result:   []byte{'f', 'i', 'r', 's', 't', 'i', 'c', 'o', 'n'},
+							err:      nil,
+						},
+						{
+							itemHash: []byte{0x5f, 0xea, 0xd2, 0xa7, 0x56, 0xec, 0x6b, 0xfd, 0xec, 0x06, 0xd8, 0xb3, 0x5f, 0x9f, 0xb1, 0xfe},
+							result:   nil,
+							err:      assert.AnError,
+						},
+					},
+				},
+			},
+			expectOutput: nil,
+			expectErr:    assert.AnError,
+		},
+		{
+			name:        "retrieve mixed items (regular icon and clear icon)",
+			userSession: newTestSession("user_screen_name"),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x10_0x06_BARTDownload2Query{
+					ScreenName: "user_screen_name",
+					IDs: []wire.BARTID{
+						{
+							Type: wire.BARTTypesBuddyIcon,
+							BARTInfo: wire.BARTInfo{
+								Flags: wire.BARTFlagsKnown,
+								Hash:  []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+							},
+						},
+						{
+							Type: wire.BARTTypesBuddyIcon,
+							BARTInfo: wire.BARTInfo{
+								Flags: wire.BARTFlagsKnown,
+								Hash:  wire.GetClearIconHash(),
+							},
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				bartItemManagerParams: bartItemManagerParams{
+					bartItemManagerRetrieveParams: bartItemManagerRetrieveParams{
+						{
+							itemHash: []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+							result:   []byte{'r', 'e', 'g', 'u', 'l', 'a', 'r', 'i', 'c', 'o', 'n'},
+						},
+					},
+				},
+			},
+			expectOutput: []wire.SNACMessage{
+				{
+					Frame: wire.SNACFrame{
+						FoodGroup: wire.BART,
+						SubGroup:  wire.BARTDownload2Reply,
+						RequestID: 1234,
+					},
+					Body: wire.SNAC_0x10_0x07_BARTDownload2Reply{
+						ScreenName: "user_screen_name",
+						ReplyID: wire.BartQueryReplyID{
+							QueryID: wire.BARTID{
+								Type: wire.BARTTypesBuddyIcon,
+								BARTInfo: wire.BARTInfo{
+									Flags: wire.BARTFlagsKnown,
+									Hash:  []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+								},
+							},
+							Code: 0x00, // found
+							ReplyID: wire.BARTID{
+								Type: wire.BARTTypesBuddyIcon,
+								BARTInfo: wire.BARTInfo{
+									Flags: wire.BARTFlagsKnown,
+									Hash:  []byte{0x4e, 0xd9, 0xc1, 0x96, 0x45, 0xdb, 0x5a, 0xec, 0xdb, 0xf5, 0xc7, 0xa2, 0x4e, 0x8e, 0xa0, 0xed},
+								},
+							},
+						},
+						Data: []byte{'r', 'e', 'g', 'u', 'l', 'a', 'r', 'i', 'c', 'o', 'n'},
+					},
+				},
+				{
+					Frame: wire.SNACFrame{
+						FoodGroup: wire.BART,
+						SubGroup:  wire.BARTDownload2Reply,
+						RequestID: 1234,
+					},
+					Body: wire.SNAC_0x10_0x07_BARTDownload2Reply{
+						ScreenName: "user_screen_name",
+						ReplyID: wire.BartQueryReplyID{
+							QueryID: wire.BARTID{
+								Type: wire.BARTTypesBuddyIcon,
+								BARTInfo: wire.BARTInfo{
+									Flags: wire.BARTFlagsKnown,
+									Hash:  wire.GetClearIconHash(),
+								},
+							},
+							Code: 0x00, // found
+							ReplyID: wire.BARTID{
+								Type: wire.BARTTypesBuddyIcon,
+								BARTInfo: wire.BARTInfo{
+									Flags: wire.BARTFlagsKnown,
+									Hash:  wire.GetClearIconHash(),
+								},
+							},
+						},
+						Data: blankGIF,
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			bartItemManager := newMockBARTItemManager(t)
+			for _, params := range tc.mockParams.bartItemManagerParams.bartItemManagerRetrieveParams {
+				bartItemManager.EXPECT().
+					BARTItem(matchContext(), params.itemHash).
+					Return(params.result, params.err)
 			}
+
+			svc := NewBARTService(slog.Default(), bartItemManager, nil, nil, nil)
+
+			output, err := svc.RetrieveItemV2(context.Background(), tc.inputSNAC.Frame, tc.inputSNAC.Body.(wire.SNAC_0x10_0x06_BARTDownload2Query))
+
+			assert.ErrorIs(t, err, tc.expectErr)
 			assert.Equal(t, output, tc.expectOutput)
 		})
 	}

+ 15 - 15
foodgroup/buddy.go

@@ -14,10 +14,10 @@ func NewBuddyService(
 	clientSideBuddyListManager ClientSideBuddyListManager,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
-	buddyIconManager BuddyIconManager,
+	bartItemManager BARTItemManager,
 ) *BuddyService {
 	return &BuddyService{
-		buddyBroadcaster:           newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
+		buddyBroadcaster:           newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		clientSideBuddyListManager: clientSideBuddyListManager,
 	}
 }
@@ -100,22 +100,23 @@ 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) BroadcastBuddyArrived(ctx context.Context, sess *state.Session) error {
-	return s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess)
+func (s BuddyService) BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error {
+	return s.buddyBroadcaster.BroadcastBuddyDeparted(ctx, sess)
 }
 
 func newBuddyNotifier(
-	buddyIconManager BuddyIconManager,
+	bartItemManager BARTItemManager,
 	relationshipFetcher RelationshipFetcher,
 	messageRelayer MessageRelayer,
 	sessionRetriever SessionRetriever,
 ) buddyNotifier {
 	return buddyNotifier{
-		buddyIconManager:    buddyIconManager,
+		bartItemManager:     bartItemManager,
 		relationshipFetcher: relationshipFetcher,
 		messageRelayer:      messageRelayer,
 		sessionRetriever:    sessionRetriever,
@@ -125,7 +126,7 @@ func newBuddyNotifier(
 // buddyNotifier centralizes logic for sending buddy arrival and departure
 // notifications.
 type buddyNotifier struct {
-	buddyIconManager    BuddyIconManager
+	bartItemManager     BARTItemManager
 	relationshipFetcher RelationshipFetcher
 	messageRelayer      MessageRelayer
 	sessionRetriever    SessionRetriever
@@ -135,8 +136,8 @@ type buddyNotifier struct {
 // While updates are sent via the wire.BuddyArrived SNAC, the message is not
 // only used to indicate the user coming online. It can also notify changes to
 // buddy icons, warning levels, invisibility status, etc.
-func (s buddyNotifier) BroadcastBuddyArrived(ctx context.Context, sess *state.Session) error {
-	users, err := s.relationshipFetcher.AllRelationships(ctx, sess.IdentScreenName(), nil)
+func (s buddyNotifier) BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error {
+	users, err := s.relationshipFetcher.AllRelationships(ctx, screenName, nil)
 	if err != nil {
 		return err
 	}
@@ -149,9 +150,8 @@ func (s buddyNotifier) BroadcastBuddyArrived(ctx context.Context, sess *state.Se
 		recipients = append(recipients, user.User)
 	}
 
-	userInfo := sess.TLVUserInfo()
-	if err := s.setBuddyIcon(ctx, sess.IdentScreenName(), &userInfo); err != nil {
-		return fmt.Errorf("failed to set buddy icon for %s: %w", sess.IdentScreenName().String(), err)
+	if err := s.setBuddyIcon(ctx, screenName, &userInfo); err != nil {
+		return fmt.Errorf("failed to set buddy icon for %s: %w", screenName.String(), err)
 	}
 
 	s.messageRelayer.RelayToScreenNames(ctx, recipients, wire.SNACMessage{
@@ -286,7 +286,7 @@ func (s buddyNotifier) BroadcastVisibility(
 
 // setBuddyIcon adds buddy icon metadata to TLV user info
 func (s buddyNotifier) setBuddyIcon(ctx context.Context, you state.IdentScreenName, myInfo *wire.TLVUserInfo) error {
-	icon, err := s.buddyIconManager.BuddyIconMetadata(ctx, you)
+	icon, err := s.bartItemManager.BuddyIconMetadata(ctx, you)
 	if err != nil {
 		return fmt.Errorf("retrieve buddy icon ref: %v", err)
 	}

+ 13 - 13
foodgroup/buddy_test.go

@@ -244,7 +244,7 @@ func TestBuddyNotifier_BroadcastBuddyArrived(t *testing.T) {
 			name:        "happy path",
 			userSession: newTestSession("me"),
 			mockParams: mockParams{
-				buddyIconManagerParams: buddyIconManagerParams{
+				bartItemManagerParams: bartItemManagerParams{
 					buddyIconMetadataParams: buddyIconMetadataParams{
 						{
 							screenName: state.NewIdentScreenName("me"),
@@ -335,9 +335,9 @@ func TestBuddyNotifier_BroadcastBuddyArrived(t *testing.T) {
 					AllRelationships(matchContext(), params.screenName, params.filter).
 					Return(params.result, params.err)
 			}
-			buddyIconManager := newMockBuddyIconManager(t)
+			bartItemManager := newMockBARTItemManager(t)
 			for _, params := range tc.mockParams.buddyIconMetadataParams {
-				buddyIconManager.EXPECT().
+				bartItemManager.EXPECT().
 					BuddyIconMetadata(matchContext(), params.screenName).
 					Return(params.result, params.err)
 			}
@@ -348,12 +348,12 @@ func TestBuddyNotifier_BroadcastBuddyArrived(t *testing.T) {
 			}
 
 			svc := buddyNotifier{
-				buddyIconManager:    buddyIconManager,
+				bartItemManager:     bartItemManager,
 				relationshipFetcher: relationshipFetcher,
 				messageRelayer:      messageRelayer,
 			}
 
-			err := svc.BroadcastBuddyArrived(context.Background(), tc.userSession)
+			err := svc.BroadcastBuddyArrived(context.Background(), tc.userSession.IdentScreenName(), tc.userSession.TLVUserInfo())
 			assert.NoError(t, err)
 		})
 	}
@@ -458,9 +458,9 @@ func TestBuddyService_BroadcastDeparture(t *testing.T) {
 					AllRelationships(matchContext(), params.screenName, params.filter).
 					Return(params.result, params.err)
 			}
-			buddyIconManager := newMockBuddyIconManager(t)
+			bartItemManager := newMockBARTItemManager(t)
 			for _, params := range tc.mockParams.buddyIconMetadataParams {
-				buddyIconManager.EXPECT().
+				bartItemManager.EXPECT().
 					BuddyIconMetadata(matchContext(), params.screenName).
 					Return(params.result, params.err)
 			}
@@ -500,7 +500,7 @@ func Test_buddyNotifier_BroadcastVisibility(t *testing.T) {
 			name:        "happy path",
 			userSession: newTestSession("me"),
 			mockParams: mockParams{
-				buddyIconManagerParams: buddyIconManagerParams{
+				bartItemManagerParams: bartItemManagerParams{
 					buddyIconMetadataParams: buddyIconMetadataParams{
 						{
 							screenName: state.NewIdentScreenName("me"),
@@ -657,7 +657,7 @@ func Test_buddyNotifier_BroadcastVisibility(t *testing.T) {
 			name:        "don't send departure notifications",
 			userSession: newTestSession("me"),
 			mockParams: mockParams{
-				buddyIconManagerParams: buddyIconManagerParams{
+				bartItemManagerParams: bartItemManagerParams{
 					buddyIconMetadataParams: buddyIconMetadataParams{
 						{
 							screenName: state.NewIdentScreenName("me"),
@@ -758,7 +758,7 @@ func Test_buddyNotifier_BroadcastVisibility(t *testing.T) {
 			name:        "users have buddy icons",
 			userSession: newTestSession("me"),
 			mockParams: mockParams{
-				buddyIconManagerParams: buddyIconManagerParams{
+				bartItemManagerParams: bartItemManagerParams{
 					buddyIconMetadataParams: buddyIconMetadataParams{
 						{
 							screenName: state.NewIdentScreenName("me"),
@@ -850,9 +850,9 @@ func Test_buddyNotifier_BroadcastVisibility(t *testing.T) {
 					AllRelationships(matchContext(), params.screenName, params.filter).
 					Return(params.result, params.err)
 			}
-			buddyIconManager := newMockBuddyIconManager(t)
+			bartItemManager := newMockBARTItemManager(t)
 			for _, params := range tc.mockParams.buddyIconMetadataParams {
-				buddyIconManager.EXPECT().
+				bartItemManager.EXPECT().
 					BuddyIconMetadata(matchContext(), params.screenName).
 					Return(params.result, params.err)
 			}
@@ -869,7 +869,7 @@ func Test_buddyNotifier_BroadcastVisibility(t *testing.T) {
 			}
 
 			svc := buddyNotifier{
-				buddyIconManager:    buddyIconManager,
+				bartItemManager:     bartItemManager,
 				relationshipFetcher: relationshipFetcher,
 				messageRelayer:      messageRelayer,
 				sessionRetriever:    sessionRetriever,

+ 42 - 25
foodgroup/chat.go

@@ -85,7 +85,7 @@ func (s ChatService) ChannelMsgToHost(ctx context.Context, sess *state.Session,
 			Body:  bodyOut,
 		})
 	} else {
-		// forward  message all participants, except sender
+		// forward message all participants, except sender
 		s.chatMessageRelayer.RelayToAllExcept(ctx, sess.ChatRoomCookie(), sess.IdentScreenName(), wire.SNACMessage{
 			Frame: frameOut,
 			Body:  bodyOut,
@@ -116,32 +116,53 @@ func (s ChatService) transformChatMessage(inBody wire.SNAC_0x0E_0x05_ChatChannel
 	if !hasMessage {
 		return wire.TLVRestBlock{}, errors.New("SNAC(0x0E,0x05) does not contain a message TLV")
 	}
-	messageText, err := textFromChatMsgBlob(messageBlob)
-	if err != nil {
+
+	restBlock := wire.TLVRestBlock{}
+	if err := wire.UnmarshalBE(&restBlock, bytes.NewBuffer(messageBlob)); err != nil {
 		return wire.TLVRestBlock{}, err
 	}
 
-	newMessageBlock := func(sess *state.Session, msg any) wire.TLVRestBlock {
-		block := wire.TLVRestBlock{}
-		// the order of these TLVs matters for AIM 2.x. if out of order, screen
-		// names do not appear with each chat message.
-		block.Append(wire.NewTLVBE(wire.ChatTLVSenderInformation, sess.TLVUserInfo()))
-		if inBody.HasTag(wire.ChatTLVPublicWhisperFlag) {
-			// send message to all chat room participants
-			block.Append(wire.NewTLVBE(wire.ChatTLVPublicWhisperFlag, []byte{}))
-		}
-		block.Append(wire.NewTLVBE(wire.ChatTLVMessageInfo, msg))
-		return block
+	txt, err := extractChatMessage(restBlock)
+	if err != nil {
+		return wire.TLVRestBlock{}, err
 	}
 
-	if doRoll, dice, sides := parseDiceCommand(messageText); doRoll {
+	if doRoll, dice, sides := parseDiceCommand(txt); doRoll {
 		payload := s.rollDice(sender, dice, sides)
 		// send die roll results from OnlineHost user
-		return newMessageBlock(sessOnlineHost, payload), nil
+		return newChatTLVBlock(inBody, sessOnlineHost, payload), nil
+	}
+
+	newRestBlock := wire.TLVRestBlock{}
+
+	// Strip down to the essential TLVs for cross-client compatibility.
+	// Some newer clients include extra metadata that cause older clients
+	// to crash when they encounter unfamiliar TLVs. For example, chat messages
+	// sent by Windows AIM 5.9 will cause macOS AIM 2.x to crash. Rather than
+	// implement complex per-client filtering, we simply preserve only the three
+	// TLVs that every client expects.
+	for _, tlv := range restBlock.TLVList {
+		if tlv.Tag == wire.ChatTLVMessageInfoText ||
+			tlv.Tag == wire.ChatTLVMessageInfoEncoding ||
+			tlv.Tag == wire.ChatTLVMessageInfoLang {
+			newRestBlock.TLVList = append(newRestBlock.TLVList, tlv)
+		}
 	}
 
-	// return the incoming payload without modification
-	return newMessageBlock(sender, messageBlob), nil
+	return newChatTLVBlock(inBody, sender, newRestBlock), nil
+}
+
+func newChatTLVBlock(body wire.SNAC_0x0E_0x05_ChatChannelMsgToHost, sess *state.Session, msg any) wire.TLVRestBlock {
+	block := wire.TLVRestBlock{}
+	// the order of these TLVs matters for AIM 2.x. if out of order, screen
+	// names do not appear with each chat message.
+	block.Append(wire.NewTLVBE(wire.ChatTLVSenderInformation, sess.TLVUserInfo()))
+	if body.HasTag(wire.ChatTLVPublicWhisperFlag) {
+		// send message to all chat room participants
+		block.Append(wire.NewTLVBE(wire.ChatTLVPublicWhisperFlag, []byte{}))
+	}
+	block.Append(wire.NewTLVBE(wire.ChatTLVMessageInfo, msg))
+	return block
 }
 
 // rollDice generates a chat response for the results of a die roll.
@@ -161,15 +182,11 @@ func (s ChatService) rollDice(sess *state.Session, dice int, sides int) wire.TLV
 	return block
 }
 
-// textFromChatMsgBlob extracts plaintext message text from HTML located in
+// extractChatMessage extracts plaintext message text from HTML located in
 // chat message info TLV(0x05).
-func textFromChatMsgBlob(msg []byte) ([]byte, error) {
-	block := wire.TLVRestBlock{}
-	if err := wire.UnmarshalBE(&block, bytes.NewBuffer(msg)); err != nil {
-		return nil, err
-	}
+func extractChatMessage(msg wire.TLVRestBlock) ([]byte, error) {
 
-	b, hasMsg := block.Bytes(wire.ChatTLVMessageInfoText)
+	b, hasMsg := msg.Bytes(wire.ChatTLVMessageInfoText)
 	if !hasMsg {
 		return nil, errors.New("SNAC(0x0E,0x05) has no chat msg text TLV")
 	}

+ 63 - 0
foodgroup/chat_test.go

@@ -402,6 +402,69 @@ func TestChatService_ChannelMsgToHost(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "send chat room message, expect that TLVs that crash macos client",
+			userSession: newTestSession("user_sending_chat_msg", sessOptCannedSignonTime,
+				sessOptChatRoomCookie("the-chat-cookie")),
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
+					Cookie:  1234,
+					Channel: 14,
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: wire.TLVList{
+							{
+								Tag:   wire.ChatTLVPublicWhisperFlag,
+								Value: []byte{},
+							},
+							wire.NewTLVBE(wire.ChatTLVMessageInfo, wire.TLVRestBlock{
+								TLVList: wire.TLVList{
+									wire.NewTLVBE(wire.ChatTLVMessageInfoText,
+										"<HTML><BODY BGCOLOR=\"#ffffff\"><FONT LANG=\"0\">Hello</FONT></BODY></HTML>"),
+									wire.NewTLVBE(0x04, "remove me"),
+									wire.NewTLVBE(0x05, "remove me"),
+								},
+							}),
+						},
+					},
+				},
+			},
+			mockParams: mockParams{
+				chatMessageRelayerParams: chatMessageRelayerParams{
+					chatRelayToAllExceptParams: chatRelayToAllExceptParams{
+						{
+							screenName: state.NewIdentScreenName("user_sending_chat_msg"),
+							cookie:     "the-chat-cookie",
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Chat,
+									SubGroup:  wire.ChatChannelMsgToClient,
+								},
+								Body: wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{
+									Cookie:  1234,
+									Channel: 14,
+									TLVRestBlock: wire.TLVRestBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLVBE(wire.ChatTLVSenderInformation,
+												newTestSession("user_sending_chat_msg", sessOptCannedSignonTime).TLVUserInfo()),
+											wire.NewTLVBE(wire.ChatTLVPublicWhisperFlag, []byte{}),
+											wire.NewTLVBE(wire.ChatTLVMessageInfo, wire.TLVRestBlock{
+												TLVList: wire.TLVList{
+													wire.NewTLVBE(wire.ChatTLVMessageInfoText,
+														"<HTML><BODY BGCOLOR=\"#ffffff\"><FONT LANG=\"0\">Hello</FONT></BODY></HTML>"),
+												},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
 	}
 
 	for _, tc := range cases {

+ 7 - 7
foodgroup/feedbag.go

@@ -17,13 +17,13 @@ func NewFeedbagService(
 	logger *slog.Logger,
 	messageRelayer MessageRelayer,
 	feedbagManager FeedbagManager,
-	buddyIconManager BuddyIconManager,
+	bartItemManager BARTItemManager,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
 ) FeedbagService {
 	return FeedbagService{
-		buddyIconManager: buddyIconManager,
-		buddyBroadcaster: newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
+		bartItemManager:  bartItemManager,
+		buddyBroadcaster: newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		feedbagManager:   feedbagManager,
 		logger:           logger,
 		messageRelayer:   messageRelayer,
@@ -33,7 +33,7 @@ func NewFeedbagService(
 // FeedbagService provides functionality for the Feedbag food group, which
 // handles buddy list management.
 type FeedbagService struct {
-	buddyIconManager BuddyIconManager
+	bartItemManager  BARTItemManager
 	buddyBroadcaster buddyBroadcaster
 	feedbagManager   FeedbagManager
 	logger           *slog.Logger
@@ -251,7 +251,7 @@ func (s FeedbagService) broadcastIconUpdate(ctx context.Context, sess *state.Ses
 		s.logger.DebugContext(ctx, "user is clearing icon",
 			"hash", fmt.Sprintf("%x", btlv.Hash))
 		// tell buddies about the icon update
-		return s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess)
+		return s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo())
 	}
 
 	bid := wire.BARTID{
@@ -261,7 +261,7 @@ func (s FeedbagService) broadcastIconUpdate(ctx context.Context, sess *state.Ses
 			Hash:  btlv.Hash,
 		},
 	}
-	if b, err := s.buddyIconManager.BuddyIcon(ctx, btlv.Hash); err != nil {
+	if b, err := s.bartItemManager.BARTItem(ctx, btlv.Hash); err != nil {
 		return err
 	} else if len(b) == 0 {
 		// icon doesn't exist, tell the client to upload buddy icon
@@ -272,7 +272,7 @@ func (s FeedbagService) broadcastIconUpdate(ctx context.Context, sess *state.Ses
 		s.logger.DebugContext(ctx, "icon already exists in BART store, don't upload the icon file",
 			"hash", fmt.Sprintf("%x", btlv.Hash))
 		// tell buddies about the icon update
-		if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+		if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo()); err != nil {
 			return err
 		}
 	}

+ 15 - 12
foodgroup/feedbag_test.go

@@ -768,8 +768,8 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 				},
 			},
 			mockParams: mockParams{
-				buddyIconManagerParams: buddyIconManagerParams{
-					buddyIconManagerRetrieveParams: buddyIconManagerRetrieveParams{
+				bartItemManagerParams: bartItemManagerParams{
+					bartItemManagerRetrieveParams: bartItemManagerRetrieveParams{
 						{
 							itemHash: []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
 							result:   []byte{}, // icon doesn't exist
@@ -852,11 +852,12 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 				},
 			},
 			mockParams: mockParams{
-				buddyIconManagerParams: buddyIconManagerParams{
-					buddyIconManagerRetrieveParams: buddyIconManagerRetrieveParams{
+				bartItemManagerParams: bartItemManagerParams{
+					bartItemManagerRetrieveParams: bartItemManagerRetrieveParams{
 						{
 							itemHash: []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
 							result:   []byte{'i', 'c', 'o', 'n', 'd', 'a', 't', 'a'},
+							err:      nil,
 						},
 					},
 				},
@@ -906,7 +907,7 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("me"),
+							screenName: state.DisplayScreenName("me"),
 						},
 					},
 				},
@@ -967,7 +968,7 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("me"),
+							screenName: state.DisplayScreenName("me"),
 						},
 					},
 				},
@@ -998,16 +999,18 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 				messageRelayer.EXPECT().
 					RelayToScreenName(mock.Anything, params.screenName, params.message)
 			}
-			buddyIconManager := newMockBuddyIconManager(t)
-			for _, params := range tc.mockParams.buddyIconManagerParams.buddyIconManagerRetrieveParams {
-				buddyIconManager.EXPECT().
-					BuddyIcon(matchContext(), params.itemHash).
+			bartItemManager := newMockBARTItemManager(t)
+			for _, params := range tc.mockParams.bartItemManagerParams.bartItemManagerRetrieveParams {
+				bartItemManager.EXPECT().
+					BARTItem(matchContext(), params.itemHash).
 					Return(params.result, nil)
 			}
 			buddyUpdateBroadcaster := newMockbuddyBroadcaster(t)
 			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
 				buddyUpdateBroadcaster.EXPECT().
-					BroadcastBuddyArrived(mock.Anything, matchSession(params.screenName)).
+					BroadcastBuddyArrived(mock.Anything, state.NewIdentScreenName(params.screenName.String()), mock.MatchedBy(func(userInfo wire.TLVUserInfo) bool {
+						return userInfo.ScreenName == params.screenName.String()
+					})).
 					Return(params.err)
 			}
 			for _, params := range tc.mockParams.broadcastVisibilityParams {
@@ -1015,7 +1018,7 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 					BroadcastVisibility(mock.Anything, matchSession(params.from), params.filter, true).
 					Return(params.err)
 			}
-			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, buddyIconManager, nil, nil)
+			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, bartItemManager, nil, nil)
 			svc.buddyBroadcaster = buddyUpdateBroadcaster
 			output, err := svc.UpsertItem(context.Background(), tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x13_0x08_FeedbagInsertItem).Items)

+ 23 - 15
foodgroup/helpers_test.go

@@ -16,7 +16,7 @@ import (
 // in one place for a table test
 type mockParams struct {
 	accountManagerParams
-	buddyIconManagerParams
+	bartItemManagerParams
 	buddyBroadcasterParams
 	relationshipFetcherParams
 	chatMessageRelayerParams
@@ -214,30 +214,32 @@ type setMoreInfoParams []struct {
 	err  error
 }
 
-// buddyIconManagerParams is a helper struct that contains mock parameters for
-// BuddyIconManager methods
-type buddyIconManagerParams struct {
-	buddyIconManagerRetrieveParams
-	buddyIconManagerUpsertParams
+// bartItemManagerParams is a helper struct that contains mock parameters for
+// BARTItemManager methods
+type bartItemManagerParams struct {
+	bartItemManagerRetrieveParams
+	bartItemManagerUpsertParams
 	buddyIconMetadataParams
 }
 
-// buddyIconManagerRetrieveParams is the list of parameters passed at the mock
-// BuddyIconManager.BuddyIcon call site
-type buddyIconManagerRetrieveParams []struct {
+// bartItemManagerRetrieveParams is the list of parameters passed at the mock
+// BARTItemManager.BuddyIcon call site
+type bartItemManagerRetrieveParams []struct {
 	itemHash []byte
 	result   []byte
+	err      error
 }
 
-// buddyIconManagerUpsertParams is the list of parameters passed at the mock
-// BuddyIconManager.SetBuddyIcon call site
-type buddyIconManagerUpsertParams []struct {
+// bartItemManagerUpsertParams is the list of parameters passed at the mock
+// BARTItemManager.SetBuddyIcon call site
+type bartItemManagerUpsertParams []struct {
 	itemHash []byte
 	payload  []byte
+	bartType uint16
 }
 
 // buddyIconMetadataParams is the list of parameters passed at the mock
-// BuddyIconManager.BuddyIconMetadata call site
+// BARTItemManager.BuddyIconMetadata call site
 type buddyIconMetadataParams []struct {
 	screenName state.IdentScreenName
 	result     *wire.BARTID
@@ -675,7 +677,7 @@ type broadcastVisibilityParams []struct {
 // broadcastBuddyArrivedParams is the list of parameters passed at the mock
 // buddyBroadcaster.BroadcastBuddyArrived call site
 type broadcastBuddyArrivedParams []struct {
-	screenName state.IdentScreenName
+	screenName state.DisplayScreenName
 	err        error
 }
 
@@ -721,7 +723,7 @@ type createChatRoomParams []struct {
 // sessOptWarning sets a warning level on the session object
 func sessOptWarning(level int16) func(session *state.Session) {
 	return func(session *state.Session) {
-		session.IncrementWarning(level, 1)
+		session.SetWarning(uint16(level))
 	}
 }
 
@@ -744,6 +746,12 @@ func sessOptChatRoomCookie(cookie string) func(session *state.Session) {
 	}
 }
 
+// sessOptBot sets the bot flag to true on the session
+// object
+func sessOptBot(session *state.Session) {
+	session.SetUserInfoFlag(wire.OServiceUserFlagBot)
+}
+
 // sessOptInvisible sets the invisible flag to true on the session
 // object
 func sessOptInvisible(session *state.Session) {

+ 173 - 79
foodgroup/icbm.go

@@ -5,6 +5,7 @@ import (
 	"context"
 	"fmt"
 	"log/slog"
+	"sync"
 	"time"
 
 	"github.com/patrickmn/go-cache"
@@ -21,19 +22,21 @@ const (
 
 // NewICBMService returns a new instance of ICBMService.
 func NewICBMService(
-	buddyIconManager BuddyIconManager,
+	bartItemManager BARTItemManager,
 	messageRelayer MessageRelayer,
 	offlineMessageSaver OfflineMessageManager,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
+	userManager UserManager,
 	snacRateLimits wire.SNACRateLimits,
 	logger *slog.Logger,
 ) *ICBMService {
 	return &ICBMService{
 		relationshipFetcher: relationshipFetcher,
-		buddyBroadcaster:    newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
+		buddyBroadcaster:    newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		messageRelayer:      messageRelayer,
 		offlineMessageSaver: offlineMessageSaver,
+		userManager:         userManager,
 		timeNow:             time.Now,
 		sessionRetriever:    sessionRetriever,
 		snacRateLimits:      snacRateLimits,
@@ -51,6 +54,7 @@ type ICBMService struct {
 	buddyBroadcaster    buddyBroadcaster
 	messageRelayer      MessageRelayer
 	offlineMessageSaver OfflineMessageManager
+	userManager         UserManager
 	timeNow             func() time.Time
 	sessionRetriever    SessionRetriever
 	snacRateLimits      wire.SNACRateLimits
@@ -232,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,
@@ -245,6 +250,7 @@ func (s ICBMService) ClientEvent(ctx context.Context, sess *state.Session, inFra
 				Event:      inBody.Event,
 			},
 		})
+
 		return nil
 	}
 }
@@ -280,16 +286,7 @@ func (s ICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFra
 	// don't let users warn themselves, it causes the AIM client to go into a
 	// weird state.
 	if identScreenName == sess.IdentScreenName() {
-		return wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.ICBM,
-				SubGroup:  wire.ICBMErr,
-				RequestID: inFrame.RequestID,
-			},
-			Body: wire.SNACError{
-				Code: wire.ErrorCodeNotSupportedByHost,
-			},
-		}, nil
+		return *newICBMErr(inFrame.RequestID, wire.ErrorCodeNotSupportedByHost), nil
 	}
 
 	blocked, err := s.relationshipFetcher.Relationship(ctx, sess.IdentScreenName(), identScreenName)
@@ -297,44 +294,26 @@ func (s ICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFra
 		return wire.SNACMessage{}, err
 	}
 	if blocked.BlocksYou || blocked.YouBlock {
-		return wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.ICBM,
-				SubGroup:  wire.ICBMErr,
-				RequestID: inFrame.RequestID,
-			},
-			Body: wire.SNACError{
-				Code: wire.ErrorCodeNotLoggedOn,
-			},
-		}, nil
+		// user or target is blocked
+		return *newICBMErr(inFrame.RequestID, wire.ErrorCodeNotLoggedOn), nil
 	}
 
 	recipSess := s.sessionRetriever.RetrieveSession(identScreenName)
 	if recipSess == nil {
-		return wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.ICBM,
-				SubGroup:  wire.ICBMErr,
-				RequestID: inFrame.RequestID,
-			},
-			Body: wire.SNACError{
-				Code: wire.ErrorCodeNotLoggedOn,
-			},
-		}, nil
+		// target user is offline
+		return *newICBMErr(inFrame.RequestID, wire.ErrorCodeNotLoggedOn), nil
+	}
+
+	if recipSess.UserInfoBitmask()&wire.OServiceUserFlagBot == wire.OServiceUserFlagBot {
+		// target user is a bot, bots can't be warned
+		return *newICBMErr(inFrame.RequestID, wire.ErrorCodeRequestDenied), nil
 	}
 
 	canWarn := s.convoTracker.trackWarn(time.Now(), sess.IdentScreenName(), recipSess.IdentScreenName())
 	if !canWarn {
-		return wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.ICBM,
-				SubGroup:  wire.ICBMErr,
-				RequestID: inFrame.RequestID,
-			},
-			Body: wire.SNACError{
-				Code: wire.ErrorCodeRequestDenied,
-			},
-		}, nil
+		// user has warned target too many times or not enough messages have
+		// been received from target
+		return *newICBMErr(inFrame.RequestID, wire.ErrorCodeRequestDenied), nil
 	}
 
 	increase := evilDelta
@@ -348,22 +327,14 @@ func (s ICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFra
 		panic("failed to retrieve rate class for ICBMChannelMsgToHost")
 	}
 
-	if ok := recipSess.IncrementWarning(int16(increase), classID); !ok {
-		return wire.SNACMessage{
-			Frame: wire.SNACFrame{
-				FoodGroup: wire.ICBM,
-				SubGroup:  wire.ICBMErr,
-				RequestID: inFrame.RequestID,
-			},
-			Body: wire.SNACError{
-				Code: wire.ErrorCodeRequestDenied,
-			},
-		}, nil
+	ok, newLevel := recipSess.IncrementWarning(int16(increase), classID)
+	if !ok {
+		// target's warning is at 100%
+		return *newICBMErr(inFrame.RequestID, wire.ErrorCodeRequestDenied), nil
 	}
-	recipSess.NotifyWarning(ctx)
 
 	notif := wire.SNAC_0x01_0x10_OServiceEvilNotification{
-		NewEvil: recipSess.Warning(),
+		NewEvil: newLevel,
 	}
 
 	// append info about user who sent the warning
@@ -386,11 +357,6 @@ func (s ICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFra
 		Body: notif,
 	})
 
-	// inform the warned user's buddies that their warning level has increased
-	if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, recipSess); err != nil {
-		return wire.SNACMessage{}, err
-	}
-
 	return wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.ICBM,
@@ -399,20 +365,66 @@ func (s ICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFra
 		},
 		Body: wire.SNAC_0x04_0x09_ICBMEvilReply{
 			EvilDeltaApplied: increase,
-			UpdatedEvilValue: recipSess.Warning(),
+			UpdatedEvilValue: newLevel,
 		},
 	}, nil
 }
 
-// DecayWarnLevel gradually reduces a user's warning level over time.
-// It listens for warning notifications and starts a periodic decay process
-// that reduces the warning level by a fixed percentage at regular intervals
-// until the warning level reaches zero. Warning updates are broadcast to
-// users who have this user on their buddy list.
-func (s ICBMService) DecayWarnLevel(ctx context.Context, sess *state.Session) {
+// RestoreWarningLevel restores the warning level from the last stored value at login time,
+// accounting for time passed between logins.
+func (s ICBMService) RestoreWarningLevel(ctx context.Context, sess *state.Session) error {
+	u, err := s.userManager.User(ctx, sess.IdentScreenName())
+	if err != nil {
+		return fmt.Errorf("failed to get user: %w", err)
+	}
+	if u == nil {
+		return state.ErrNoUser
+	}
+
+	if u.LastWarnLevel == 0 {
+		// user had no warning at the end of last session
+		return nil
+	}
+
+	sess.SetWarning(u.LastWarnLevel)
+
+	warnDelta := calcElapsedWarningLevel(u.LastWarnUpdate, s.timeNow(), s.interval)
+
+	// get the rate class for sending IMs, which gets limited when the user gets warned
+	classID, ok := s.snacRateLimits.RateClassLookup(wire.ICBM, wire.ICBMChannelMsgToHost)
+	if !ok {
+		panic("failed to retrieve rate class for ICBMChannelMsgToHost")
+	}
+
+	// increment warning level by the amount of time that has passed since last
+	// login, proportionally increasing the warning level
+	sess.IncrementWarning(warnDelta, classID)
+
+	if sess.Warning() > 0 {
+		s.logger.DebugContext(ctx, "restored warning level with time decay applied since last login",
+			"stored_level", u.LastWarnLevel,
+			"time_since_update", s.timeNow().Sub(u.LastWarnUpdate),
+			"decay_delta", warnDelta,
+			"final_level", sess.Warning(),
+		)
+	} else {
+		s.logger.DebugContext(ctx, "warning level decayed to zero since last login",
+			"stored_level", u.LastWarnLevel,
+			"time_since_update", s.timeNow().Sub(u.LastWarnUpdate),
+			"decay_delta", warnDelta,
+		)
+	}
+
+	return nil
+}
+
+// UpdateWarnLevel periodically updates the warning level relative to time
+// elapsed between warnings.
+func (s ICBMService) UpdateWarnLevel(ctx context.Context, sess *state.Session) {
 	var inProgress bool
 	var ticker *time.Ticker
 	var tickC <-chan time.Time // nil when idle, enables/disables the select case
+	var doReset bool
 
 	stopTicker := func() {
 		if ticker != nil {
@@ -421,44 +433,110 @@ func (s ICBMService) DecayWarnLevel(ctx context.Context, sess *state.Session) {
 		}
 		tickC = nil
 		inProgress = false
+		s.logger.DebugContext(ctx, "warning decay stopped")
 	}
 
-	startTicker := func() {
-		ticker = time.NewTicker(s.interval)
+	startTicker := func(interval time.Duration) {
+		ticker = time.NewTicker(interval)
 		tickC = ticker.C
 		inProgress = true
 		s.logger.DebugContext(ctx, "warning decay started")
 	}
 
+	if sess.Warning() > 0 {
+		u, err := s.userManager.User(ctx, sess.IdentScreenName())
+		if err != nil {
+			s.logger.ErrorContext(ctx, "failed to get user", "err", err)
+			return
+		}
+		newInterval := timeTillNextInterval(u.LastWarnUpdate, s.timeNow(), s.interval)
+		interval := s.interval
+		if newInterval > 0 {
+			interval = newInterval
+		}
+		s.logger.DebugContext(ctx, "starting warning level update with interval adjusted to next boundary",
+			"user", sess.IdentScreenName(),
+			"adjusted_interval", interval,
+			"default_interval", s.interval,
+			"time_since_last_update", s.timeNow().Sub(u.LastWarnUpdate),
+		)
+		startTicker(interval)
+		doReset = true
+	}
+
 	// get the rate class for sending IMs, which gets limited when the user gets warned
 	classID, ok := s.snacRateLimits.RateClassLookup(wire.ICBM, wire.ICBMChannelMsgToHost)
 	if !ok {
 		panic("failed to retrieve rate class for ICBMChannelMsgToHost")
 	}
 
+	warnCh := make(chan struct{}, 1)
+
+	var wg sync.WaitGroup
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		defer close(warnCh)
+		for {
+			select {
+			case <-sess.Closed():
+				return
+			case <-ctx.Done():
+				return
+			case warning := <-sess.WarningCh():
+				if warning > 0 {
+					warnCh <- struct{}{}
+				}
+				if err := s.userManager.SetWarnLevel(ctx, sess.IdentScreenName(), s.timeNow(), warning); err != nil {
+					s.logger.ErrorContext(ctx, "failed to set warn level", "err", err)
+				}
+
+				info := sess.TLVUserInfo()
+				// lock in the current warning level to avoid race conditions
+				// where the warning level might change during this broadcast
+				// operation
+				info.WarningLevel = warning
+				if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), info); err != nil {
+					s.logger.ErrorContext(ctx, "BroadcastBuddyArrived failed", "err", err)
+				} else {
+					s.logger.DebugContext(ctx, "warning lowered", "remaining", warning)
+				}
+			}
+		}
+	}()
+
+	defer wg.Wait()
+
 	for {
 		select {
+		case <-sess.Closed():
+			stopTicker()
+			return
 		case <-ctx.Done():
 			stopTicker()
 			return
 
-		case <-sess.WarningCh():
+		case <-warnCh:
 			if inProgress {
 				s.logger.DebugContext(ctx, "warning decay already in progress")
 				continue
 			}
-			startTicker()
+			startTicker(s.interval)
 
 		case <-tickC:
-			sess.IncrementWarning(warningDecayPct, classID)
+			if doReset {
+				ticker.Reset(s.interval)
+				doReset = false
+			}
 
-			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
-				s.logger.ErrorContext(ctx, "BroadcastBuddyArrived failed", "err", err)
-			} else {
-				s.logger.DebugContext(ctx, "warning lowered", "remaining", sess.Warning())
+			ok, warning := sess.IncrementWarning(warningDecayPct, classID)
+			if !ok {
+				s.logger.ErrorContext(ctx, "warning increment out of rage", "level", warning)
+				stopTicker()
+				return
 			}
 
-			if sess.Warning() <= 0 {
+			if warning == 0 {
 				s.logger.DebugContext(ctx, "warning decay complete")
 				stopTicker()
 			}
@@ -466,6 +544,22 @@ func (s ICBMService) DecayWarnLevel(ctx context.Context, sess *state.Session) {
 	}
 }
 
+func calcElapsedWarningLevel(lastWarnUpdate time.Time, now time.Time, interval time.Duration) int16 {
+	// time passed since last signoff
+	since := now.Sub(lastWarnUpdate)
+
+	// how many times warning decayed since last signoff
+	decayPeriods := int(since / interval)
+	// total amount warning decreased since last signoff
+	warnDelta := decayPeriods * warningDecayPct
+
+	return int16(warnDelta)
+}
+
+func timeTillNextInterval(lastWarned time.Time, now time.Time, interval time.Duration) time.Duration {
+	return interval - (now.Sub(lastWarned) % interval)
+}
+
 // convoTracker keeps track of messages initiated from a sender to a recipient.
 // A user (the warner) can only warn another user (the warnee) only if the
 // warner has received a message from the warnee. The warner may only warn 1
@@ -563,5 +657,5 @@ func (r *ringBuffer) val() time.Time {
 // set stores the given time at the current cursor position and advances the cursor.
 func (r *ringBuffer) set(v time.Time) {
 	r.vals[r.cur] = v
-	r.cur = (r.cur + 1) % 3
+	r.cur = (r.cur + 1) % len(r.vals)
 }

+ 307 - 62
foodgroup/icbm_test.go

@@ -2,6 +2,7 @@ package foodgroup
 
 import (
 	"context"
+	"errors"
 	"log/slog"
 	"net"
 	"net/netip"
@@ -979,13 +980,6 @@ func TestICBMService_EvilRequest(t *testing.T) {
 				},
 			},
 			mockParams: mockParams{
-				buddyBroadcasterParams: buddyBroadcasterParams{
-					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
-						{
-							screenName: state.NewIdentScreenName("recipient-screen-name"),
-						},
-					},
-				},
 				relationshipFetcherParams: relationshipFetcherParams{
 					relationshipParams: relationshipParams{
 						{
@@ -1053,13 +1047,6 @@ func TestICBMService_EvilRequest(t *testing.T) {
 				},
 			},
 			mockParams: mockParams{
-				buddyBroadcasterParams: buddyBroadcasterParams{
-					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
-						{
-							screenName: state.NewIdentScreenName("recipient-screen-name"),
-						},
-					},
-				},
 				relationshipFetcherParams: relationshipFetcherParams{
 					relationshipParams: relationshipParams{
 						{
@@ -1192,6 +1179,55 @@ func TestICBMService_EvilRequest(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:          "can't warn bots",
+			senderSession: newTestSession("sender-screen-name"),
+			msgsReceived:  1,
+			inputSNAC: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					RequestID: 1234,
+				},
+				Body: wire.SNAC_0x04_0x08_ICBMEvilRequest{
+					SendAs:     0, // make it identified
+					ScreenName: "recipient-screen-name",
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.ICBM,
+					SubGroup:  wire.ICBMErr,
+					RequestID: 1234,
+				},
+				Body: wire.SNACError{
+					Code: wire.ErrorCodeRequestDenied,
+				},
+			},
+			mockParams: mockParams{
+				relationshipFetcherParams: relationshipFetcherParams{
+					relationshipParams: relationshipParams{
+						{
+							me:   state.NewIdentScreenName("sender-screen-name"),
+							them: state.NewIdentScreenName("recipient-screen-name"),
+							result: state.Relationship{
+								User:          state.NewIdentScreenName("recipient-screen-name"),
+								BlocksYou:     false,
+								YouBlock:      false,
+								IsOnTheirList: false,
+								IsOnYourList:  false,
+							},
+						},
+					},
+				},
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionParams{
+						{
+							screenName: state.NewIdentScreenName("recipient-screen-name"),
+							result:     newTestSession("recipient-screen-name", sessOptBot),
+						},
+					},
+				},
+			},
+		},
 		{
 			name:          "don't let users warn themselves",
 			senderSession: newTestSession("sender-screen-name"),
@@ -1318,12 +1354,6 @@ func TestICBMService_EvilRequest(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			mockBuddyBroadcaster := newMockbuddyBroadcaster(t)
-			for _, item := range tc.mockParams.broadcastBuddyArrivedParams {
-				mockBuddyBroadcaster.EXPECT().
-					BroadcastBuddyArrived(mock.Anything, matchSession(item.screenName)).
-					Return(item.err)
-			}
 			relationshipFetcher := newMockRelationshipFetcher(t)
 			for _, item := range tc.mockParams.relationshipFetcherParams.relationshipParams {
 				relationshipFetcher.EXPECT().
@@ -1349,7 +1379,6 @@ func TestICBMService_EvilRequest(t *testing.T) {
 			}
 
 			svc := ICBMService{
-				buddyBroadcaster:    mockBuddyBroadcaster,
 				relationshipFetcher: relationshipFetcher,
 				messageRelayer:      messageRelayer,
 				offlineMessageSaver: offlineMessageManager,
@@ -1385,7 +1414,7 @@ func TestICBMService_EvilRequest(t *testing.T) {
 }
 
 func TestICBMService_ParameterQuery(t *testing.T) {
-	svc := NewICBMService(nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
+	svc := NewICBMService(nil, nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
 
 	have := svc.ParameterQuery(nil, wire.SNACFrame{RequestID: 1234})
 	want := wire.SNACMessage{
@@ -1437,7 +1466,7 @@ func TestICBMService_ClientErr(t *testing.T) {
 	messageRelayer.EXPECT().
 		RelayToScreenName(mock.Anything, state.NewIdentScreenName("recipientScreenName"), expect)
 
-	svc := NewICBMService(nil, messageRelayer, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
+	svc := NewICBMService(nil, messageRelayer, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), slog.Default())
 
 	err := svc.ClientErr(context.Background(), sess, wire.SNACFrame{RequestID: 1234}, inBody)
 	assert.NoError(t, err)
@@ -1680,25 +1709,48 @@ func TestConvoTracker(t *testing.T) {
 	assert.True(t, ct.trackWarn(now, recip, sender))
 }
 
-func TestICBMService_DecayWarnLevel(t *testing.T) {
+func TestICBMService_UpdateWarnLevel(t *testing.T) {
 
 	t.Run("happy path", func(t *testing.T) {
+		now := time.Now()
 
 		sess := newTestSession("screen-name")
 		warnCh := make(chan uint16)
 
 		mockBuddyBroadcaster := newMockbuddyBroadcaster(t)
 		mockBuddyBroadcaster.EXPECT().
-			BroadcastBuddyArrived(mock.Anything, matchSession(sess.IdentScreenName())).
-			Run(func(ctx context.Context, sess *state.Session) {
-				warnCh <- sess.Warning()
+			BroadcastBuddyArrived(mock.Anything, sess.IdentScreenName(), mock.MatchedBy(func(userInfo wire.TLVUserInfo) bool {
+				return userInfo.ScreenName == sess.IdentScreenName().String()
+			})).
+			Run(func(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) {
+				warnCh <- userInfo.WarningLevel
 			}).Return(nil)
 
+		u := &state.User{}
+		userManager := newMockUserManager(t)
+		userManager.EXPECT().
+			User(matchContext(), sess.IdentScreenName()).
+			Return(u, nil)
+		userManager.EXPECT().
+			SetWarnLevel(matchContext(), sess.IdentScreenName(), now, uint16(100)).
+			Return(nil)
+		userManager.EXPECT().
+			SetWarnLevel(matchContext(), sess.IdentScreenName(), now, uint16(50)).
+			Return(nil)
+		userManager.EXPECT().
+			SetWarnLevel(matchContext(), sess.IdentScreenName(), now, uint16(30)).
+			Return(nil)
+		userManager.EXPECT().
+			SetWarnLevel(matchContext(), sess.IdentScreenName(), now, uint16(0)).
+			Return(nil)
+
 		svc := ICBMService{
 			buddyBroadcaster: mockBuddyBroadcaster,
-			logger:           slog.Default(),
 			interval:         1 * time.Millisecond,
+			logger:           slog.Default(),
 			snacRateLimits:   wire.DefaultSNACRateLimits(),
+			timeNow:          func() time.Time { return now },
+			userManager:      userManager,
 		}
 
 		ctx, cancel := context.WithCancel(context.Background())
@@ -1708,62 +1760,255 @@ func TestICBMService_DecayWarnLevel(t *testing.T) {
 		wg.Add(1)
 		go func() {
 			defer wg.Done()
-			svc.DecayWarnLevel(ctx, sess)
+			svc.UpdateWarnLevel(ctx, sess) // do a sync test here?
 		}()
 
-		sess.IncrementWarning(100, 3)
-		sess.NotifyWarning(ctx)
-
+		ok, _ := sess.IncrementWarning(100, 3)
+		assert.True(t, ok)
+		assert.Equal(t, uint16(100), <-warnCh)
 		assert.Equal(t, uint16(50), <-warnCh)
 		assert.Equal(t, uint16(0), <-warnCh)
 
-		sess.IncrementWarning(50, 3)
-		sess.NotifyWarning(ctx)
-		sess.IncrementWarning(50, 3)
-		sess.NotifyWarning(ctx)
-
+		ok, _ = sess.IncrementWarning(100, 3)
+		assert.True(t, ok)
+		assert.Equal(t, uint16(100), <-warnCh)
 		assert.Equal(t, uint16(50), <-warnCh)
 		assert.Equal(t, uint16(0), <-warnCh)
 
+		sess.IncrementWarning(30, 3)
+		assert.Equal(t, uint16(30), <-warnCh)
+		assert.Equal(t, uint16(0), <-warnCh)
+
 		cancel()
 		wg.Wait()
 	})
+}
+
+func TestICBMService_RestoreWarningLevel(t *testing.T) {
+	tests := []struct {
+		name           string
+		lastWarnUpdate time.Duration
+		lastWarnLevel  uint16
+		expectedWarn   uint16
+	}{
+		{
+			name:           "decays warning when last update is before interval boundary",
+			lastWarnUpdate: -15*time.Millisecond - 1*time.Millisecond,
+			lastWarnLevel:  250,
+			expectedWarn:   100,
+		},
+		{
+			name:           "decays warning when last update is after interval boundary",
+			lastWarnUpdate: -15*time.Millisecond + 1*time.Millisecond,
+			lastWarnLevel:  250,
+			expectedWarn:   150,
+		},
+		{
+			name:           "decays warning when last update is exactly on interval boundary",
+			lastWarnUpdate: -15 * time.Millisecond,
+			lastWarnLevel:  250,
+			expectedWarn:   100,
+		},
+		{
+			name:           "resets warning to zero when time is exactly at decay period",
+			lastWarnUpdate: -25 * time.Millisecond,
+			lastWarnLevel:  250,
+			expectedWarn:   0,
+		},
+		{
+			name:           "resets warning to zero when time far decay period",
+			lastWarnUpdate: -1 * time.Second,
+			lastWarnLevel:  250,
+			expectedWarn:   0,
+		},
+	}
 
-	t.Run("3% burn down clamps to 0", func(t *testing.T) {
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			now := time.Now()
 
+			sess := newTestSession("screen-name")
+
+			u := &state.User{
+				LastWarnUpdate: now.Add(tt.lastWarnUpdate),
+				LastWarnLevel:  tt.lastWarnLevel,
+			}
+			userManager := newMockUserManager(t)
+			userManager.EXPECT().
+				User(matchContext(), sess.IdentScreenName()).
+				Return(u, nil)
+
+			svc := ICBMService{
+				logger:         slog.Default(),
+				interval:       5 * time.Millisecond,
+				snacRateLimits: wire.DefaultSNACRateLimits(),
+				timeNow:        func() time.Time { return now },
+				userManager:    userManager,
+			}
+
+			ctx, cancel := context.WithCancel(context.Background())
+			defer cancel()
+
+			err := svc.RestoreWarningLevel(ctx, sess)
+			assert.NoError(t, err)
+
+			assert.Equal(t, tt.expectedWarn, sess.Warning())
+		})
+	}
+}
+
+func TestICBMService_RestoreWarningLevel_ErrorCases(t *testing.T) {
+	t.Run("user does not exist", func(t *testing.T) {
 		sess := newTestSession("screen-name")
-		warnCh := make(chan uint16)
 
-		mockBuddyBroadcaster := newMockbuddyBroadcaster(t)
-		mockBuddyBroadcaster.EXPECT().
-			BroadcastBuddyArrived(mock.Anything, matchSession(sess.IdentScreenName())).
-			Run(func(ctx context.Context, sess *state.Session) {
-				warnCh <- sess.Warning()
-			}).Return(nil)
+		userManager := newMockUserManager(t)
+		userManager.EXPECT().
+			User(matchContext(), sess.IdentScreenName()).
+			Return(nil, nil)
 
 		svc := ICBMService{
-			buddyBroadcaster: mockBuddyBroadcaster,
-			logger:           slog.Default(),
-			interval:         1 * time.Millisecond,
-			snacRateLimits:   wire.DefaultSNACRateLimits(),
+			logger:         slog.Default(),
+			interval:       5 * time.Millisecond,
+			snacRateLimits: wire.DefaultSNACRateLimits(),
+			timeNow:        time.Now,
+			userManager:    userManager,
 		}
 
-		ctx, cancel := context.WithCancel(context.Background())
-		defer cancel()
+		err := svc.RestoreWarningLevel(context.Background(), sess)
+		assert.ErrorIs(t, err, state.ErrNoUser)
+	})
 
-		var wg sync.WaitGroup
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			svc.DecayWarnLevel(ctx, sess)
-		}()
+	t.Run("user manager returns error", func(t *testing.T) {
+		sess := newTestSession("screen-name")
+		expectedErr := errors.New("database connection failed")
 
-		sess.IncrementWarning(30, 3)
-		sess.NotifyWarning(ctx)
+		userManager := newMockUserManager(t)
+		userManager.EXPECT().
+			User(matchContext(), sess.IdentScreenName()).
+			Return(nil, expectedErr)
 
-		assert.Equal(t, uint16(0), <-warnCh)
+		svc := ICBMService{
+			logger:         slog.Default(),
+			interval:       5 * time.Millisecond,
+			snacRateLimits: wire.DefaultSNACRateLimits(),
+			timeNow:        time.Now,
+			userManager:    userManager,
+		}
 
-		cancel()
-		wg.Wait()
+		err := svc.RestoreWarningLevel(context.Background(), sess)
+		assert.Error(t, err)
+		// When user is nil, it returns ErrNoUser regardless of the error
+		assert.ErrorIs(t, err, expectedErr)
+	})
+
+	t.Run("user exists with zero warning level", func(t *testing.T) {
+		sess := newTestSession("screen-name")
+
+		u := &state.User{
+			LastWarnUpdate: time.Now().Add(-10 * time.Millisecond),
+			LastWarnLevel:  0, // No warning level
+		}
+		userManager := newMockUserManager(t)
+		userManager.EXPECT().
+			User(matchContext(), sess.IdentScreenName()).
+			Return(u, nil)
+
+		svc := ICBMService{
+			logger:         slog.Default(),
+			interval:       5 * time.Millisecond,
+			snacRateLimits: wire.DefaultSNACRateLimits(),
+			timeNow:        time.Now,
+			userManager:    userManager,
+		}
+
+		err := svc.RestoreWarningLevel(context.Background(), sess)
+		assert.NoError(t, err)
+		assert.Equal(t, uint16(0), sess.Warning())
+	})
+}
+
+func Test_calcWarningLevelChange(t *testing.T) {
+
+	t.Run("active warn level, last modified between intervals", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-15 * time.Millisecond).Add(-1 * time.Millisecond)
+		warnDelta := calcElapsedWarningLevel(lastWarn, now, interval)
+		assert.Equal(t, int16(-150), warnDelta)
+	})
+
+	t.Run("active warn level, last modified between intervals", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-15 * time.Millisecond).Add(1 * time.Millisecond)
+		warnDelta := calcElapsedWarningLevel(lastWarn, now, interval)
+		assert.Equal(t, int16(-100), warnDelta)
+	})
+
+	t.Run("active warn level, last modified exactly on interval", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-15 * time.Millisecond)
+		warnDelta := calcElapsedWarningLevel(lastWarn, now, interval)
+		assert.Equal(t, int16(-150), warnDelta)
+	})
+
+	t.Run("resolved warn level", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-25 * time.Millisecond)
+		warnDelta := calcElapsedWarningLevel(lastWarn, now, interval)
+		assert.Equal(t, int16(-250), warnDelta)
+	})
+
+	t.Run("resolved warn level - time past exceeds maximum window", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-200 * time.Millisecond)
+		warnDelta := calcElapsedWarningLevel(lastWarn, now, interval)
+		assert.Equal(t, int16(-2000), warnDelta)
+	})
+}
+
+func Test_calcRefreshInterval(t *testing.T) {
+
+	t.Run("active warn level, last modified between intervals", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-15 * time.Millisecond).Add(-1 * time.Millisecond)
+		newInterval := timeTillNextInterval(lastWarn, now, interval)
+		assert.Equal(t, 4*time.Millisecond, newInterval)
+	})
+
+	t.Run("active warn level, last modified between intervals", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-15 * time.Millisecond).Add(1 * time.Millisecond)
+		newInterval := timeTillNextInterval(lastWarn, now, interval)
+		assert.Equal(t, 1*time.Millisecond, newInterval)
+	})
+
+	t.Run("active warn level, last modified exactly on interval", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-15 * time.Millisecond)
+		newInterval := timeTillNextInterval(lastWarn, now, interval)
+		assert.Equal(t, 5*time.Millisecond, newInterval)
+	})
+
+	t.Run("resolved warn level", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-25 * time.Millisecond)
+		newInterval := timeTillNextInterval(lastWarn, now, interval)
+		assert.Equal(t, interval, newInterval)
+	})
+
+	t.Run("resolved warn level - time past exceeds maximum window", func(t *testing.T) {
+		now := time.Now()
+		interval := 5 * time.Millisecond
+		lastWarn := now.Add(-200 * time.Millisecond)
+		newInterval := timeTillNextInterval(lastWarn, now, interval)
+		assert.Equal(t, interval, newInterval)
 	})
 }

+ 4 - 4
foodgroup/locate.go

@@ -22,14 +22,14 @@ var omitCaps = map[[16]byte]bool{
 
 // NewLocateService creates a new instance of LocateService.
 func NewLocateService(
-	buddyIconManager BuddyIconManager,
+	bartItemManager BARTItemManager,
 	messageRelayer MessageRelayer,
 	profileManager ProfileManager,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
 ) LocateService {
 	return LocateService{
-		buddyBroadcaster:    newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
+		buddyBroadcaster:    newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		relationshipFetcher: relationshipFetcher,
 		profileManager:      profileManager,
 		sessionRetriever:    sessionRetriever,
@@ -84,7 +84,7 @@ func (s LocateService) SetInfo(ctx context.Context, sess *state.Session, inBody
 	if awayMsg, hasAwayMsg := inBody.String(wire.LocateTLVTagsInfoUnavailableData); hasAwayMsg {
 		sess.SetAwayMessage(awayMsg)
 		if sess.SignonComplete() {
-			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo()); err != nil {
 				return err
 			}
 		}
@@ -106,7 +106,7 @@ func (s LocateService) SetInfo(ctx context.Context, sess *state.Session, inBody
 		}
 		sess.SetCaps(caps)
 		if sess.SignonComplete() {
-			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo()); err != nil {
 				return err
 			}
 		}

+ 4 - 2
foodgroup/locate_test.go

@@ -725,7 +725,7 @@ func TestLocateService_SetInfo(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("user_screen_name"),
+							screenName: state.DisplayScreenName("user_screen_name"),
 						},
 					},
 				},
@@ -743,7 +743,9 @@ func TestLocateService_SetInfo(t *testing.T) {
 			buddyUpdateBroadcaster := newMockbuddyBroadcaster(t)
 			for _, params := range tt.mockParams.broadcastBuddyArrivedParams {
 				buddyUpdateBroadcaster.EXPECT().
-					BroadcastBuddyArrived(mock.Anything, matchSession(params.screenName)).
+					BroadcastBuddyArrived(mock.Anything, state.NewIdentScreenName(params.screenName.String()), mock.MatchedBy(func(userInfo wire.TLVUserInfo) bool {
+						return userInfo.ScreenName == params.screenName.String()
+					})).
 					Return(params.err)
 			}
 			svc := NewLocateService(nil, nil, profileManager, nil, nil)

+ 312 - 0
foodgroup/mock_bart_item_manager_test.go

@@ -0,0 +1,312 @@
+// Code generated by mockery v2.53.3. DO NOT EDIT.
+
+package foodgroup
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockBARTItemManager is an autogenerated mock type for the BARTItemManager type
+type mockBARTItemManager struct {
+	mock.Mock
+}
+
+type mockBARTItemManager_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockBARTItemManager) EXPECT() *mockBARTItemManager_Expecter {
+	return &mockBARTItemManager_Expecter{mock: &_m.Mock}
+}
+
+// BARTItem provides a mock function with given fields: ctx, hash
+func (_m *mockBARTItemManager) BARTItem(ctx context.Context, hash []byte) ([]byte, error) {
+	ret := _m.Called(ctx, hash)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BARTItem")
+	}
+
+	var r0 []byte
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, []byte) ([]byte, error)); ok {
+		return rf(ctx, hash)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, []byte) []byte); ok {
+		r0 = rf(ctx, hash)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]byte)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
+		r1 = rf(ctx, hash)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockBARTItemManager_BARTItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BARTItem'
+type mockBARTItemManager_BARTItem_Call struct {
+	*mock.Call
+}
+
+// BARTItem is a helper method to define mock.On call
+//   - ctx context.Context
+//   - hash []byte
+func (_e *mockBARTItemManager_Expecter) BARTItem(ctx interface{}, hash interface{}) *mockBARTItemManager_BARTItem_Call {
+	return &mockBARTItemManager_BARTItem_Call{Call: _e.mock.On("BARTItem", ctx, hash)}
+}
+
+func (_c *mockBARTItemManager_BARTItem_Call) Run(run func(ctx context.Context, hash []byte)) *mockBARTItemManager_BARTItem_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].([]byte))
+	})
+	return _c
+}
+
+func (_c *mockBARTItemManager_BARTItem_Call) Return(_a0 []byte, _a1 error) *mockBARTItemManager_BARTItem_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockBARTItemManager_BARTItem_Call) RunAndReturn(run func(context.Context, []byte) ([]byte, error)) *mockBARTItemManager_BARTItem_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// BuddyIconMetadata provides a mock function with given fields: ctx, screenName
+func (_m *mockBARTItemManager) BuddyIconMetadata(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error) {
+	ret := _m.Called(ctx, screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BuddyIconMetadata")
+	}
+
+	var r0 *wire.BARTID
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) (*wire.BARTID, error)); ok {
+		return rf(ctx, screenName)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) *wire.BARTID); ok {
+		r0 = rf(ctx, screenName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.BARTID)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, state.IdentScreenName) error); ok {
+		r1 = rf(ctx, screenName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockBARTItemManager_BuddyIconMetadata_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BuddyIconMetadata'
+type mockBARTItemManager_BuddyIconMetadata_Call struct {
+	*mock.Call
+}
+
+// BuddyIconMetadata is a helper method to define mock.On call
+//   - ctx context.Context
+//   - screenName state.IdentScreenName
+func (_e *mockBARTItemManager_Expecter) BuddyIconMetadata(ctx interface{}, screenName interface{}) *mockBARTItemManager_BuddyIconMetadata_Call {
+	return &mockBARTItemManager_BuddyIconMetadata_Call{Call: _e.mock.On("BuddyIconMetadata", ctx, screenName)}
+}
+
+func (_c *mockBARTItemManager_BuddyIconMetadata_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName)) *mockBARTItemManager_BuddyIconMetadata_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockBARTItemManager_BuddyIconMetadata_Call) Return(_a0 *wire.BARTID, _a1 error) *mockBARTItemManager_BuddyIconMetadata_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockBARTItemManager_BuddyIconMetadata_Call) RunAndReturn(run func(context.Context, state.IdentScreenName) (*wire.BARTID, error)) *mockBARTItemManager_BuddyIconMetadata_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// DeleteBARTItem provides a mock function with given fields: ctx, hash
+func (_m *mockBARTItemManager) DeleteBARTItem(ctx context.Context, hash []byte) error {
+	ret := _m.Called(ctx, hash)
+
+	if len(ret) == 0 {
+		panic("no return value specified for DeleteBARTItem")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, []byte) error); ok {
+		r0 = rf(ctx, hash)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBARTItemManager_DeleteBARTItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteBARTItem'
+type mockBARTItemManager_DeleteBARTItem_Call struct {
+	*mock.Call
+}
+
+// DeleteBARTItem is a helper method to define mock.On call
+//   - ctx context.Context
+//   - hash []byte
+func (_e *mockBARTItemManager_Expecter) DeleteBARTItem(ctx interface{}, hash interface{}) *mockBARTItemManager_DeleteBARTItem_Call {
+	return &mockBARTItemManager_DeleteBARTItem_Call{Call: _e.mock.On("DeleteBARTItem", ctx, hash)}
+}
+
+func (_c *mockBARTItemManager_DeleteBARTItem_Call) Run(run func(ctx context.Context, hash []byte)) *mockBARTItemManager_DeleteBARTItem_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].([]byte))
+	})
+	return _c
+}
+
+func (_c *mockBARTItemManager_DeleteBARTItem_Call) Return(_a0 error) *mockBARTItemManager_DeleteBARTItem_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBARTItemManager_DeleteBARTItem_Call) RunAndReturn(run func(context.Context, []byte) error) *mockBARTItemManager_DeleteBARTItem_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// InsertBARTItem provides a mock function with given fields: ctx, hash, blob, itemType
+func (_m *mockBARTItemManager) InsertBARTItem(ctx context.Context, hash []byte, blob []byte, itemType uint16) error {
+	ret := _m.Called(ctx, hash, blob, itemType)
+
+	if len(ret) == 0 {
+		panic("no return value specified for InsertBARTItem")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, []byte, []byte, uint16) error); ok {
+		r0 = rf(ctx, hash, blob, itemType)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBARTItemManager_InsertBARTItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InsertBARTItem'
+type mockBARTItemManager_InsertBARTItem_Call struct {
+	*mock.Call
+}
+
+// InsertBARTItem is a helper method to define mock.On call
+//   - ctx context.Context
+//   - hash []byte
+//   - blob []byte
+//   - itemType uint16
+func (_e *mockBARTItemManager_Expecter) InsertBARTItem(ctx interface{}, hash interface{}, blob interface{}, itemType interface{}) *mockBARTItemManager_InsertBARTItem_Call {
+	return &mockBARTItemManager_InsertBARTItem_Call{Call: _e.mock.On("InsertBARTItem", ctx, hash, blob, itemType)}
+}
+
+func (_c *mockBARTItemManager_InsertBARTItem_Call) Run(run func(ctx context.Context, hash []byte, blob []byte, itemType uint16)) *mockBARTItemManager_InsertBARTItem_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].([]byte), args[2].([]byte), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockBARTItemManager_InsertBARTItem_Call) Return(_a0 error) *mockBARTItemManager_InsertBARTItem_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBARTItemManager_InsertBARTItem_Call) RunAndReturn(run func(context.Context, []byte, []byte, uint16) error) *mockBARTItemManager_InsertBARTItem_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// ListBARTItems provides a mock function with given fields: ctx, itemType
+func (_m *mockBARTItemManager) ListBARTItems(ctx context.Context, itemType uint16) ([]state.BARTItem, error) {
+	ret := _m.Called(ctx, itemType)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ListBARTItems")
+	}
+
+	var r0 []state.BARTItem
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, uint16) ([]state.BARTItem, error)); ok {
+		return rf(ctx, itemType)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, uint16) []state.BARTItem); ok {
+		r0 = rf(ctx, itemType)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]state.BARTItem)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, uint16) error); ok {
+		r1 = rf(ctx, itemType)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockBARTItemManager_ListBARTItems_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListBARTItems'
+type mockBARTItemManager_ListBARTItems_Call struct {
+	*mock.Call
+}
+
+// ListBARTItems is a helper method to define mock.On call
+//   - ctx context.Context
+//   - itemType uint16
+func (_e *mockBARTItemManager_Expecter) ListBARTItems(ctx interface{}, itemType interface{}) *mockBARTItemManager_ListBARTItems_Call {
+	return &mockBARTItemManager_ListBARTItems_Call{Call: _e.mock.On("ListBARTItems", ctx, itemType)}
+}
+
+func (_c *mockBARTItemManager_ListBARTItems_Call) Run(run func(ctx context.Context, itemType uint16)) *mockBARTItemManager_ListBARTItems_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockBARTItemManager_ListBARTItems_Call) Return(_a0 []state.BARTItem, _a1 error) *mockBARTItemManager_ListBARTItems_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockBARTItemManager_ListBARTItems_Call) RunAndReturn(run func(context.Context, uint16) ([]state.BARTItem, error)) *mockBARTItemManager_ListBARTItems_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockBARTItemManager creates a new instance of mockBARTItemManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockBARTItemManager(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockBARTItemManager {
+	mock := &mockBARTItemManager{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 14 - 11
foodgroup/mock_buddy_broadcaster_test.go

@@ -7,6 +7,8 @@ import (
 
 	state "github.com/mk6i/retro-aim-server/state"
 	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
 )
 
 // mockbuddyBroadcaster is an autogenerated mock type for the buddyBroadcaster type
@@ -22,17 +24,17 @@ func (_m *mockbuddyBroadcaster) EXPECT() *mockbuddyBroadcaster_Expecter {
 	return &mockbuddyBroadcaster_Expecter{mock: &_m.Mock}
 }
 
-// BroadcastBuddyArrived provides a mock function with given fields: ctx, sess
-func (_m *mockbuddyBroadcaster) BroadcastBuddyArrived(ctx context.Context, sess *state.Session) error {
-	ret := _m.Called(ctx, sess)
+// BroadcastBuddyArrived provides a mock function with given fields: ctx, screenName, userInfo
+func (_m *mockbuddyBroadcaster) BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error {
+	ret := _m.Called(ctx, screenName, userInfo)
 
 	if len(ret) == 0 {
 		panic("no return value specified for BroadcastBuddyArrived")
 	}
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session) error); ok {
-		r0 = rf(ctx, sess)
+	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, wire.TLVUserInfo) error); ok {
+		r0 = rf(ctx, screenName, userInfo)
 	} else {
 		r0 = ret.Error(0)
 	}
@@ -47,14 +49,15 @@ type mockbuddyBroadcaster_BroadcastBuddyArrived_Call struct {
 
 // BroadcastBuddyArrived is a helper method to define mock.On call
 //   - ctx context.Context
-//   - sess *state.Session
-func (_e *mockbuddyBroadcaster_Expecter) BroadcastBuddyArrived(ctx interface{}, sess interface{}) *mockbuddyBroadcaster_BroadcastBuddyArrived_Call {
-	return &mockbuddyBroadcaster_BroadcastBuddyArrived_Call{Call: _e.mock.On("BroadcastBuddyArrived", ctx, sess)}
+//   - screenName state.IdentScreenName
+//   - userInfo wire.TLVUserInfo
+func (_e *mockbuddyBroadcaster_Expecter) BroadcastBuddyArrived(ctx interface{}, screenName interface{}, userInfo interface{}) *mockbuddyBroadcaster_BroadcastBuddyArrived_Call {
+	return &mockbuddyBroadcaster_BroadcastBuddyArrived_Call{Call: _e.mock.On("BroadcastBuddyArrived", ctx, screenName, userInfo)}
 }
 
-func (_c *mockbuddyBroadcaster_BroadcastBuddyArrived_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockbuddyBroadcaster_BroadcastBuddyArrived_Call {
+func (_c *mockbuddyBroadcaster_BroadcastBuddyArrived_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo)) *mockbuddyBroadcaster_BroadcastBuddyArrived_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session))
+		run(args[0].(context.Context), args[1].(state.IdentScreenName), args[2].(wire.TLVUserInfo))
 	})
 	return _c
 }
@@ -64,7 +67,7 @@ func (_c *mockbuddyBroadcaster_BroadcastBuddyArrived_Call) Return(_a0 error) *mo
 	return _c
 }
 
-func (_c *mockbuddyBroadcaster_BroadcastBuddyArrived_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockbuddyBroadcaster_BroadcastBuddyArrived_Call {
+func (_c *mockbuddyBroadcaster_BroadcastBuddyArrived_Call) RunAndReturn(run func(context.Context, state.IdentScreenName, wire.TLVUserInfo) error) *mockbuddyBroadcaster_BroadcastBuddyArrived_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 0 - 205
foodgroup/mock_buddy_icon_manager_test.go

@@ -1,205 +0,0 @@
-// Code generated by mockery v2.53.3. DO NOT EDIT.
-
-package foodgroup
-
-import (
-	context "context"
-
-	state "github.com/mk6i/retro-aim-server/state"
-	mock "github.com/stretchr/testify/mock"
-
-	wire "github.com/mk6i/retro-aim-server/wire"
-)
-
-// mockBuddyIconManager is an autogenerated mock type for the BuddyIconManager type
-type mockBuddyIconManager struct {
-	mock.Mock
-}
-
-type mockBuddyIconManager_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockBuddyIconManager) EXPECT() *mockBuddyIconManager_Expecter {
-	return &mockBuddyIconManager_Expecter{mock: &_m.Mock}
-}
-
-// BuddyIcon provides a mock function with given fields: ctx, md5
-func (_m *mockBuddyIconManager) BuddyIcon(ctx context.Context, md5 []byte) ([]byte, error) {
-	ret := _m.Called(ctx, md5)
-
-	if len(ret) == 0 {
-		panic("no return value specified for BuddyIcon")
-	}
-
-	var r0 []byte
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, []byte) ([]byte, error)); ok {
-		return rf(ctx, md5)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, []byte) []byte); ok {
-		r0 = rf(ctx, md5)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).([]byte)
-		}
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
-		r1 = rf(ctx, md5)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockBuddyIconManager_BuddyIcon_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BuddyIcon'
-type mockBuddyIconManager_BuddyIcon_Call struct {
-	*mock.Call
-}
-
-// BuddyIcon is a helper method to define mock.On call
-//   - ctx context.Context
-//   - md5 []byte
-func (_e *mockBuddyIconManager_Expecter) BuddyIcon(ctx interface{}, md5 interface{}) *mockBuddyIconManager_BuddyIcon_Call {
-	return &mockBuddyIconManager_BuddyIcon_Call{Call: _e.mock.On("BuddyIcon", ctx, md5)}
-}
-
-func (_c *mockBuddyIconManager_BuddyIcon_Call) Run(run func(ctx context.Context, md5 []byte)) *mockBuddyIconManager_BuddyIcon_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].([]byte))
-	})
-	return _c
-}
-
-func (_c *mockBuddyIconManager_BuddyIcon_Call) Return(_a0 []byte, _a1 error) *mockBuddyIconManager_BuddyIcon_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockBuddyIconManager_BuddyIcon_Call) RunAndReturn(run func(context.Context, []byte) ([]byte, error)) *mockBuddyIconManager_BuddyIcon_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// BuddyIconMetadata provides a mock function with given fields: ctx, screenName
-func (_m *mockBuddyIconManager) BuddyIconMetadata(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error) {
-	ret := _m.Called(ctx, screenName)
-
-	if len(ret) == 0 {
-		panic("no return value specified for BuddyIconMetadata")
-	}
-
-	var r0 *wire.BARTID
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) (*wire.BARTID, error)); ok {
-		return rf(ctx, screenName)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) *wire.BARTID); ok {
-		r0 = rf(ctx, screenName)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*wire.BARTID)
-		}
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, state.IdentScreenName) error); ok {
-		r1 = rf(ctx, screenName)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockBuddyIconManager_BuddyIconMetadata_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BuddyIconMetadata'
-type mockBuddyIconManager_BuddyIconMetadata_Call struct {
-	*mock.Call
-}
-
-// BuddyIconMetadata is a helper method to define mock.On call
-//   - ctx context.Context
-//   - screenName state.IdentScreenName
-func (_e *mockBuddyIconManager_Expecter) BuddyIconMetadata(ctx interface{}, screenName interface{}) *mockBuddyIconManager_BuddyIconMetadata_Call {
-	return &mockBuddyIconManager_BuddyIconMetadata_Call{Call: _e.mock.On("BuddyIconMetadata", ctx, screenName)}
-}
-
-func (_c *mockBuddyIconManager_BuddyIconMetadata_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName)) *mockBuddyIconManager_BuddyIconMetadata_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(state.IdentScreenName))
-	})
-	return _c
-}
-
-func (_c *mockBuddyIconManager_BuddyIconMetadata_Call) Return(_a0 *wire.BARTID, _a1 error) *mockBuddyIconManager_BuddyIconMetadata_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockBuddyIconManager_BuddyIconMetadata_Call) RunAndReturn(run func(context.Context, state.IdentScreenName) (*wire.BARTID, error)) *mockBuddyIconManager_BuddyIconMetadata_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// SetBuddyIcon provides a mock function with given fields: ctx, md5, image
-func (_m *mockBuddyIconManager) SetBuddyIcon(ctx context.Context, md5 []byte, image []byte) error {
-	ret := _m.Called(ctx, md5, image)
-
-	if len(ret) == 0 {
-		panic("no return value specified for SetBuddyIcon")
-	}
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, []byte, []byte) error); ok {
-		r0 = rf(ctx, md5, image)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// mockBuddyIconManager_SetBuddyIcon_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetBuddyIcon'
-type mockBuddyIconManager_SetBuddyIcon_Call struct {
-	*mock.Call
-}
-
-// SetBuddyIcon is a helper method to define mock.On call
-//   - ctx context.Context
-//   - md5 []byte
-//   - image []byte
-func (_e *mockBuddyIconManager_Expecter) SetBuddyIcon(ctx interface{}, md5 interface{}, image interface{}) *mockBuddyIconManager_SetBuddyIcon_Call {
-	return &mockBuddyIconManager_SetBuddyIcon_Call{Call: _e.mock.On("SetBuddyIcon", ctx, md5, image)}
-}
-
-func (_c *mockBuddyIconManager_SetBuddyIcon_Call) Run(run func(ctx context.Context, md5 []byte, image []byte)) *mockBuddyIconManager_SetBuddyIcon_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].([]byte), args[2].([]byte))
-	})
-	return _c
-}
-
-func (_c *mockBuddyIconManager_SetBuddyIcon_Call) Return(_a0 error) *mockBuddyIconManager_SetBuddyIcon_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockBuddyIconManager_SetBuddyIcon_Call) RunAndReturn(run func(context.Context, []byte, []byte) error) *mockBuddyIconManager_SetBuddyIcon_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// newMockBuddyIconManager creates a new instance of mockBuddyIconManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func newMockBuddyIconManager(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockBuddyIconManager {
-	mock := &mockBuddyIconManager{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 51 - 0
foodgroup/mock_user_manager_manager_test.go

@@ -7,6 +7,8 @@ import (
 
 	state "github.com/mk6i/retro-aim-server/state"
 	mock "github.com/stretchr/testify/mock"
+
+	time "time"
 )
 
 // mockUserManager is an autogenerated mock type for the UserManager type
@@ -69,6 +71,55 @@ func (_c *mockUserManager_InsertUser_Call) RunAndReturn(run func(context.Context
 	return _c
 }
 
+// SetWarnLevel provides a mock function with given fields: ctx, user, lastWarnUpdate, lastWarnLevel
+func (_m *mockUserManager) SetWarnLevel(ctx context.Context, user state.IdentScreenName, lastWarnUpdate time.Time, lastWarnLevel uint16) error {
+	ret := _m.Called(ctx, user, lastWarnUpdate, lastWarnLevel)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetWarnLevel")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, time.Time, uint16) error); ok {
+		r0 = rf(ctx, user, lastWarnUpdate, lastWarnLevel)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockUserManager_SetWarnLevel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetWarnLevel'
+type mockUserManager_SetWarnLevel_Call struct {
+	*mock.Call
+}
+
+// SetWarnLevel is a helper method to define mock.On call
+//   - ctx context.Context
+//   - user state.IdentScreenName
+//   - lastWarnUpdate time.Time
+//   - lastWarnLevel uint16
+func (_e *mockUserManager_Expecter) SetWarnLevel(ctx interface{}, user interface{}, lastWarnUpdate interface{}, lastWarnLevel interface{}) *mockUserManager_SetWarnLevel_Call {
+	return &mockUserManager_SetWarnLevel_Call{Call: _e.mock.On("SetWarnLevel", ctx, user, lastWarnUpdate, lastWarnLevel)}
+}
+
+func (_c *mockUserManager_SetWarnLevel_Call) Run(run func(ctx context.Context, user state.IdentScreenName, lastWarnUpdate time.Time, lastWarnLevel uint16)) *mockUserManager_SetWarnLevel_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(state.IdentScreenName), args[2].(time.Time), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockUserManager_SetWarnLevel_Call) Return(_a0 error) *mockUserManager_SetWarnLevel_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockUserManager_SetWarnLevel_Call) RunAndReturn(run func(context.Context, state.IdentScreenName, time.Time, uint16) error) *mockUserManager_SetWarnLevel_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // User provides a mock function with given fields: ctx, screenName
 func (_m *mockUserManager) User(ctx context.Context, screenName state.IdentScreenName) (*state.User, error) {
 	ret := _m.Called(ctx, screenName)

+ 4 - 4
foodgroup/oservice.go

@@ -38,7 +38,7 @@ func NewOServiceService(
 	chatRoomManager ChatRoomRegistry,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
-	buddyIconManager BuddyIconManager,
+	bartItemManager BARTItemManager,
 	rateLimitClasses wire.RateLimitClasses,
 	snacRateLimits wire.SNACRateLimits,
 	chatMessageRelayer ChatMessageRelayer,
@@ -46,7 +46,7 @@ func NewOServiceService(
 	return &OServiceService{
 		cookieIssuer:       cookieIssuer,
 		messageRelayer:     messageRelayer,
-		buddyBroadcaster:   newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
+		buddyBroadcaster:   newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		cfg:                cfg,
 		logger:             logger,
 		rateLimitClasses:   rateLimitClasses,
@@ -237,7 +237,7 @@ func (s OServiceService) SetUserInfoFields(ctx context.Context, sess *state.Sess
 				return wire.SNACMessage{}, err
 			}
 		} else {
-			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+			if err := s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo()); err != nil {
 				return wire.SNACMessage{}, err
 			}
 
@@ -262,7 +262,7 @@ func (s OServiceService) IdleNotification(ctx context.Context, sess *state.Sessi
 	} else {
 		sess.SetIdle(time.Duration(bodyIn.IdleTime) * time.Second)
 	}
-	return s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess)
+	return s.buddyBroadcaster.BroadcastBuddyArrived(ctx, sess.IdentScreenName(), sess.TLVUserInfo())
 }
 
 // SetPrivacyFlags sets client privacy settings. Currently, there's no action

+ 10 - 6
foodgroup/oservice_test.go

@@ -873,7 +873,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("me"),
+							screenName: state.DisplayScreenName("me"),
 						},
 					},
 				},
@@ -943,7 +943,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("me"),
+							screenName: state.DisplayScreenName("me"),
 						},
 					},
 				},
@@ -989,7 +989,9 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 			buddyUpdateBroadcaster := newMockbuddyBroadcaster(t)
 			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
 				buddyUpdateBroadcaster.EXPECT().
-					BroadcastBuddyArrived(mock.Anything, matchSession(params.screenName)).
+					BroadcastBuddyArrived(mock.Anything, state.NewIdentScreenName(params.screenName.String()), mock.MatchedBy(func(userInfo wire.TLVUserInfo) bool {
+						return userInfo.ScreenName == params.screenName.String()
+					})).
 					Return(params.err)
 			}
 			for _, params := range tc.mockParams.broadcastBuddyDepartedParams {
@@ -1805,7 +1807,7 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("me"),
+							screenName: state.DisplayScreenName("me"),
 						},
 					},
 				},
@@ -1821,7 +1823,7 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 				buddyBroadcasterParams: buddyBroadcasterParams{
 					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenName: state.NewIdentScreenName("me"),
+							screenName: state.DisplayScreenName("me"),
 						},
 					},
 				},
@@ -1833,7 +1835,9 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 			buddyUpdateBroadcaster := newMockbuddyBroadcaster(t)
 			for _, params := range tt.mockParams.broadcastBuddyArrivedParams {
 				buddyUpdateBroadcaster.EXPECT().
-					BroadcastBuddyArrived(mock.Anything, matchSession(params.screenName)).
+					BroadcastBuddyArrived(mock.Anything, state.NewIdentScreenName(params.screenName.String()), mock.MatchedBy(func(userInfo wire.TLVUserInfo) bool {
+						return userInfo.ScreenName == params.screenName.String()
+					})).
 					Return(params.err)
 			}
 			svc := OServiceService{

+ 2 - 2
foodgroup/permit_deny.go

@@ -9,14 +9,14 @@ import (
 
 // NewPermitDenyService creates an instance of PermitDenyService.
 func NewPermitDenyService(
-	buddyIconManager BuddyIconManager,
+	bartItemManager BARTItemManager,
 	relationshipFetcher RelationshipFetcher,
 	clientSideBuddyListManager ClientSideBuddyListManager,
 	messageRelayer MessageRelayer,
 	sessionRetriever SessionRetriever,
 ) PermitDenyService {
 	return PermitDenyService{
-		buddyBroadcaster:           newBuddyNotifier(buddyIconManager, relationshipFetcher, messageRelayer, sessionRetriever),
+		buddyBroadcaster:           newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		clientSideBuddyListManager: clientSideBuddyListManager,
 	}
 }

+ 16 - 7
foodgroup/types.go

@@ -87,7 +87,7 @@ type AccountManager interface {
 // or changes visibility status.
 type buddyBroadcaster interface {
 	// BroadcastBuddyArrived notifies all relevant users that the given user has come online.
-	BroadcastBuddyArrived(ctx context.Context, sess *state.Session) error
+	BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error
 
 	// BroadcastBuddyDeparted notifies all relevant users that the given user has gone offline.
 	BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error
@@ -97,17 +97,23 @@ type buddyBroadcaster interface {
 	BroadcastVisibility(ctx context.Context, you *state.Session, filter []state.IdentScreenName, sendDepartures bool) error
 }
 
-// BuddyIconManager is the interface for managing AIM buddy icons.
-type BuddyIconManager interface {
-	// BuddyIcon retrieves a buddy icon image by its md5 hash.
-	BuddyIcon(ctx context.Context, md5 []byte) ([]byte, error)
+// BARTItemManager is the interface for managing BART (Buddy Art) assets.
+type BARTItemManager interface {
+	// BARTItem retrieves a BART asset by its hash.
+	BARTItem(ctx context.Context, hash []byte) ([]byte, error)
 
 	// BuddyIconMetadata retrieves a user's buddy icon metadata. It returns nil
 	// if the user does not have a buddy icon.
 	BuddyIconMetadata(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error)
 
-	// SetBuddyIcon creates or updates a buddy icon image and image hash.
-	SetBuddyIcon(ctx context.Context, md5 []byte, image []byte) error
+	// InsertBARTItem creates or updates a BART asset and blob hash.
+	InsertBARTItem(ctx context.Context, hash []byte, blob []byte, itemType uint16) error
+
+	// ListBARTItems returns BART assets filtered by type.
+	ListBARTItems(ctx context.Context, itemType uint16) ([]state.BARTItem, error)
+
+	// DeleteBARTItem deletes a BART asset by hash.
+	DeleteBARTItem(ctx context.Context, hash []byte) error
 }
 
 // RelationshipFetcher is the interface for retrieving relationships between users.
@@ -365,4 +371,7 @@ type UserManager interface {
 
 	// User returns the user record associated with the given screen name.
 	User(ctx context.Context, screenName state.IdentScreenName) (*state.User, error)
+
+	// SetWarnLevel updates the last warn update time and warning level for a user.
+	SetWarnLevel(ctx context.Context, user state.IdentScreenName, lastWarnUpdate time.Time, lastWarnLevel uint16) error
 }

+ 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=

+ 482 - 0
scripts/import_bart.sh

@@ -0,0 +1,482 @@
+#!/bin/bash
+
+# BART Import Script for Retro AIM Server
+# This script imports BART (Buddy ART) assets from an AIM client's bartcache
+# directory (usually found under %APPDATA%\acccore\caches\bart) into Retro AIM
+# Server via the management API.
+# 
+# Compatible with macOS and Linux terminals
+
+set -e
+
+# Ensure we're using bash and have proper error handling
+if [ -z "$BASH_VERSION" ]; then
+    echo "Error: This script requires bash" >&2
+    exit 1
+fi
+
+# Default values
+API_BASE_URL="http://localhost:8080"
+VERBOSE=false
+DRY_RUN=false
+BART_TYPE=""
+TARGET_DIRS=()
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+usage() {
+    echo "Usage: $0 [OPTIONS] -t <type> <directory_path> [directory_path...]"
+    echo ""
+    echo "Import BART assets from bartcache directories into Retro AIM Server"
+    echo ""
+    echo "Arguments:"
+    echo "  directory_path    Path to bartcache directory containing BART assets"
+    echo "                   Directory should contain subdirectories named by BART type (0, 1, 2, etc.)"
+    echo "                   Each type directory should contain files named by their hash"
+    echo "                   Multiple directories can be specified for bulk import"
+    echo ""
+    echo "Options:"
+    echo "  -t, --type TYPE   BART type to import (required)"
+    echo "                   Valid types: buddy_icon_small, buddy_icon, status_str, arrive_sound,"
+    echo "                   rich_text, superbuddy_icon, radio_station, buddy_icon_big,"
+    echo "                   status_str_tod, current_av_track, depart_sound, im_chrome,"
+    echo "                   im_sound, im_chrome_xml, im_chrome_immers, emoticon_set,"
+    echo "                   encr_cert_chain, sign_cert_chain, gateway_cert"
+    echo "  -u, --url URL     API base URL (default: http://localhost:8080)"
+    echo "  -v, --verbose     Enable verbose output"
+    echo "  -d, --dry-run     Show what would be uploaded without actually uploading"
+    echo "  -h, --help        Show this help message"
+    echo ""
+    echo "Examples:"
+    echo "  $0 -t buddy_icon /Users/mike/Downloads/aim\\ barts/eigdbvye/bartcache"
+    echo "  $0 --type status_str --verbose --dry-run /path/to/bartcache"
+    echo "  $0 -t arrive_sound /path/to/bartcache/*"
+}
+
+log_info() {
+    echo -e "${BLUE}[INFO]${NC} $1"
+}
+
+log_success() {
+    echo -e "${GREEN}[SUCCESS]${NC} $1"
+}
+
+log_warning() {
+    echo -e "${YELLOW}[WARNING]${NC} $1"
+}
+
+log_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+log_verbose() {
+    if [ "$VERBOSE" = true ]; then
+        echo -e "${BLUE}[VERBOSE]${NC} $1"
+    fi
+}
+
+command_exists() {
+    command -v "$1" >/dev/null 2>&1
+}
+
+is_hex_string() {
+    local string="$1"
+    # Check if string contains only hexadecimal characters (0-9, a-f, A-F)
+    if [[ "$string" =~ ^[0-9a-fA-F]+$ ]]; then
+        return 0
+    else
+        return 1
+    fi
+}
+
+# Normalize path for cross-platform compatibility
+normalize_path() {
+    local path="$1"
+    # Remove trailing slashes and normalize the path
+    echo "$path" | sed 's|/*$||'
+}
+
+get_bart_type_number() {
+    case "$1" in
+        "buddy_icon_small") echo "0" ;;
+        "buddy_icon") echo "1" ;;
+        "status_str") echo "2" ;;
+        "arrive_sound") echo "3" ;;
+        "rich_text") echo "4" ;;
+        "superbuddy_icon") echo "5" ;;
+        "radio_station") echo "6" ;;
+        "buddy_icon_big") echo "12" ;;
+        "status_str_tod") echo "13" ;;
+        "current_av_track") echo "15" ;;
+        "depart_sound") echo "96" ;;
+        "im_chrome") echo "129" ;;
+        "im_sound") echo "131" ;;
+        "im_chrome_xml") echo "136" ;;
+        "im_chrome_immers") echo "137" ;;
+        "emoticon_set") echo "1024" ;;
+        "encr_cert_chain") echo "1026" ;;
+        "sign_cert_chain") echo "1027" ;;
+        "gateway_cert") echo "1028" ;;
+        *) echo "" ;;
+    esac
+}
+
+get_bart_type_name() {
+    case "$1" in
+        "0") echo "buddy_icon_small" ;;
+        "1") echo "buddy_icon" ;;
+        "2") echo "status_str" ;;
+        "3") echo "arrive_sound" ;;
+        "4") echo "rich_text" ;;
+        "5") echo "superbuddy_icon" ;;
+        "6") echo "radio_station" ;;
+        "12") echo "buddy_icon_big" ;;
+        "13") echo "status_str_tod" ;;
+        "15") echo "current_av_track" ;;
+        "96") echo "depart_sound" ;;
+        "129") echo "im_chrome" ;;
+        "131") echo "im_sound" ;;
+        "136") echo "im_chrome_xml" ;;
+        "137") echo "im_chrome_immers" ;;
+        "1024") echo "emoticon_set" ;;
+        "1026") echo "encr_cert_chain" ;;
+        "1027") echo "sign_cert_chain" ;;
+        "1028") echo "gateway_cert" ;;
+        *) echo "unknown_type_$1" ;;
+    esac
+}
+
+check_prerequisites() {
+    if ! command_exists curl; then
+        log_error "curl is required but not installed"
+        exit 1
+    fi
+
+    if ! command_exists jq; then
+        log_warning "jq is not installed. JSON responses will not be formatted"
+    fi
+}
+
+test_api() {
+    log_info "Testing API connectivity..."
+
+    local response
+    local http_code
+    if response=$(curl -s -w "%{http_code}" "$API_BASE_URL/bart?type=0" 2>/dev/null); then
+        # Extract HTTP code from response (last 3 characters)
+        http_code=$(echo "$response" | tail -c 4)
+        if [ "$http_code" = "200" ]; then
+            log_success "API is accessible"
+            return 0
+        else
+            log_error "API returned HTTP $http_code"
+            return 1
+        fi
+    else
+        log_error "Failed to connect to API at $API_BASE_URL"
+        return 1
+    fi
+}
+
+upload_bart_asset() {
+    local file_path="$1"
+    local bart_type="$2"
+    local hash="$3"
+
+    log_verbose "Uploading $file_path (type: $bart_type, hash: $hash)"
+
+    if [ "$DRY_RUN" = true ]; then
+        log_info "[DRY RUN] Would upload: $file_path -> type=$bart_type, hash=$hash"
+        return 0
+    fi
+
+    local response
+    local http_code
+
+    # Upload the file
+    if response=$(curl -s -w "%{http_code}" \
+        -X POST \
+        -H "Content-Type: application/octet-stream" \
+        --data-binary "@$file_path" \
+        "$API_BASE_URL/bart/$hash?type=$bart_type" 2>/dev/null); then
+
+        # Extract HTTP code from response (last 3 characters)
+        http_code=$(echo "$response" | tail -c 4)
+        # Extract response body (all except last 3 characters)
+        # Use a more portable approach that works on both macOS and Linux
+        response_length=$(echo "$response" | wc -c)
+        response_body_length=$((response_length - 4))
+        response_body=$(echo "$response" | head -c "$response_body_length")
+
+        case "$http_code" in
+            201)
+                log_success "Uploaded $hash"
+                if [ "$VERBOSE" = true ] && command_exists jq; then
+                    echo "$response_body" | jq .
+                fi
+                return 0
+                ;;
+            409)
+                log_warning "Asset $hash already exists"
+                return 0
+                ;;
+            400)
+                log_error "Bad request for $hash (type: $bart_type)"
+                if command_exists jq; then
+                    echo "$response_body" | jq .
+                else
+                    echo "$response_body"
+                fi
+                return 1
+                ;;
+            413)
+                log_error "File too large for $hash (type: $bart_type)"
+                return 1
+                ;;
+            *)
+                log_error "Upload failed for $hash (type: $bart_type) - HTTP $http_code"
+                if command_exists jq; then
+                    echo "$response_body" | jq .
+                else
+                    echo "$response_body"
+                fi
+                return 1
+                ;;
+        esac
+    else
+        log_error "Failed to upload $hash (type: $bart_type)"
+        return 1
+    fi
+}
+
+process_bart_type_directory() {
+    local type_dir="$1"
+    local bart_type="$2"
+    local type_name="$3"
+
+    log_info "Processing BART type $bart_type ($type_name)..."
+
+    if [ ! -d "$type_dir" ]; then
+        log_warning "Type directory $type_dir does not exist, skipping"
+        return 0
+    fi
+
+    local file_count=0
+    local success_count=0
+    local error_count=0
+
+    # Find all files in the type directory (excluding directories)
+    # Use a more portable approach that works on both macOS and Linux
+    # Store results in a temporary file to avoid subshell variable scoping issues
+    local temp_file=$(mktemp)
+    find "$type_dir" -maxdepth 1 -type f 2>/dev/null > "$temp_file"
+    
+    while read -r file_path; do
+        if [ -f "$file_path" ]; then
+            local filename=$(basename "$file_path")
+            file_count=$((file_count + 1))
+
+            log_verbose "Found file: $filename"
+
+            # Validate that filename is a hexadecimal string
+            if ! is_hex_string "$filename"; then
+                log_warning "Skipping file '$filename' - filename is not a valid hexadecimal string"
+                error_count=$((error_count + 1))
+                continue
+            fi
+
+            if upload_bart_asset "$file_path" "$bart_type" "$filename"; then
+                success_count=$((success_count + 1))
+            else
+                error_count=$((error_count + 1))
+            fi
+        fi
+    done < "$temp_file"
+    
+    # Clean up temporary file
+    rm -f "$temp_file"
+
+    log_info "Type $bart_type ($type_name): $file_count files, $success_count successful, $error_count errors"
+
+    return $error_count
+}
+
+process_directory() {
+    local base_dir="$1"
+    base_dir=$(normalize_path "$base_dir")
+
+    if [ ! -d "$base_dir" ] && [ ! -f "$base_dir" ]; then
+        log_error "Path $base_dir does not exist"
+        return 1
+    fi
+
+    # Check if this is a single file
+    if [ -f "$base_dir" ]; then
+        log_info "Processing file: $base_dir"
+        local filename=$(basename "$base_dir")
+        
+        # Validate that filename is a hexadecimal string
+        if ! is_hex_string "$filename"; then
+            log_error "Cannot process file '$filename' - filename is not a valid hexadecimal string"
+            return 1
+        fi
+        
+        if upload_bart_asset "$base_dir" "$BART_TYPE_NUMBER" "$filename"; then
+            log_success "Successfully processed file: $base_dir"
+            return 0
+        else
+            log_error "Failed to process file: $base_dir"
+            return 1
+        fi
+    fi
+
+    # For directories, show the BART type info
+    log_info "Processing BART assets from directory: $base_dir"
+    log_info "BART type: $BART_TYPE (type number: $BART_TYPE_NUMBER)"
+
+    # Look for the specific type directory first (nested structure)
+    local type_dir="$base_dir/$BART_TYPE_NUMBER"
+
+    if [ -d "$type_dir" ]; then
+        log_info "Found type directory: $type_dir"
+        # Process the single type directory
+        if process_bart_type_directory "$type_dir" "$BART_TYPE_NUMBER" "$BART_TYPE"; then
+            local file_count=$(find "$type_dir" -maxdepth 1 -type f | wc -l)
+            log_success "Successfully processed $file_count files for type $BART_TYPE"
+            return 0
+        else
+            log_error "Failed to process files for type $BART_TYPE"
+            return 1
+        fi
+    fi
+
+    # If no type directory found, check if this is a flat structure (files directly in directory)
+    local file_count=$(find "$base_dir" -maxdepth 1 -type f | wc -l)
+    if [ $file_count -gt 0 ]; then
+        log_info "Found flat file structure with $file_count files"
+        # Process files directly in the directory
+        if process_bart_type_directory "$base_dir" "$BART_TYPE_NUMBER" "$BART_TYPE"; then
+            log_success "Successfully processed $file_count files for type $BART_TYPE"
+            return 0
+        else
+            log_error "Failed to process files for type $BART_TYPE"
+            return 1
+        fi
+    fi
+
+    # No files or directories found
+    log_error "No files found in $base_dir"
+    log_error "Expected either:"
+    log_error "  - Nested structure: $base_dir/$BART_TYPE_NUMBER/"
+    log_error "  - Flat structure: files directly in $base_dir/"
+    return 1
+}
+
+while [[ $# -gt 0 ]]; do
+    case $1 in
+        -t|--type)
+            BART_TYPE="$2"
+            shift 2
+            ;;
+        -u|--url)
+            API_BASE_URL="$2"
+            shift 2
+            ;;
+        -v|--verbose)
+            VERBOSE=true
+            shift
+            ;;
+        -d|--dry-run)
+            DRY_RUN=true
+            shift
+            ;;
+        -h|--help)
+            usage
+            exit 0
+            ;;
+        -*)
+            log_error "Unknown option $1"
+            usage
+            exit 1
+            ;;
+        *)
+            TARGET_DIRS+=("$1")
+            shift
+            ;;
+    esac
+done
+
+if [ -z "$BART_TYPE" ]; then
+    log_error "BART type is required"
+    usage
+    exit 1
+fi
+
+if [ ${#TARGET_DIRS[@]} -eq 0 ]; then
+    log_error "No directory path provided"
+    usage
+    exit 1
+fi
+
+# Validate BART type
+BART_TYPE_NUMBER=$(get_bart_type_number "$BART_TYPE")
+if [ -z "$BART_TYPE_NUMBER" ]; then
+    log_error "Invalid BART type: $BART_TYPE"
+    log_error "Valid types: buddy_icon_small, buddy_icon, status_str, arrive_sound, rich_text, superbuddy_icon, radio_station, buddy_icon_big, status_str_tod, current_av_track, depart_sound, im_chrome, im_sound, im_chrome_xml, im_chrome_immers, emoticon_set, encr_cert_chain, sign_cert_chain, gateway_cert"
+    exit 1
+fi
+
+main() {
+    log_info "BART Import Script for Retro AIM Server"
+    log_info "========================================"
+    log_info "Target paths: ${TARGET_DIRS[*]}"
+    log_info "BART type: $BART_TYPE (type number: $BART_TYPE_NUMBER)"
+
+    if [ "$DRY_RUN" = true ]; then
+        log_warning "DRY RUN MODE - No files will be uploaded"
+    fi
+
+    check_prerequisites
+
+    if [ "$DRY_RUN" = false ]; then
+        test_api
+    fi
+
+    local total_errors=0
+    local total_dirs=0
+
+    # Process each file/directory
+    # Ensure array is properly expanded for cross-platform compatibility
+    if [ ${#TARGET_DIRS[@]} -gt 0 ]; then
+        for target_path in "${TARGET_DIRS[@]}"; do
+            total_dirs=$((total_dirs + 1))
+
+            if process_directory "$target_path"; then
+                # Success is logged by process_directory
+                :
+            else
+                total_errors=$((total_errors + 1))
+            fi
+        done
+    fi
+
+    # Summary
+    log_info "Import completed!"
+    log_info "Total files processed: $total_dirs"
+    log_info "Total errors: $total_errors"
+
+    if [ $total_errors -eq 0 ]; then
+        log_success "All operations completed successfully"
+        exit 0
+    else
+        log_error "Some operations failed"
+        exit 1
+    fi
+}
+
+main

+ 37 - 10
server/http/helpers_test.go

@@ -12,7 +12,7 @@ import (
 
 type mockParams struct {
 	accountManagerParams
-	bartRetrieverParams
+	bartAssetManagerParams
 	chatRoomDeleterParams
 	chatRoomRetrieverParams
 	chatSessionRetrieverParams
@@ -73,20 +73,47 @@ type setBotStatusParams []struct {
 	err        error
 }
 
-// bartRetrieverParams is a helper struct that contains mock parameters for
-// BuddyIconRetriever methods
-type bartRetrieverParams struct {
-	bartRetrieveParams
+// bartAssetManagerParams is a helper struct that contains mock parameters for
+// BARTAssetManager methods
+type bartAssetManagerParams struct {
+	bartItemParams
+	insertBARTItemParams
+	listBARTItemsParams
+	deleteBARTItemParams
 }
 
-// bartRetrieveParams is the list of parameters passed at the mock
-// BuddyIconRetriever.BuddyIconParams call site
-type bartRetrieveParams []struct {
-	itemHash []byte
-	result   []byte
+// bartItemParams is the list of parameters passed at the mock
+// BARTAssetManager.BARTItem call site
+type bartItemParams []struct {
+	hash   []byte
+	result []byte
+	err    error
+}
+
+// insertBARTItemParams is the list of parameters passed at the mock
+// BARTAssetManager.InsertBARTItem call site
+type insertBARTItemParams []struct {
+	hash     []byte
+	blob     []byte
+	itemType uint16
+	err      error
+}
+
+// listBARTItemsParams is the list of parameters passed at the mock
+// BARTAssetManager.ListBARTItems call site
+type listBARTItemsParams []struct {
+	itemType uint16
+	result   []state.BARTItem
 	err      error
 }
 
+// deleteBARTItemParams is the list of parameters passed at the mock
+// BARTAssetManager.DeleteBARTItem call site
+type deleteBARTItemParams []struct {
+	hash []byte
+	err  error
+}
+
 // chatRoomRetrieverParams is a helper struct that contains mock parameters for
 // ChatRoomRetriever methods
 type chatRoomRetrieverParams struct {

+ 198 - 4
server/http/mgmt_api.go

@@ -4,9 +4,11 @@ import (
 	"bytes"
 	"context"
 	"encoding/base64"
+	"encoding/hex"
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"log/slog"
 	"net/http"
 	"strconv"
@@ -20,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, bartRetriever BuddyIconRetriever, feedbagRetriever FeedBagRetriever, accountManager AccountManager, profileRetriever ProfileRetriever, logger *slog.Logger) *Server {
+func NewManagementAPI(bld config.Build, listener string, userManager UserManager, sessionRetriever SessionRetriever, chatRoomRetriever ChatRoomRetriever, chatRoomCreator ChatRoomCreator, 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
@@ -54,7 +56,7 @@ func NewManagementAPI(bld config.Build, listener string, userManager UserManager
 
 	// Handlers for '/user/{screenname}/icon' route
 	mux.HandleFunc("GET /user/{screenname}/icon", func(w http.ResponseWriter, r *http.Request) {
-		getUserBuddyIconHandler(w, r, userManager, feedbagRetriever, bartRetriever, logger)
+		getUserBuddyIconHandler(w, r, userManager, feedbagRetriever, bartAssetManager, logger)
 	})
 
 	// Handlers for '/session' route
@@ -96,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)
@@ -124,6 +143,22 @@ func NewManagementAPI(bld config.Build, listener string, userManager UserManager
 		deleteDirectoryKeywordHandler(w, r, directoryManager, logger)
 	})
 
+	// Handlers for '/bart' route
+	mux.HandleFunc("GET /bart", func(w http.ResponseWriter, r *http.Request) {
+		getBARTByTypeHandler(w, r, bartAssetManager, logger)
+	})
+
+	// Handlers for '/bart/{hash}' route
+	mux.HandleFunc("GET /bart/{hash}", func(w http.ResponseWriter, r *http.Request) {
+		getBARTHandler(w, r, bartAssetManager, logger)
+	})
+	mux.HandleFunc("POST /bart/{hash}", func(w http.ResponseWriter, r *http.Request) {
+		postBARTHandler(w, r, bartAssetManager, logger)
+	})
+	mux.HandleFunc("DELETE /bart/{hash}", func(w http.ResponseWriter, r *http.Request) {
+		deleteBARTHandler(w, r, bartAssetManager, logger)
+	})
+
 	return &Server{
 		server: http.Server{
 			Addr:    listener,
@@ -597,7 +632,7 @@ func postInstantMessageHandler(w http.ResponseWriter, r *http.Request, messageRe
 }
 
 // getUserBuddyIconHandler handles the GET /user/{screenname}/icon endpoint.
-func getUserBuddyIconHandler(w http.ResponseWriter, r *http.Request, u UserManager, f FeedBagRetriever, b BuddyIconRetriever, logger *slog.Logger) {
+func getUserBuddyIconHandler(w http.ResponseWriter, r *http.Request, u UserManager, f FeedBagRetriever, b BARTAssetManager, logger *slog.Logger) {
 	screenName := state.NewIdentScreenName(r.PathValue("screenname"))
 	user, err := u.User(r.Context(), screenName)
 	if err != nil {
@@ -619,7 +654,7 @@ func getUserBuddyIconHandler(w http.ResponseWriter, r *http.Request, u UserManag
 		http.Error(w, "icon not found", http.StatusNotFound)
 		return
 	}
-	icon, err := b.BuddyIcon(r.Context(), iconRef.Hash)
+	icon, err := b.BARTItem(r.Context(), iconRef.Hash)
 	if err != nil {
 		logger.Error("error retrieving buddy icon bart item", "err", err.Error())
 		http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -986,3 +1021,162 @@ func errorMsg(w http.ResponseWriter, error string, code int) {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 	}
 }
+
+// BARTAsset represents a BART asset entry.
+type BARTAsset struct {
+	Hash string `json:"hash"`
+	Type uint16 `json:"type"`
+}
+
+// getBARTByTypeHandler handles the GET /bart endpoint.
+func getBARTByTypeHandler(w http.ResponseWriter, r *http.Request, bartAssetManager BARTAssetManager, logger *slog.Logger) {
+	w.Header().Set("Content-Type", "application/json")
+
+	// Get type from query parameter (required)
+	typeStr := r.URL.Query().Get("type")
+	if typeStr == "" {
+		errorMsg(w, "type query parameter is required", http.StatusBadRequest)
+		return
+	}
+	typeVal, err := strconv.ParseUint(typeStr, 10, 16)
+	if err != nil {
+		errorMsg(w, "invalid type ID", http.StatusBadRequest)
+		return
+	}
+	itemType := uint16(typeVal)
+
+	// Get BART items, filtered by type
+	items, err := bartAssetManager.ListBARTItems(r.Context(), itemType)
+	if err != nil {
+		logger.Error("error listing BART items", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	// Convert to BARTAsset format
+	assets := make([]BARTAsset, 0, len(items))
+	for _, item := range items {
+		assets = append(assets, BARTAsset{
+			Hash: item.Hash,
+			Type: item.Type,
+		})
+	}
+
+	if err := json.NewEncoder(w).Encode(assets); err != nil {
+		logger.Error("error encoding response", "err", err.Error())
+	}
+}
+
+// getBARTHandler handles the GET /bart/{hash} endpoint.
+func getBARTHandler(w http.ResponseWriter, r *http.Request, bartAssetManager BARTAssetManager, logger *slog.Logger) {
+	hashStr := r.PathValue("hash")
+	if hashStr == "" {
+		errorMsg(w, "hash is required", http.StatusBadRequest)
+		return
+	}
+
+	hashBytes, err := hex.DecodeString(hashStr)
+	if err != nil {
+		errorMsg(w, "invalid hash format", http.StatusBadRequest)
+		return
+	}
+
+	body, err := bartAssetManager.BARTItem(r.Context(), hashBytes)
+	if err != nil {
+		logger.Error("error retrieving BART asset", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	if len(body) == 0 {
+		errorMsg(w, "BART asset not found", http.StatusNotFound)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/octet-stream")
+	w.Write(body)
+}
+
+// postBARTHandler handles the POST /bart endpoint.
+func postBARTHandler(w http.ResponseWriter, r *http.Request, bartAssetManager BARTAssetManager, logger *slog.Logger) {
+	w.Header().Set("Content-Type", "application/json")
+
+	// Extract hash from URL path
+	hashStr := r.PathValue("hash")
+	if hashStr == "" {
+		errorMsg(w, "hash path parameter is required", http.StatusBadRequest)
+		return
+	}
+
+	hashBytes, err := hex.DecodeString(hashStr)
+	if err != nil {
+		errorMsg(w, "invalid hash format", http.StatusBadRequest)
+		return
+	}
+
+	typeStr := r.URL.Query().Get("type")
+	if typeStr == "" {
+		errorMsg(w, "type query parameter is required", http.StatusBadRequest)
+		return
+	}
+	typeVal, err := strconv.ParseUint(typeStr, 10, 16)
+	if err != nil {
+		errorMsg(w, "invalid type ID", http.StatusBadRequest)
+		return
+	}
+	bartType := uint16(typeVal)
+
+	data, err := io.ReadAll(r.Body)
+	if err != nil {
+		errorMsg(w, "failed to read request body", http.StatusBadRequest)
+		return
+	}
+
+	if err := bartAssetManager.InsertBARTItem(r.Context(), hashBytes, data, bartType); err != nil {
+		if errors.Is(err, state.ErrBARTItemExists) {
+			errorMsg(w, "BART asset already exists", http.StatusConflict)
+			return
+		}
+		logger.Error("error in POST /bart", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	w.WriteHeader(http.StatusCreated)
+	response := BARTAsset{
+		Hash: hex.EncodeToString(hashBytes),
+		Type: bartType,
+	}
+	json.NewEncoder(w).Encode(response)
+}
+
+// deleteBARTHandler handles the DELETE /bart/{hash} endpoint.
+func deleteBARTHandler(w http.ResponseWriter, r *http.Request, bartAssetManager BARTAssetManager, logger *slog.Logger) {
+	w.Header().Set("Content-Type", "application/json")
+
+	// Extract hash from URL path
+	hashStr := r.PathValue("hash")
+	if hashStr == "" {
+		errorMsg(w, "hash path parameter is required", http.StatusBadRequest)
+		return
+	}
+
+	hashBytes, err := hex.DecodeString(hashStr)
+	if err != nil {
+		errorMsg(w, "invalid hash format", http.StatusBadRequest)
+		return
+	}
+
+	if err := bartAssetManager.DeleteBARTItem(r.Context(), hashBytes); err != nil {
+		if errors.Is(err, state.ErrBARTItemNotFound) {
+			errorMsg(w, "BART asset not found", http.StatusNotFound)
+			return
+		}
+		logger.Error("error in DELETE /bart", "err", err.Error())
+		errorMsg(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	msg := messageBody{Message: "BART asset deleted successfully."}
+	json.NewEncoder(w).Encode(msg)
+}

+ 465 - 15
server/http/mgmt_api_test.go

@@ -782,11 +782,11 @@ func TestUserBuddyIconHandler_GET(t *testing.T) {
 						},
 					},
 				},
-				bartRetrieverParams: bartRetrieverParams{
-					bartRetrieveParams: bartRetrieveParams{
+				bartAssetManagerParams: bartAssetManagerParams{
+					bartItemParams: bartItemParams{
 						{
-							itemHash: []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
-							result:   sampleGIF,
+							hash:   []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+							result: sampleGIF,
 						},
 					},
 				},
@@ -824,11 +824,11 @@ func TestUserBuddyIconHandler_GET(t *testing.T) {
 						},
 					},
 				},
-				bartRetrieverParams: bartRetrieverParams{
-					bartRetrieveParams: bartRetrieveParams{
+				bartAssetManagerParams: bartAssetManagerParams{
+					bartItemParams: bartItemParams{
 						{
-							itemHash: []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
-							result:   sampleJPG,
+							hash:   []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+							result: sampleJPG,
 						},
 					},
 				},
@@ -866,11 +866,11 @@ func TestUserBuddyIconHandler_GET(t *testing.T) {
 						},
 					},
 				},
-				bartRetrieverParams: bartRetrieverParams{
-					bartRetrieveParams: bartRetrieveParams{
+				bartAssetManagerParams: bartAssetManagerParams{
+					bartItemParams: bartItemParams{
 						{
-							itemHash: []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
-							result:   []byte{0x13, 0x37, 0x13, 0x37, 0x13, 0x37},
+							hash:   []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+							result: []byte{0x13, 0x37, 0x13, 0x37, 0x13, 0x37},
 						},
 					},
 				},
@@ -960,10 +960,10 @@ func TestUserBuddyIconHandler_GET(t *testing.T) {
 					Return(params.result, params.err)
 			}
 
-			bartRetriever := newMockBuddyIconRetriever(t)
-			for _, params := range tc.mockParams.bartRetrieverParams.bartRetrieveParams {
+			bartRetriever := newMockBARTAssetManager(t)
+			for _, params := range tc.mockParams.bartAssetManagerParams.bartItemParams {
 				bartRetriever.EXPECT().
-					BuddyIcon(matchContext(), params.itemHash).
+					BARTItem(matchContext(), params.hash).
 					Return(params.result, params.err)
 			}
 
@@ -2535,3 +2535,453 @@ func TestDirectoryKeywordHandler_DELETE(t *testing.T) {
 		})
 	}
 }
+
+func TestBARTByTypeHandler_GET(t *testing.T) {
+	tt := []struct {
+		name           string
+		queryParams    string
+		wantStatusCode int
+		wantResponse   string
+		mockParams     mockParams
+	}{
+		{
+			name:           "success with items",
+			queryParams:    "?type=1",
+			wantStatusCode: http.StatusOK,
+			wantResponse:   `[{"hash":"2B000001E4","type":1},{"hash":"2B000001B7","type":1}]`,
+			mockParams: mockParams{
+				bartAssetManagerParams: bartAssetManagerParams{
+					listBARTItemsParams: listBARTItemsParams{
+						{
+							itemType: 1,
+							result: []state.BARTItem{
+								{Hash: "2B000001E4", Type: 1},
+								{Hash: "2B000001B7", Type: 1},
+							},
+							err: nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "success with empty list",
+			queryParams:    "?type=2",
+			wantStatusCode: http.StatusOK,
+			wantResponse:   `[]`,
+			mockParams: mockParams{
+				bartAssetManagerParams: bartAssetManagerParams{
+					listBARTItemsParams: listBARTItemsParams{
+						{
+							itemType: 2,
+							result:   []state.BARTItem{},
+							err:      nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "missing type parameter",
+			queryParams:    "",
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"type query parameter is required"}`,
+		},
+		{
+			name:           "invalid type parameter",
+			queryParams:    "?type=invalid",
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"invalid type ID"}`,
+		},
+		{
+			name:           "internal server error",
+			queryParams:    "?type=1",
+			wantStatusCode: http.StatusInternalServerError,
+			wantResponse:   `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				bartAssetManagerParams: bartAssetManagerParams{
+					listBARTItemsParams: listBARTItemsParams{
+						{
+							itemType: 1,
+							result:   nil,
+							err:      errors.New("database error"),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodGet, "/bart"+tc.queryParams, nil)
+			responseRecorder := httptest.NewRecorder()
+
+			mockBARTManager := newMockBARTAssetManager(t)
+			for _, params := range tc.mockParams.bartAssetManagerParams.listBARTItemsParams {
+				mockBARTManager.EXPECT().
+					ListBARTItems(matchContext(), params.itemType).
+					Return(params.result, params.err)
+			}
+
+			getBARTByTypeHandler(responseRecorder, request, mockBARTManager, slog.Default())
+
+			assert.Equal(t, tc.wantStatusCode, responseRecorder.Code)
+			assert.JSONEq(t, tc.wantResponse, responseRecorder.Body.String())
+		})
+	}
+}
+
+func TestBARTHandler_GET(t *testing.T) {
+	tt := []struct {
+		name           string
+		hash           string
+		wantStatusCode int
+		wantResponse   string
+		wantHeaders    map[string]string
+		mockParams     mockParams
+	}{
+		{
+			name:           "success with valid hash",
+			hash:           "2B000001E4",
+			wantStatusCode: http.StatusOK,
+			wantResponse:   "binary data",
+			wantHeaders:    map[string]string{"Content-Type": "application/octet-stream"},
+			mockParams: mockParams{
+				bartAssetManagerParams: bartAssetManagerParams{
+					bartItemParams: bartItemParams{
+						{
+							hash:   []byte{0x2B, 0x00, 0x00, 0x01, 0xE4},
+							result: []byte("binary data"),
+							err:    nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "missing hash parameter",
+			hash:           "",
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"hash is required"}`,
+		},
+		{
+			name:           "invalid hash format",
+			hash:           "invalid-hex",
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"invalid hash format"}`,
+		},
+		{
+			name:           "asset not found",
+			hash:           "2B000001E4",
+			wantStatusCode: http.StatusNotFound,
+			wantResponse:   `{"message":"BART asset not found"}`,
+			mockParams: mockParams{
+				bartAssetManagerParams: bartAssetManagerParams{
+					bartItemParams: bartItemParams{
+						{
+							hash:   []byte{0x2B, 0x00, 0x00, 0x01, 0xE4},
+							result: []byte{},
+							err:    nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "internal server error",
+			hash:           "2B000001E4",
+			wantStatusCode: http.StatusInternalServerError,
+			wantResponse:   `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				bartAssetManagerParams: bartAssetManagerParams{
+					bartItemParams: bartItemParams{
+						{
+							hash:   []byte{0x2B, 0x00, 0x00, 0x01, 0xE4},
+							result: nil,
+							err:    errors.New("database error"),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodGet, "/bart/"+tc.hash, nil)
+			// Set the path value manually for testing
+			if tc.hash != "" {
+				request.SetPathValue("hash", tc.hash)
+			}
+			responseRecorder := httptest.NewRecorder()
+
+			mockBARTManager := newMockBARTAssetManager(t)
+			for _, params := range tc.mockParams.bartAssetManagerParams.bartItemParams {
+				mockBARTManager.EXPECT().
+					BARTItem(matchContext(), params.hash).
+					Return(params.result, params.err)
+			}
+
+			getBARTHandler(responseRecorder, request, mockBARTManager, slog.Default())
+
+			assert.Equal(t, tc.wantStatusCode, responseRecorder.Code)
+
+			if tc.wantHeaders != nil {
+				for key, value := range tc.wantHeaders {
+					assert.Equal(t, value, responseRecorder.Header().Get(key))
+				}
+			}
+
+			if tc.wantStatusCode == http.StatusOK {
+				assert.Equal(t, tc.wantResponse, responseRecorder.Body.String())
+			} else {
+				assert.JSONEq(t, tc.wantResponse, responseRecorder.Body.String())
+			}
+		})
+	}
+}
+
+func TestBARTHandler_POST(t *testing.T) {
+	tt := []struct {
+		name           string
+		hash           string
+		queryParams    string
+		requestBody    string
+		wantStatusCode int
+		wantResponse   string
+		mockParams     mockParams
+	}{
+		{
+			name:           "success with valid data",
+			hash:           "2B000001E4",
+			queryParams:    "?type=1",
+			requestBody:    "binary data",
+			wantStatusCode: http.StatusCreated,
+			wantResponse:   `{"hash":"2b000001e4","type":1}`,
+			mockParams: mockParams{
+				bartAssetManagerParams: bartAssetManagerParams{
+					insertBARTItemParams: insertBARTItemParams{
+						{
+							hash:     []byte{0x2B, 0x00, 0x00, 0x01, 0xE4},
+							blob:     []byte("binary data"),
+							itemType: 1,
+							err:      nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "missing hash parameter",
+			hash:           "",
+			queryParams:    "?type=1",
+			requestBody:    "binary data",
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"hash path parameter is required"}`,
+		},
+		{
+			name:           "invalid hash format",
+			hash:           "invalid-hex",
+			queryParams:    "?type=1",
+			requestBody:    "binary data",
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"invalid hash format"}`,
+		},
+		{
+			name:           "missing type parameter",
+			hash:           "2B000001E4",
+			queryParams:    "",
+			requestBody:    "binary data",
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"type query parameter is required"}`,
+		},
+		{
+			name:           "invalid type parameter",
+			hash:           "2B000001E4",
+			queryParams:    "?type=invalid",
+			requestBody:    "binary data",
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"invalid type ID"}`,
+		},
+		{
+			name:           "failed to read request body",
+			hash:           "2B000001E4",
+			queryParams:    "?type=1",
+			requestBody:    "", // This will cause an error when reading
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"failed to read request body"}`,
+		},
+		{
+			name:           "asset already exists",
+			hash:           "2B000001E4",
+			queryParams:    "?type=1",
+			requestBody:    "binary data",
+			wantStatusCode: http.StatusConflict,
+			wantResponse:   `{"message":"BART asset already exists"}`,
+			mockParams: mockParams{
+				bartAssetManagerParams: bartAssetManagerParams{
+					insertBARTItemParams: insertBARTItemParams{
+						{
+							hash:     []byte{0x2B, 0x00, 0x00, 0x01, 0xE4},
+							blob:     []byte("binary data"),
+							itemType: 1,
+							err:      state.ErrBARTItemExists,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "internal server error",
+			hash:           "2B000001E4",
+			queryParams:    "?type=1",
+			requestBody:    "binary data",
+			wantStatusCode: http.StatusInternalServerError,
+			wantResponse:   `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				bartAssetManagerParams: bartAssetManagerParams{
+					insertBARTItemParams: insertBARTItemParams{
+						{
+							hash:     []byte{0x2B, 0x00, 0x00, 0x01, 0xE4},
+							blob:     []byte("binary data"),
+							itemType: 1,
+							err:      errors.New("database error"),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			var requestBody io.Reader
+			if tc.requestBody != "" {
+				requestBody = strings.NewReader(tc.requestBody)
+			} else {
+				requestBody = &errorReader{}
+			}
+
+			request := httptest.NewRequest(http.MethodPost, "/bart/"+tc.hash+tc.queryParams, requestBody)
+			// Set the path value manually for testing
+			if tc.hash != "" {
+				request.SetPathValue("hash", tc.hash)
+			}
+			responseRecorder := httptest.NewRecorder()
+
+			mockBARTManager := newMockBARTAssetManager(t)
+			for _, params := range tc.mockParams.bartAssetManagerParams.insertBARTItemParams {
+				mockBARTManager.EXPECT().
+					InsertBARTItem(matchContext(), params.hash, params.blob, params.itemType).
+					Return(params.err)
+			}
+
+			postBARTHandler(responseRecorder, request, mockBARTManager, slog.Default())
+
+			assert.Equal(t, tc.wantStatusCode, responseRecorder.Code)
+			assert.JSONEq(t, tc.wantResponse, responseRecorder.Body.String())
+		})
+	}
+}
+
+func TestBARTHandler_DELETE(t *testing.T) {
+	tt := []struct {
+		name           string
+		hash           string
+		wantStatusCode int
+		wantResponse   string
+		mockParams     mockParams
+	}{
+		{
+			name:           "success with valid hash",
+			hash:           "2B000001E4",
+			wantStatusCode: http.StatusOK,
+			wantResponse:   `{"message":"BART asset deleted successfully."}`,
+			mockParams: mockParams{
+				bartAssetManagerParams: bartAssetManagerParams{
+					deleteBARTItemParams: deleteBARTItemParams{
+						{
+							hash: []byte{0x2B, 0x00, 0x00, 0x01, 0xE4},
+							err:  nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "missing hash parameter",
+			hash:           "",
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"hash path parameter is required"}`,
+		},
+		{
+			name:           "invalid hash format",
+			hash:           "invalid-hex",
+			wantStatusCode: http.StatusBadRequest,
+			wantResponse:   `{"message":"invalid hash format"}`,
+		},
+		{
+			name:           "asset not found",
+			hash:           "2B000001E4",
+			wantStatusCode: http.StatusNotFound,
+			wantResponse:   `{"message":"BART asset not found"}`,
+			mockParams: mockParams{
+				bartAssetManagerParams: bartAssetManagerParams{
+					deleteBARTItemParams: deleteBARTItemParams{
+						{
+							hash: []byte{0x2B, 0x00, 0x00, 0x01, 0xE4},
+							err:  state.ErrBARTItemNotFound,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:           "internal server error",
+			hash:           "2B000001E4",
+			wantStatusCode: http.StatusInternalServerError,
+			wantResponse:   `{"message":"internal server error"}`,
+			mockParams: mockParams{
+				bartAssetManagerParams: bartAssetManagerParams{
+					deleteBARTItemParams: deleteBARTItemParams{
+						{
+							hash: []byte{0x2B, 0x00, 0x00, 0x01, 0xE4},
+							err:  errors.New("database error"),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodDelete, "/bart/"+tc.hash, nil)
+			// Set the path value manually for testing
+			if tc.hash != "" {
+				request.SetPathValue("hash", tc.hash)
+			}
+			responseRecorder := httptest.NewRecorder()
+
+			mockBARTManager := newMockBARTAssetManager(t)
+			for _, params := range tc.mockParams.bartAssetManagerParams.deleteBARTItemParams {
+				mockBARTManager.EXPECT().
+					DeleteBARTItem(matchContext(), params.hash).
+					Return(params.err)
+			}
+
+			deleteBARTHandler(responseRecorder, request, mockBARTManager, slog.Default())
+
+			assert.Equal(t, tc.wantStatusCode, responseRecorder.Code)
+			assert.JSONEq(t, tc.wantResponse, responseRecorder.Body.String())
+		})
+	}
+}
+
+// errorReader is a helper type that always returns an error when reading
+type errorReader struct{}
+
+func (er *errorReader) Read(p []byte) (n int, err error) {
+	return 0, errors.New("read error")
+}

+ 251 - 0
server/http/mock_bart_asset_manager_test.go

@@ -0,0 +1,251 @@
+// Code generated by mockery v2.53.3. DO NOT EDIT.
+
+package http
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockBARTAssetManager is an autogenerated mock type for the BARTAssetManager type
+type mockBARTAssetManager struct {
+	mock.Mock
+}
+
+type mockBARTAssetManager_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockBARTAssetManager) EXPECT() *mockBARTAssetManager_Expecter {
+	return &mockBARTAssetManager_Expecter{mock: &_m.Mock}
+}
+
+// BARTItem provides a mock function with given fields: ctx, hash
+func (_m *mockBARTAssetManager) BARTItem(ctx context.Context, hash []byte) ([]byte, error) {
+	ret := _m.Called(ctx, hash)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BARTItem")
+	}
+
+	var r0 []byte
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, []byte) ([]byte, error)); ok {
+		return rf(ctx, hash)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, []byte) []byte); ok {
+		r0 = rf(ctx, hash)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]byte)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
+		r1 = rf(ctx, hash)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockBARTAssetManager_BARTItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BARTItem'
+type mockBARTAssetManager_BARTItem_Call struct {
+	*mock.Call
+}
+
+// BARTItem is a helper method to define mock.On call
+//   - ctx context.Context
+//   - hash []byte
+func (_e *mockBARTAssetManager_Expecter) BARTItem(ctx interface{}, hash interface{}) *mockBARTAssetManager_BARTItem_Call {
+	return &mockBARTAssetManager_BARTItem_Call{Call: _e.mock.On("BARTItem", ctx, hash)}
+}
+
+func (_c *mockBARTAssetManager_BARTItem_Call) Run(run func(ctx context.Context, hash []byte)) *mockBARTAssetManager_BARTItem_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].([]byte))
+	})
+	return _c
+}
+
+func (_c *mockBARTAssetManager_BARTItem_Call) Return(_a0 []byte, _a1 error) *mockBARTAssetManager_BARTItem_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockBARTAssetManager_BARTItem_Call) RunAndReturn(run func(context.Context, []byte) ([]byte, error)) *mockBARTAssetManager_BARTItem_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// DeleteBARTItem provides a mock function with given fields: ctx, hash
+func (_m *mockBARTAssetManager) DeleteBARTItem(ctx context.Context, hash []byte) error {
+	ret := _m.Called(ctx, hash)
+
+	if len(ret) == 0 {
+		panic("no return value specified for DeleteBARTItem")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, []byte) error); ok {
+		r0 = rf(ctx, hash)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBARTAssetManager_DeleteBARTItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteBARTItem'
+type mockBARTAssetManager_DeleteBARTItem_Call struct {
+	*mock.Call
+}
+
+// DeleteBARTItem is a helper method to define mock.On call
+//   - ctx context.Context
+//   - hash []byte
+func (_e *mockBARTAssetManager_Expecter) DeleteBARTItem(ctx interface{}, hash interface{}) *mockBARTAssetManager_DeleteBARTItem_Call {
+	return &mockBARTAssetManager_DeleteBARTItem_Call{Call: _e.mock.On("DeleteBARTItem", ctx, hash)}
+}
+
+func (_c *mockBARTAssetManager_DeleteBARTItem_Call) Run(run func(ctx context.Context, hash []byte)) *mockBARTAssetManager_DeleteBARTItem_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].([]byte))
+	})
+	return _c
+}
+
+func (_c *mockBARTAssetManager_DeleteBARTItem_Call) Return(_a0 error) *mockBARTAssetManager_DeleteBARTItem_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBARTAssetManager_DeleteBARTItem_Call) RunAndReturn(run func(context.Context, []byte) error) *mockBARTAssetManager_DeleteBARTItem_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// InsertBARTItem provides a mock function with given fields: ctx, hash, blob, itemType
+func (_m *mockBARTAssetManager) InsertBARTItem(ctx context.Context, hash []byte, blob []byte, itemType uint16) error {
+	ret := _m.Called(ctx, hash, blob, itemType)
+
+	if len(ret) == 0 {
+		panic("no return value specified for InsertBARTItem")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, []byte, []byte, uint16) error); ok {
+		r0 = rf(ctx, hash, blob, itemType)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBARTAssetManager_InsertBARTItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InsertBARTItem'
+type mockBARTAssetManager_InsertBARTItem_Call struct {
+	*mock.Call
+}
+
+// InsertBARTItem is a helper method to define mock.On call
+//   - ctx context.Context
+//   - hash []byte
+//   - blob []byte
+//   - itemType uint16
+func (_e *mockBARTAssetManager_Expecter) InsertBARTItem(ctx interface{}, hash interface{}, blob interface{}, itemType interface{}) *mockBARTAssetManager_InsertBARTItem_Call {
+	return &mockBARTAssetManager_InsertBARTItem_Call{Call: _e.mock.On("InsertBARTItem", ctx, hash, blob, itemType)}
+}
+
+func (_c *mockBARTAssetManager_InsertBARTItem_Call) Run(run func(ctx context.Context, hash []byte, blob []byte, itemType uint16)) *mockBARTAssetManager_InsertBARTItem_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].([]byte), args[2].([]byte), args[3].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockBARTAssetManager_InsertBARTItem_Call) Return(_a0 error) *mockBARTAssetManager_InsertBARTItem_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBARTAssetManager_InsertBARTItem_Call) RunAndReturn(run func(context.Context, []byte, []byte, uint16) error) *mockBARTAssetManager_InsertBARTItem_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// ListBARTItems provides a mock function with given fields: ctx, itemType
+func (_m *mockBARTAssetManager) ListBARTItems(ctx context.Context, itemType uint16) ([]state.BARTItem, error) {
+	ret := _m.Called(ctx, itemType)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ListBARTItems")
+	}
+
+	var r0 []state.BARTItem
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, uint16) ([]state.BARTItem, error)); ok {
+		return rf(ctx, itemType)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, uint16) []state.BARTItem); ok {
+		r0 = rf(ctx, itemType)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]state.BARTItem)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, uint16) error); ok {
+		r1 = rf(ctx, itemType)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockBARTAssetManager_ListBARTItems_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListBARTItems'
+type mockBARTAssetManager_ListBARTItems_Call struct {
+	*mock.Call
+}
+
+// ListBARTItems is a helper method to define mock.On call
+//   - ctx context.Context
+//   - itemType uint16
+func (_e *mockBARTAssetManager_Expecter) ListBARTItems(ctx interface{}, itemType interface{}) *mockBARTAssetManager_ListBARTItems_Call {
+	return &mockBARTAssetManager_ListBARTItems_Call{Call: _e.mock.On("ListBARTItems", ctx, itemType)}
+}
+
+func (_c *mockBARTAssetManager_ListBARTItems_Call) Run(run func(ctx context.Context, itemType uint16)) *mockBARTAssetManager_ListBARTItems_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(uint16))
+	})
+	return _c
+}
+
+func (_c *mockBARTAssetManager_ListBARTItems_Call) Return(_a0 []state.BARTItem, _a1 error) *mockBARTAssetManager_ListBARTItems_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockBARTAssetManager_ListBARTItems_Call) RunAndReturn(run func(context.Context, uint16) ([]state.BARTItem, error)) *mockBARTAssetManager_ListBARTItems_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockBARTAssetManager creates a new instance of mockBARTAssetManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockBARTAssetManager(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockBARTAssetManager {
+	mock := &mockBARTAssetManager{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 0 - 95
server/http/mock_buddy_icon_retriever_test.go

@@ -1,95 +0,0 @@
-// Code generated by mockery v2.53.3. DO NOT EDIT.
-
-package http
-
-import (
-	context "context"
-
-	mock "github.com/stretchr/testify/mock"
-)
-
-// mockBuddyIconRetriever is an autogenerated mock type for the BuddyIconRetriever type
-type mockBuddyIconRetriever struct {
-	mock.Mock
-}
-
-type mockBuddyIconRetriever_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockBuddyIconRetriever) EXPECT() *mockBuddyIconRetriever_Expecter {
-	return &mockBuddyIconRetriever_Expecter{mock: &_m.Mock}
-}
-
-// BuddyIcon provides a mock function with given fields: ctx, itemHash
-func (_m *mockBuddyIconRetriever) BuddyIcon(ctx context.Context, itemHash []byte) ([]byte, error) {
-	ret := _m.Called(ctx, itemHash)
-
-	if len(ret) == 0 {
-		panic("no return value specified for BuddyIcon")
-	}
-
-	var r0 []byte
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, []byte) ([]byte, error)); ok {
-		return rf(ctx, itemHash)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, []byte) []byte); ok {
-		r0 = rf(ctx, itemHash)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).([]byte)
-		}
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
-		r1 = rf(ctx, itemHash)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// mockBuddyIconRetriever_BuddyIcon_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BuddyIcon'
-type mockBuddyIconRetriever_BuddyIcon_Call struct {
-	*mock.Call
-}
-
-// BuddyIcon is a helper method to define mock.On call
-//   - ctx context.Context
-//   - itemHash []byte
-func (_e *mockBuddyIconRetriever_Expecter) BuddyIcon(ctx interface{}, itemHash interface{}) *mockBuddyIconRetriever_BuddyIcon_Call {
-	return &mockBuddyIconRetriever_BuddyIcon_Call{Call: _e.mock.On("BuddyIcon", ctx, itemHash)}
-}
-
-func (_c *mockBuddyIconRetriever_BuddyIcon_Call) Run(run func(ctx context.Context, itemHash []byte)) *mockBuddyIconRetriever_BuddyIcon_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].([]byte))
-	})
-	return _c
-}
-
-func (_c *mockBuddyIconRetriever_BuddyIcon_Call) Return(_a0 []byte, _a1 error) *mockBuddyIconRetriever_BuddyIcon_Call {
-	_c.Call.Return(_a0, _a1)
-	return _c
-}
-
-func (_c *mockBuddyIconRetriever_BuddyIcon_Call) RunAndReturn(run func(context.Context, []byte) ([]byte, error)) *mockBuddyIconRetriever_BuddyIcon_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// newMockBuddyIconRetriever creates a new instance of mockBuddyIconRetriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func newMockBuddyIconRetriever(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockBuddyIconRetriever {
-	mock := &mockBuddyIconRetriever{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 42 - 4
server/http/types.go

@@ -32,10 +32,19 @@ type AccountManager interface {
 	SetBotStatus(ctx context.Context, isBot bool, screenName state.IdentScreenName) error
 }
 
-// BuddyIconRetriever defines a method for retrieving a buddy icon image by its hash.
-type BuddyIconRetriever interface {
-	// BuddyIcon retrieves a buddy icon image by its md5 hash.
-	BuddyIcon(ctx context.Context, itemHash []byte) ([]byte, error)
+// BARTAssetManager defines methods for managing BART (Buddy ART) assets.
+type BARTAssetManager interface {
+	// BARTItem retrieves a BART asset by its hash.
+	BARTItem(ctx context.Context, hash []byte) ([]byte, error)
+
+	// InsertBARTItem inserts a BART asset.
+	InsertBARTItem(ctx context.Context, hash []byte, blob []byte, itemType uint16) error
+
+	// ListBARTItems returns BART assets filtered by type.
+	ListBARTItems(ctx context.Context, itemType uint16) ([]state.BARTItem, error)
+
+	// DeleteBARTItem deletes a BART asset by hash.
+	DeleteBARTItem(ctx context.Context, hash []byte) error
 }
 
 // ChatRoomCreator defines a method for creating a new chat room.
@@ -230,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)
+}

+ 21 - 1
server/oscar/handler.go

@@ -112,7 +112,7 @@ func (rt Handler) BARTDownloadQuery(ctx context.Context, sess *state.Session, in
 	if err := wire.UnmarshalBE(&inBody, r); err != nil {
 		return err
 	}
-	outSNAC, err := rt.BARTService.RetrieveItem(ctx, sess, inFrame, inBody)
+	outSNAC, err := rt.BARTService.RetrieveItem(ctx, inFrame, inBody)
 	if err != nil {
 		return err
 	}
@@ -120,6 +120,24 @@ func (rt Handler) BARTDownloadQuery(ctx context.Context, sess *state.Session, in
 	return rw.SendSNAC(outSNAC.Frame, outSNAC.Body)
 }
 
+func (rt Handler) BARTDownload2Query(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	inBody := wire.SNAC_0x10_0x06_BARTDownload2Query{}
+	if err := wire.UnmarshalBE(&inBody, r); err != nil {
+		return err
+	}
+	outSNACS, err := rt.BARTService.RetrieveItemV2(ctx, inFrame, inBody)
+	if err != nil {
+		return err
+	}
+	for _, snac := range outSNACS {
+		rt.LogRequestAndResponse(ctx, inFrame, snac, snac.Frame, snac.Body)
+		if err := rw.SendSNAC(snac.Frame, snac.Body); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 func (rt Handler) BuddyRightsQuery(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
 	inSNAC := wire.SNAC_0x03_0x02_BuddyRightsQuery{}
 	if err := wire.UnmarshalBE(&inSNAC, r); err != nil {
@@ -934,6 +952,8 @@ func (rt Handler) Handle(ctx context.Context, server uint16, sess *state.Session
 		switch inFrame.SubGroup {
 		case wire.BARTDownloadQuery:
 			return rt.BARTDownloadQuery(ctx, sess, inFrame, r, rw)
+		case wire.BARTDownload2Query:
+			return rt.BARTDownload2Query(ctx, sess, inFrame, r, rw)
 		case wire.BARTUploadQuery:
 			return rt.BARTUploadQuery(ctx, sess, inFrame, r, rw)
 		}

+ 82 - 1
server/oscar/handler_test.go

@@ -430,7 +430,7 @@ func TestHandler_BARTDownloadQuery(t *testing.T) {
 
 			svc := newMockBARTService(t)
 			svc.EXPECT().
-				RetrieveItem(mock.Anything, mock.Anything, input.Frame, input.Body).
+				RetrieveItem(mock.Anything, input.Frame, input.Body).
 				Return(output, tt.serviceError)
 
 			h := Handler{
@@ -460,6 +460,87 @@ func TestHandler_BARTDownloadQuery(t *testing.T) {
 	}
 }
 
+func TestHandler_BARTDownload2Query(t *testing.T) {
+	tests := []struct {
+		name          string
+		inputBody     wire.SNAC_0x10_0x06_BARTDownload2Query
+		serviceError  error
+		responseError error
+		expectedError error
+	}{
+		{
+			name:      "success",
+			inputBody: wire.SNAC_0x10_0x06_BARTDownload2Query{},
+		},
+		{
+			name:          "service error",
+			inputBody:     wire.SNAC_0x10_0x06_BARTDownload2Query{},
+			serviceError:  assert.AnError,
+			expectedError: assert.AnError,
+		},
+		{
+			name:          "response writer error",
+			inputBody:     wire.SNAC_0x10_0x06_BARTDownload2Query{},
+			responseError: assert.AnError,
+			expectedError: assert.AnError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			input := wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BART,
+					SubGroup:  wire.BARTDownload2Query,
+				},
+				Body: tt.inputBody,
+			}
+			output := []wire.SNACMessage{
+				{
+					Frame: wire.SNACFrame{
+						FoodGroup: wire.BART,
+						SubGroup:  wire.BARTDownload2Reply,
+					},
+					Body: wire.SNAC_0x10_0x07_BARTDownload2Reply{
+						ScreenName: "the-screen-name",
+					},
+				},
+			}
+
+			svc := newMockBARTService(t)
+			svc.EXPECT().
+				RetrieveItemV2(mock.Anything, input.Frame, input.Body).
+				Return(output, tt.serviceError)
+
+			h := Handler{
+				BARTService: svc,
+				RouteLogger: middleware.RouteLogger{
+					Logger: slog.Default(),
+				},
+			}
+
+			responseWriter := newMockResponseWriter(t)
+			if tt.serviceError == nil {
+				for _, msg := range output {
+					responseWriter.EXPECT().
+						SendSNAC(msg.Frame, msg.Body).
+						Return(tt.responseError)
+				}
+			}
+
+			buf := &bytes.Buffer{}
+			assert.NoError(t, wire.MarshalBE(input.Body, buf))
+
+			err := h.Handle(context.TODO(), wire.BOS, nil, input.Frame, buf, responseWriter, config.Listener{})
+			if tt.expectedError != nil {
+				assert.ErrorIs(t, err, tt.expectedError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
 func TestHandler_BARTUploadQuery(t *testing.T) {
 	tests := []struct {
 		name          string

+ 74 - 15
server/oscar/mock_bart_service_test.go

@@ -24,9 +24,9 @@ func (_m *mockBARTService) EXPECT() *mockBARTService_Expecter {
 	return &mockBARTService_Expecter{mock: &_m.Mock}
 }
 
-// RetrieveItem provides a mock function with given fields: ctx, sess, inFrame, inBody
-func (_m *mockBARTService) RetrieveItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error) {
-	ret := _m.Called(ctx, sess, inFrame, inBody)
+// RetrieveItem provides a mock function with given fields: ctx, inFrame, inBody
+func (_m *mockBARTService) RetrieveItem(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error) {
+	ret := _m.Called(ctx, inFrame, inBody)
 
 	if len(ret) == 0 {
 		panic("no return value specified for RetrieveItem")
@@ -34,17 +34,17 @@ func (_m *mockBARTService) RetrieveItem(ctx context.Context, sess *state.Session
 
 	var r0 wire.SNACMessage
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error)); ok {
-		return rf(ctx, sess, inFrame, inBody)
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error)); ok {
+		return rf(ctx, inFrame, inBody)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x10_0x04_BARTDownloadQuery) wire.SNACMessage); ok {
-		r0 = rf(ctx, sess, inFrame, inBody)
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x10_0x04_BARTDownloadQuery) wire.SNACMessage); ok {
+		r0 = rf(ctx, inFrame, inBody)
 	} else {
 		r0 = ret.Get(0).(wire.SNACMessage)
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x10_0x04_BARTDownloadQuery) error); ok {
-		r1 = rf(ctx, sess, inFrame, inBody)
+	if rf, ok := ret.Get(1).(func(context.Context, wire.SNACFrame, wire.SNAC_0x10_0x04_BARTDownloadQuery) error); ok {
+		r1 = rf(ctx, inFrame, inBody)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -59,16 +59,15 @@ type mockBARTService_RetrieveItem_Call struct {
 
 // RetrieveItem is a helper method to define mock.On call
 //   - ctx context.Context
-//   - sess *state.Session
 //   - inFrame wire.SNACFrame
 //   - inBody wire.SNAC_0x10_0x04_BARTDownloadQuery
-func (_e *mockBARTService_Expecter) RetrieveItem(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockBARTService_RetrieveItem_Call {
-	return &mockBARTService_RetrieveItem_Call{Call: _e.mock.On("RetrieveItem", ctx, sess, inFrame, inBody)}
+func (_e *mockBARTService_Expecter) RetrieveItem(ctx interface{}, inFrame interface{}, inBody interface{}) *mockBARTService_RetrieveItem_Call {
+	return &mockBARTService_RetrieveItem_Call{Call: _e.mock.On("RetrieveItem", ctx, inFrame, inBody)}
 }
 
-func (_c *mockBARTService_RetrieveItem_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery)) *mockBARTService_RetrieveItem_Call {
+func (_c *mockBARTService_RetrieveItem_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery)) *mockBARTService_RetrieveItem_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(wire.SNAC_0x10_0x04_BARTDownloadQuery))
+		run(args[0].(context.Context), args[1].(wire.SNACFrame), args[2].(wire.SNAC_0x10_0x04_BARTDownloadQuery))
 	})
 	return _c
 }
@@ -78,7 +77,67 @@ func (_c *mockBARTService_RetrieveItem_Call) Return(_a0 wire.SNACMessage, _a1 er
 	return _c
 }
 
-func (_c *mockBARTService_RetrieveItem_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error)) *mockBARTService_RetrieveItem_Call {
+func (_c *mockBARTService_RetrieveItem_Call) RunAndReturn(run func(context.Context, wire.SNACFrame, wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error)) *mockBARTService_RetrieveItem_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RetrieveItemV2 provides a mock function with given fields: ctx, frame, body
+func (_m *mockBARTService) RetrieveItemV2(ctx context.Context, frame wire.SNACFrame, body wire.SNAC_0x10_0x06_BARTDownload2Query) ([]wire.SNACMessage, error) {
+	ret := _m.Called(ctx, frame, body)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RetrieveItemV2")
+	}
+
+	var r0 []wire.SNACMessage
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x10_0x06_BARTDownload2Query) ([]wire.SNACMessage, error)); ok {
+		return rf(ctx, frame, body)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x10_0x06_BARTDownload2Query) []wire.SNACMessage); ok {
+		r0 = rf(ctx, frame, body)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]wire.SNACMessage)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, wire.SNACFrame, wire.SNAC_0x10_0x06_BARTDownload2Query) error); ok {
+		r1 = rf(ctx, frame, body)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockBARTService_RetrieveItemV2_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveItemV2'
+type mockBARTService_RetrieveItemV2_Call struct {
+	*mock.Call
+}
+
+// RetrieveItemV2 is a helper method to define mock.On call
+//   - ctx context.Context
+//   - frame wire.SNACFrame
+//   - body wire.SNAC_0x10_0x06_BARTDownload2Query
+func (_e *mockBARTService_Expecter) RetrieveItemV2(ctx interface{}, frame interface{}, body interface{}) *mockBARTService_RetrieveItemV2_Call {
+	return &mockBARTService_RetrieveItemV2_Call{Call: _e.mock.On("RetrieveItemV2", ctx, frame, body)}
+}
+
+func (_c *mockBARTService_RetrieveItemV2_Call) Run(run func(ctx context.Context, frame wire.SNACFrame, body wire.SNAC_0x10_0x06_BARTDownload2Query)) *mockBARTService_RetrieveItemV2_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(wire.SNACFrame), args[2].(wire.SNAC_0x10_0x06_BARTDownload2Query))
+	})
+	return _c
+}
+
+func (_c *mockBARTService_RetrieveItemV2_Call) Return(_a0 []wire.SNACMessage, _a1 error) *mockBARTService_RetrieveItemV2_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockBARTService_RetrieveItemV2_Call) RunAndReturn(run func(context.Context, wire.SNACFrame, wire.SNAC_0x10_0x06_BARTDownload2Query) ([]wire.SNACMessage, error)) *mockBARTService_RetrieveItemV2_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 14 - 11
server/oscar/mock_departure_notifier_test.go

@@ -7,6 +7,8 @@ import (
 
 	state "github.com/mk6i/retro-aim-server/state"
 	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
 )
 
 // mockDepartureNotifier is an autogenerated mock type for the DepartureNotifier type
@@ -22,17 +24,17 @@ func (_m *mockDepartureNotifier) EXPECT() *mockDepartureNotifier_Expecter {
 	return &mockDepartureNotifier_Expecter{mock: &_m.Mock}
 }
 
-// BroadcastBuddyArrived provides a mock function with given fields: ctx, sess
-func (_m *mockDepartureNotifier) BroadcastBuddyArrived(ctx context.Context, sess *state.Session) error {
-	ret := _m.Called(ctx, sess)
+// BroadcastBuddyArrived provides a mock function with given fields: ctx, screenName, userInfo
+func (_m *mockDepartureNotifier) BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error {
+	ret := _m.Called(ctx, screenName, userInfo)
 
 	if len(ret) == 0 {
 		panic("no return value specified for BroadcastBuddyArrived")
 	}
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session) error); ok {
-		r0 = rf(ctx, sess)
+	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, wire.TLVUserInfo) error); ok {
+		r0 = rf(ctx, screenName, userInfo)
 	} else {
 		r0 = ret.Error(0)
 	}
@@ -47,14 +49,15 @@ type mockDepartureNotifier_BroadcastBuddyArrived_Call struct {
 
 // BroadcastBuddyArrived is a helper method to define mock.On call
 //   - ctx context.Context
-//   - sess *state.Session
-func (_e *mockDepartureNotifier_Expecter) BroadcastBuddyArrived(ctx interface{}, sess interface{}) *mockDepartureNotifier_BroadcastBuddyArrived_Call {
-	return &mockDepartureNotifier_BroadcastBuddyArrived_Call{Call: _e.mock.On("BroadcastBuddyArrived", ctx, sess)}
+//   - screenName state.IdentScreenName
+//   - userInfo wire.TLVUserInfo
+func (_e *mockDepartureNotifier_Expecter) BroadcastBuddyArrived(ctx interface{}, screenName interface{}, userInfo interface{}) *mockDepartureNotifier_BroadcastBuddyArrived_Call {
+	return &mockDepartureNotifier_BroadcastBuddyArrived_Call{Call: _e.mock.On("BroadcastBuddyArrived", ctx, screenName, userInfo)}
 }
 
-func (_c *mockDepartureNotifier_BroadcastBuddyArrived_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockDepartureNotifier_BroadcastBuddyArrived_Call {
+func (_c *mockDepartureNotifier_BroadcastBuddyArrived_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo)) *mockDepartureNotifier_BroadcastBuddyArrived_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session))
+		run(args[0].(context.Context), args[1].(state.IdentScreenName), args[2].(wire.TLVUserInfo))
 	})
 	return _c
 }
@@ -64,7 +67,7 @@ func (_c *mockDepartureNotifier_BroadcastBuddyArrived_Call) Return(_a0 error) *m
 	return _c
 }
 
-func (_c *mockDepartureNotifier_BroadcastBuddyArrived_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockDepartureNotifier_BroadcastBuddyArrived_Call {
+func (_c *mockDepartureNotifier_BroadcastBuddyArrived_Call) RunAndReturn(run func(context.Context, state.IdentScreenName, wire.TLVUserInfo) error) *mockDepartureNotifier_BroadcastBuddyArrived_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 81 - 34
server/oscar/mock_icbm_service_test.go

@@ -183,40 +183,6 @@ func (_c *mockICBMService_ClientEvent_Call) RunAndReturn(run func(context.Contex
 	return _c
 }
 
-// DecayWarnLevel provides a mock function with given fields: ctx, sess
-func (_m *mockICBMService) DecayWarnLevel(ctx context.Context, sess *state.Session) {
-	_m.Called(ctx, sess)
-}
-
-// mockICBMService_DecayWarnLevel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DecayWarnLevel'
-type mockICBMService_DecayWarnLevel_Call struct {
-	*mock.Call
-}
-
-// DecayWarnLevel is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-func (_e *mockICBMService_Expecter) DecayWarnLevel(ctx interface{}, sess interface{}) *mockICBMService_DecayWarnLevel_Call {
-	return &mockICBMService_DecayWarnLevel_Call{Call: _e.mock.On("DecayWarnLevel", ctx, sess)}
-}
-
-func (_c *mockICBMService_DecayWarnLevel_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockICBMService_DecayWarnLevel_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session))
-	})
-	return _c
-}
-
-func (_c *mockICBMService_DecayWarnLevel_Call) Return() *mockICBMService_DecayWarnLevel_Call {
-	_c.Call.Return()
-	return _c
-}
-
-func (_c *mockICBMService_DecayWarnLevel_Call) RunAndReturn(run func(context.Context, *state.Session)) *mockICBMService_DecayWarnLevel_Call {
-	_c.Run(run)
-	return _c
-}
-
 // EvilRequest provides a mock function with given fields: ctx, sess, inFrame, inBody
 func (_m *mockICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, inFrame, inBody)
@@ -323,6 +289,87 @@ func (_c *mockICBMService_ParameterQuery_Call) RunAndReturn(run func(context.Con
 	return _c
 }
 
+// RestoreWarningLevel provides a mock function with given fields: ctx, sess
+func (_m *mockICBMService) RestoreWarningLevel(ctx context.Context, sess *state.Session) error {
+	ret := _m.Called(ctx, sess)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RestoreWarningLevel")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session) error); ok {
+		r0 = rf(ctx, sess)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockICBMService_RestoreWarningLevel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestoreWarningLevel'
+type mockICBMService_RestoreWarningLevel_Call struct {
+	*mock.Call
+}
+
+// RestoreWarningLevel is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+func (_e *mockICBMService_Expecter) RestoreWarningLevel(ctx interface{}, sess interface{}) *mockICBMService_RestoreWarningLevel_Call {
+	return &mockICBMService_RestoreWarningLevel_Call{Call: _e.mock.On("RestoreWarningLevel", ctx, sess)}
+}
+
+func (_c *mockICBMService_RestoreWarningLevel_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockICBMService_RestoreWarningLevel_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session))
+	})
+	return _c
+}
+
+func (_c *mockICBMService_RestoreWarningLevel_Call) Return(_a0 error) *mockICBMService_RestoreWarningLevel_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockICBMService_RestoreWarningLevel_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockICBMService_RestoreWarningLevel_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UpdateWarnLevel provides a mock function with given fields: ctx, sess
+func (_m *mockICBMService) UpdateWarnLevel(ctx context.Context, sess *state.Session) {
+	_m.Called(ctx, sess)
+}
+
+// mockICBMService_UpdateWarnLevel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateWarnLevel'
+type mockICBMService_UpdateWarnLevel_Call struct {
+	*mock.Call
+}
+
+// UpdateWarnLevel is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+func (_e *mockICBMService_Expecter) UpdateWarnLevel(ctx interface{}, sess interface{}) *mockICBMService_UpdateWarnLevel_Call {
+	return &mockICBMService_UpdateWarnLevel_Call{Call: _e.mock.On("UpdateWarnLevel", ctx, sess)}
+}
+
+func (_c *mockICBMService_UpdateWarnLevel_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockICBMService_UpdateWarnLevel_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session))
+	})
+	return _c
+}
+
+func (_c *mockICBMService_UpdateWarnLevel_Call) Return() *mockICBMService_UpdateWarnLevel_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockICBMService_UpdateWarnLevel_Call) RunAndReturn(run func(context.Context, *state.Session)) *mockICBMService_UpdateWarnLevel_Call {
+	_c.Run(run)
+	return _c
+}
+
 // newMockICBMService creates a new instance of mockICBMService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
 // The first argument is typically a *testing.T value.
 func newMockICBMService(t interface {

+ 6 - 0
server/oscar/server.go

@@ -34,6 +34,7 @@ func NewServer(
 	limits wire.SNACRateLimits,
 	limiter *IPRateLimiter,
 	listenerCfg []config.Listener,
+	recalcWarning func(ctx context.Context, sess *state.Session) error,
 	lowerWarnLevel func(ctx context.Context, sess *state.Session),
 ) *Server {
 	oscarSvc := oscarServer{
@@ -47,6 +48,7 @@ func NewServer(
 		RateLimitUpdater:   rateLimitUpdater,
 		SNACRateLimits:     limits,
 		IPRateLimiter:      limiter,
+		recalcWarning:      recalcWarning,
 		lowerWarnLevel:     lowerWarnLevel,
 	}
 
@@ -190,6 +192,7 @@ type oscarServer struct {
 	RateLimitUpdater
 	wire.SNACRateLimits
 	*IPRateLimiter
+	recalcWarning  func(ctx context.Context, sess *state.Session) error
 	lowerWarnLevel func(ctx context.Context, sess *state.Session)
 }
 
@@ -276,6 +279,9 @@ func (s oscarServer) connectToOSCARService(
 			sess.SetRemoteAddr(&ip)
 		}
 
+		if err := s.recalcWarning(ctx, sess); err != nil {
+			return fmt.Errorf("failed to recalculate warning level: %w", err)
+		}
 		go s.lowerWarnLevel(ctx, sess)
 		go s.receiveSessMessages(ctx, sess, flapc)
 

+ 5 - 0
server/oscar/server_test.go

@@ -55,6 +55,7 @@ func TestServer_ListenAndServeAndShutdown(t *testing.T) {
 		wire.DefaultSNACRateLimits(),
 		nil,
 		cfg,
+		func(ctx context.Context, sess *state.Session) error { return nil },
 		func(ctx context.Context, sess *state.Session) {},
 	)
 
@@ -419,6 +420,9 @@ func TestOscarServer_RouteConnection_BOS(t *testing.T) {
 		BuddyListRegistry:  buddyListRegistry,
 		ChatSessionManager: chatSessionManager,
 		DepartureNotifier:  departureNotifier,
+		recalcWarning: func(ctx context.Context, sess *state.Session) error {
+			return nil
+		},
 		lowerWarnLevel: func(ctx context.Context, sess *state.Session) {
 			defer wg.Done()
 		},
@@ -760,6 +764,7 @@ func Test_oscarServer_receiveSessMessages_BOS_integration(t *testing.T) {
 		DepartureNotifier:  departureNotifier,
 		OnlineNotifier:     onlineNotifier,
 		Logger:             slog.New(slog.NewTextHandler(io.Discard, nil)),
+		recalcWarning:      func(ctx context.Context, sess *state.Session) error { return nil },
 		lowerWarnLevel:     func(ctx context.Context, sess *state.Session) {},
 	}
 

+ 5 - 3
server/oscar/types.go

@@ -30,7 +30,7 @@ type BuddyListRegistry interface {
 // DepartureNotifier is the interface for sending buddy departure notifications
 // when a client disconnects.
 type DepartureNotifier interface {
-	BroadcastBuddyArrived(ctx context.Context, sess *state.Session) error
+	BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error
 	BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error
 }
 
@@ -66,7 +66,8 @@ type AdminService interface {
 
 type BARTService interface {
 	UpsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x02_BARTUploadQuery) (wire.SNACMessage, error)
-	RetrieveItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error)
+	RetrieveItem(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error)
+	RetrieveItemV2(ctx context.Context, frame wire.SNACFrame, body wire.SNAC_0x10_0x06_BARTDownload2Query) ([]wire.SNACMessage, error)
 }
 
 type BuddyService interface {
@@ -103,7 +104,8 @@ type ICBMService interface {
 	EvilRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x08_ICBMEvilRequest) (wire.SNACMessage, error)
 	ParameterQuery(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage
 	ClientErr(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x04_0x0B_ICBMClientErr) error
-	DecayWarnLevel(ctx context.Context, sess *state.Session)
+	RestoreWarningLevel(ctx context.Context, sess *state.Session) error
+	UpdateWarnLevel(ctx context.Context, sess *state.Session)
 }
 
 type ICQService interface {

+ 7 - 1
server/toc/server.go

@@ -127,6 +127,7 @@ func NewServer(
 	logger *slog.Logger,
 	BOSProxy OSCARProxy,
 	ipRateLimiter *IPRateLimiter,
+	recalcWarning func(ctx context.Context, sess *state.Session) error,
 	lowerWarnLevel func(ctx context.Context, sess *state.Session),
 ) *Server {
 
@@ -138,6 +139,7 @@ func NewServer(
 		listenerCfg:        listenerCfg,
 		logger:             logger,
 		loginIPRateLimiter: ipRateLimiter,
+		recalcWarning:      recalcWarning,
 		lowerWarnLevel:     lowerWarnLevel,
 		servers:            make([]*http.Server, 0, len(listenerCfg)),
 		shutdownCancel:     cancel,
@@ -163,6 +165,7 @@ type Server struct {
 	bosProxy           OSCARProxy
 	logger             *slog.Logger
 	loginIPRateLimiter *IPRateLimiter
+	recalcWarning      func(ctx context.Context, sess *state.Session) error
 	lowerWarnLevel     func(ctx context.Context, sess *state.Session)
 
 	listenerCfg []string
@@ -203,7 +206,6 @@ func (s *Server) ListenAndServe() error {
 				ctx: s.shutdownCtx,
 			}
 			if err := s.servers[i].Serve(cl); !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, io.EOF) {
-				fmt.Println("HAHA")
 				s.shutdownCancel()
 				return err
 			}
@@ -396,6 +398,10 @@ func (s *Server) handleTOCRequest(
 	chatRegistry *ChatRegistry,
 	clientFlap *wire.FlapClient,
 ) error {
+	if err := s.recalcWarning(ctx, sessBOS); err != nil {
+		return fmt.Errorf("failed to recalculate warning level: %w", err)
+	}
+
 	// TOC response queue
 	msgCh := make(chan []byte, 1)
 

+ 5 - 0
server/toc/server_test.go

@@ -26,6 +26,7 @@ func TestServer_handleTOCRequest_serverShutdown(t *testing.T) {
 		sv := Server{
 			bosProxy:       testOSCARProxy(t),
 			logger:         slog.Default(),
+			recalcWarning:  func(ctx context.Context, sess *state.Session) error { return nil },
 			lowerWarnLevel: func(ctx context.Context, sess *state.Session) {},
 		}
 
@@ -65,6 +66,7 @@ func TestServer_handleTOCRequest_clientReadDisconnect(t *testing.T) {
 		sv := Server{
 			bosProxy:       testOSCARProxy(t),
 			logger:         slog.Default(),
+			recalcWarning:  func(ctx context.Context, sess *state.Session) error { return nil },
 			lowerWarnLevel: func(ctx context.Context, sess *state.Session) {},
 		}
 		err := sv.handleTOCRequest(context.Background(), closeConn, sess, NewChatRegistry(), fc)
@@ -99,6 +101,7 @@ func TestServer_handleTOCRequest_sessClose(t *testing.T) {
 		sv := Server{
 			bosProxy:       testOSCARProxy(t),
 			logger:         slog.Default(),
+			recalcWarning:  func(ctx context.Context, sess *state.Session) error { return nil },
 			lowerWarnLevel: func(ctx context.Context, sess *state.Session) {},
 		}
 		err := sv.handleTOCRequest(context.Background(), closeConn, sess, NewChatRegistry(), fc)
@@ -132,6 +135,7 @@ func TestServer_handleTOCRequest_replyFailure(t *testing.T) {
 		sv := Server{
 			bosProxy:       testOSCARProxy(t),
 			logger:         slog.Default(),
+			recalcWarning:  func(ctx context.Context, sess *state.Session) error { return nil },
 			lowerWarnLevel: func(ctx context.Context, sess *state.Session) {},
 		}
 		err := sv.handleTOCRequest(context.Background(), closeConn, sess, NewChatRegistry(), fc)
@@ -170,6 +174,7 @@ func TestServer_handleTOCRequest_happyPath(t *testing.T) {
 		sv := Server{
 			bosProxy:       testOSCARProxy(t),
 			logger:         slog.Default(),
+			recalcWarning:  func(ctx context.Context, sess *state.Session) error { return nil },
 			lowerWarnLevel: func(ctx context.Context, sess *state.Session) {},
 		}
 		err := sv.handleTOCRequest(context.Background(), closeConn, newTestSession("me"), NewChatRegistry(), fc)

+ 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
+}

+ 5 - 0
state/migrations/0014_warn_state.down.sql

@@ -0,0 +1,5 @@
+ALTER TABLE users
+    DROP COLUMN lastWarnLevel;
+
+ALTER TABLE users
+    DROP COLUMN lastWarnUpdate;

+ 5 - 0
state/migrations/0014_warn_state.up.sql

@@ -0,0 +1,5 @@
+ALTER TABLE users
+    ADD COLUMN lastWarnUpdate DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00';
+
+ALTER TABLE users
+    ADD COLUMN lastWarnLevel INTEGER NOT NULL DEFAULT 0;

+ 1 - 0
state/migrations/0015_bart_item_type.down.sql

@@ -0,0 +1 @@
+ALTER TABLE bartItem DROP COLUMN type;

+ 1 - 0
state/migrations/0015_bart_item_type.up.sql

@@ -0,0 +1 @@
+ALTER TABLE bartItem ADD COLUMN type INTEGER NOT NULL DEFAULT 0;

+ 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);
+
+
+
+
+
+
+
+
+
+
+

Некоторые файлы не были показаны из-за большого количества измененных файлов