# ChatService - Service Documentation > Auto-generated from source code audit on 2026-03-13 ## Overview **ChatService** is a real-time chat microservice providing end-to-end encrypted (E2EE) messaging with SignalR WebSocket support and AI integration (OpenAI). It implements the X3DH key exchange protocol and supports both direct (1:1) and group conversations. - **Framework**: .NET 10.0 (C# 14) - **Architecture**: Clean Architecture + CQRS (MediatR 12.4.1) - **Database**: PostgreSQL (Neon cloud / local Docker) - **Real-time**: SignalR with Redis backplane + MessagePack protocol - **AI**: OpenAI GPT-4 streaming integration (optional) - **Port**: 5010 (development) - **Database**: `chat_service` - **Health Checks**: `/health`, `/health/live`, `/health/ready` --- ## API Endpoints ### ConversationsController (`api/[controller]`) - [Authorize] | Method | Route | Description | Request | Response | |--------|-------|-------------|---------|----------| | POST | `api/Conversations` | Create a new conversation | `CreateConversationRequest` | `CreateConversationResult` | | GET | `api/Conversations` | Get user's conversations (paginated) | `?userId={guid}&page=1&pageSize=20` | `GetConversationsResult` | | GET | `api/Conversations/{conversationId}` | Get a specific conversation | `?userId={guid}` | `ConversationDto` | ### MessagesController (`api/[controller]`) - [Authorize] | Method | Route | Description | Request | Response | |--------|-------|-------------|---------|----------| | POST | `api/Messages` | Send an encrypted message | `SendMessageRequest` | `SendMessageResult` | | GET | `api/Messages/conversation/{conversationId}` | Get messages (paginated) | `?userId={guid}&page=1&pageSize=50&before={datetime}` | `GetMessagesResult` | | POST | `api/Messages/read` | Mark messages as read | `MarkReadRequest` | `MarkMessagesReadResult` | ### KeysController (`api/[controller]`) - [Authorize] | Method | Route | Description | Request | Response | |--------|-------|-------------|---------|----------| | POST | `api/Keys/register` | Register/update E2EE key bundle | `RegisterUserKeysRequest` | `RegisterUserKeysResult` | | POST | `api/Keys/rotate` | Rotate signed pre-key | `RotatePreKeyRequest` | `RotatePreKeyResult` | | POST | `api/Keys/prekeys` | Upload one-time pre-keys | `UploadPreKeysRequest` | `UploadOneTimeKeysResult` | | GET | `api/Keys/bundle/{userId}` | Get user's key bundle for E2EE session | - | `UserKeyBundleDto` | | GET | `api/Keys/my-bundle` | Get current user's key bundle status | - | `MyKeyBundleDto` | ### SignalR Hub (`/hubs/chat`) - [Authorize] | Method | Direction | Description | |--------|-----------|-------------| | `JoinRoom(Guid roomId)` | Client -> Server | Join a conversation room | | `LeaveRoom(Guid roomId)` | Client -> Server | Leave a conversation room | | `SendMessage(Guid roomId, string content, string? messageType)` | Client -> Server | Send message to room | | `SendTypingIndicator(Guid roomId, bool isTyping)` | Client -> Server | Send typing indicator | | `MarkMessageRead(Guid roomId, Guid messageId)` | Client -> Server | Mark message as read | | `StreamAIResponse(string prompt, Guid roomId)` | Client -> Server (streaming) | Stream AI response to caller | | `ReceiveMessage(MessageNotification)` | Server -> Client | Receive new message | | `UserJoined(userId, userName, roomId)` | Server -> Client | User joined room notification | | `UserLeft(userId, roomId)` | Server -> Client | User left room notification | | `ReceiveAIChunk(chunk, messageId)` | Server -> Client | Receive AI streaming chunk | | `AIResponseComplete(messageId, fullResponse)` | Server -> Client | AI response complete notification | | `TypingIndicator(userId, userName, roomId, isTyping)` | Server -> Client | Typing indicator | | `MessageRead(userId, messageId, roomId)` | Server -> Client | Read receipt | | `UserStatusChanged(userId, isOnline, lastSeen)` | Server -> Client | Online/offline status change | **AI Trigger**: Messages starting with `@gpt ` auto-trigger AI streaming response to the room. **Connection Lifecycle**: On connect, user auto-joins up to 100 conversation rooms. On disconnect (last connection), broadcasts offline status. --- ## Commands ### CreateConversationCommand - **Input**: `CreatorId (Guid)`, `ParticipantIds (IEnumerable)`, `Name? (string)`, `AvatarUrl? (string)`, `IsGroup (bool)` - **Logic**: Validates all participants exist as ChatUsers. For direct (1:1), checks for existing conversation and returns it if found. For group, creator becomes admin. Saves via UnitOfWork. - **Output**: `CreateConversationResult { ConversationId, Type, Name, Participants, CreatedAt }` - **Domain Events**: `ConversationCreatedDomainEvent` - **Validator**: `CreateConversationCommandValidator` -- CreatorId required; ParticipantIds not empty + unique; group requires name (max 100 chars) + >=1 participant; direct requires exactly 1 participant; AvatarUrl max 500 chars ### SendMessageCommand - **Input**: `ConversationId (Guid)`, `SenderId (Guid)`, `EncryptedContent (string)`, `Nonce (string)`, `AuthTag? (string)`, `MessageType (string, default "text")`, `Metadata? (string)`, `ReplyToMessageId? (Guid)` - **Logic**: Loads conversation, parses MessageType from name, calls `conversation.SendMessage()` which validates sender is participant. Updates conversation's LastMessageId/LastMessageAt. - **Output**: `SendMessageResult { MessageId, ConversationId, SenderId, Status, CreatedAt }` - **Domain Events**: `MessageSentDomainEvent` - **Validator**: `SendMessageCommandValidator` -- ConversationId + SenderId required; EncryptedContent required (max 100000 chars); Nonce required (max 100); AuthTag max 100; MessageType must be one of: text, image, video, audio, file, location, contact, sticker, system; Metadata max 10000 ### MarkMessagesReadCommand - **Input**: `ConversationId (Guid)`, `UserId (Guid)`, `LastReadMessageId? (Guid)`, `ReadUpTo? (DateTime)` - **Logic**: Loads conversation with messages. Finds participant. Marks read by: (1) specific message ID, (2) timestamp, or (3) all messages. Updates participant's LastReadMessageId and LastReadAt. - **Output**: `MarkMessagesReadResult { MessagesMarked, LastReadAt }` - **Validator**: `MarkMessagesReadCommandValidator` -- ConversationId + UserId required; all mark scenarios valid ### RegisterUserKeysCommand - **Input**: `IdentityUserId (string)`, `DisplayName (string)`, `AvatarUrl? (string)`, `IdentityPublicKey (string)`, `SignedPreKey (string)`, `SignedPreKeySignature (string)`, `OneTimePreKeys? (IEnumerable)` - **Logic**: Checks if ChatUser exists by IdentityUserId. If exists, updates key bundle and uploads pre-keys. If not, creates new ChatUser, registers key bundle, uploads pre-keys. - **Output**: `RegisterUserKeysResult { ChatUserId, OneTimePreKeysUploaded }` - **Domain Events**: `ChatUserCreatedDomainEvent` (new user), `UserKeyBundleUpdatedDomainEvent` (key registration) - **Validator**: `RegisterUserKeysCommandValidator` -- IdentityUserId required; DisplayName required (max 255); IdentityPublicKey, SignedPreKey, SignedPreKeySignature required + valid Base64; OneTimePreKeys: KeyId >= 0, PublicKey required + valid Base64 ### RotatePreKeyCommand - **Input**: `ChatUserId (Guid)`, `NewSignedPreKey (string)`, `NewSignedPreKeySignature (string)` - **Logic**: Loads ChatUser, calls `user.RotateSignedPreKey()` which creates a new UserKeyBundle with updated SignedPreKey/Signature and current timestamp. - **Output**: `RotatePreKeyResult { RotatedAt }` - **Domain Events**: `UserKeyBundleUpdatedDomainEvent` - **Validator**: `RotatePreKeyCommandValidator` -- ChatUserId required; NewSignedPreKey + NewSignedPreKeySignature required + valid Base64 ### UploadOneTimeKeysCommand - **Input**: `ChatUserId (Guid)`, `OneTimePreKeys (IEnumerable)` - **Logic**: Loads ChatUser with keys, calls `user.UploadOneTimePreKeys()` which adds non-duplicate keys. - **Output**: `UploadOneTimeKeysResult { KeysUploaded, TotalAvailableKeys }` - **Validator**: `UploadOneTimeKeysCommandValidator` -- ChatUserId required; at least 1 pre-key required; max 100 pre-keys per upload; each key: KeyId >= 0, PublicKey required + valid Base64 --- ## Queries ### GetConversationsQuery - **Input**: `UserId (Guid)`, `Page (int, default 1)`, `PageSize (int, default 20)` - **Logic**: Gets conversations where user is active participant, ordered by LastMessageAt desc. For each conversation, fetches participant user info, unread count, and last message details. - **Output**: `GetConversationsResult { Conversations (ConversationDto[]), TotalCount, Page, PageSize }` ### GetMessagesQuery - **Input**: `ConversationId (Guid)`, `UserId (Guid)`, `Page (int, default 1)`, `PageSize (int, default 50)`, `Before? (DateTime)` - **Logic**: Verifies user is participant (throws UnauthorizedAccessException if not). Fetches messages paginated with sender info. - **Output**: `GetMessagesResult { Messages (MessageDto[]), TotalCount, Page, PageSize, HasMore }` ### GetUserKeyBundleQuery - **Input**: `TargetUserId (Guid)` - **Logic**: Gets key bundle for target user and **consumes** one one-time pre-key (marks as used). Saves the consumed state. - **Output**: `UserKeyBundleDto { UserId, IdentityPublicKey, SignedPreKey, SignedPreKeySignature, SignedPreKeyTimestamp, OneTimePreKey? }` or null ### GetMyKeyBundleQuery - **Input**: `IdentityUserId (string)` - **Logic**: Looks up ChatUser by identity user ID. Returns key bundle status including rotation needs check (>30 days). - **Output**: `MyKeyBundleDto { ChatUserId, HasKeyBundle, SignedPreKeyTimestamp, NeedsKeyRotation, AvailableOneTimeKeys }` or null --- ## Domain Model ### Conversation (Aggregate Root) **Table**: `conversations` **Fields**: - `Id (Guid)` - PK - `_typeId (int)` - FK to conversation_types - `_type (ConversationType)` - Direct (1) or Group (2) - `_name (string?)` - Conversation name (required for group) - `_avatarUrl (string?)` - Group avatar URL - `_participants (List)` - Participants collection - `_messages (List)` - Messages collection - `_lastMessageId (Guid?)` - Last message ID - `_lastMessageAt (DateTime?)` - Last message timestamp - `_createdAt (DateTime)` - Created timestamp - `_updatedAt (DateTime)` - Updated timestamp **Factory Methods**: - `CreateDirect(Guid user1Id, Guid user2Id)` - Creates 1:1 conversation, raises ConversationCreatedDomainEvent - `CreateGroup(string name, Guid creatorId, IEnumerable participantIds, string? avatarUrl)` - Creates group chat, creator is admin, raises ConversationCreatedDomainEvent **Behavior Methods**: - `SendMessage(senderId, encryptedContent, nonce, type?, authTag?, metadata?, replyToMessageId?)` - Validates sender is participant, creates Message, updates last message info, raises MessageSentDomainEvent - `AddParticipant(userId, addedByUserId)` - Group only, admin-only - `RemoveParticipant(userId, removedByUserId)` - Group only, self or admin-only - `UpdateName(name, updatedByUserId)` - Group only, admin-only - `UpdateAvatar(avatarUrl, updatedByUserId)` - Group only, admin-only - `GetActiveParticipantCount()` - Count active participants - `IsParticipant(userId)` - Check membership - `GetOtherParticipant(userId)` - Get other user in direct conversation **Domain Events**: `ConversationCreatedDomainEvent`, `MessageSentDomainEvent` ### ConversationParticipant (Entity) **Table**: `conversation_participants` **Fields**: - `Id (Guid)` - PK - `ConversationId (Guid)` - FK to conversations - `UserId (Guid)` - FK reference to chat_users - `JoinedAt (DateTime)` - Join timestamp - `LastReadAt (DateTime?)` - Last read timestamp - `LastReadMessageId (Guid?)` - Last read message ID - `IsAdmin (bool)` - Admin flag (group chats) - `IsMuted (bool)` - Mute notifications flag - `LeftAt (DateTime?)` - Leave timestamp (null = active) **Methods**: `UpdateLastRead(messageId)`, `SetMuted(bool)`, `SetAdmin(bool)`, `Leave()`, `IsActive` (computed) ### Message (Entity) **Table**: `messages` **Fields**: - `Id (Guid)` - PK - `_conversationId (Guid)` - FK to conversations - `_senderId (Guid)` - Sender chat user ID - `_encryptedContent (string)` - E2EE encrypted content (Base64, AES-256-GCM) - `_nonce (string)` - Nonce/IV for AES-GCM (Base64) - `_authTag (string?)` - Authentication tag (Base64) - `_typeId (int)` - FK to message_types - `_type (MessageType)` - Text(1), Image(2), File(3), Voice(4), System(5) - `_statusId (int)` - FK to message_statuses - `_status (MessageStatus)` - Sent(1), Delivered(2), Read(3), Failed(4) - `_createdAt (DateTime)` - Sent timestamp - `_updatedAt (DateTime?)` - Updated timestamp - `_deliveredAt (DateTime?)` - Delivered timestamp - `_readAt (DateTime?)` - Read timestamp - `_metadata (string?)` - Optional JSON metadata - `_replyToMessageId (Guid?)` - Reply thread reference **Methods**: `MarkAsDelivered()`, `MarkAsRead()`, `MarkAsFailed()` ### ChatUser (Aggregate Root) **Table**: `chat_users` **Fields**: - `Id (Guid)` - PK - `_identityUserId (string)` - FK to IAM Service user ID (unique index) - `_displayName (string)` - Display name - `_avatarUrl (string?)` - Avatar URL - `_statusId (int)` - FK to user_statuses - `_status (UserStatus)` - Offline(1), Online(2), Away(3), DoNotDisturb(4) - `_keyBundle (UserKeyBundle?)` - E2EE public key bundle (owned entity) - `_oneTimePreKeys (List)` - One-time pre-keys for X3DH - `_lastSeenAt (DateTime)` - Last activity timestamp - `_createdAt (DateTime)` - Created timestamp - `_updatedAt (DateTime)` - Updated timestamp **Methods**: - `RegisterKeyBundle(identityPublicKey, signedPreKey, signedPreKeySignature)` - Register/update key bundle, raises UserKeyBundleUpdatedDomainEvent - `RotateSignedPreKey(newSignedPreKey, newSignature)` - Rotate signed pre-key, raises UserKeyBundleUpdatedDomainEvent - `UploadOneTimePreKeys(IEnumerable<(keyId, publicKey)>)` - Add one-time pre-keys (skips duplicates) - `ConsumeOneTimePreKey()` - Consume first available one-time pre-key - `GetAvailableOneTimePreKeyCount()` - Count unused one-time pre-keys - `UpdateDisplayName(displayName)`, `UpdateAvatarUrl(avatarUrl)`, `SetOnlineStatus(status)`, `UpdateLastSeen()` **Domain Events**: `ChatUserCreatedDomainEvent`, `UserKeyBundleUpdatedDomainEvent` ### UserKeyBundle (Value Object, Owned Entity) Stored inline in `chat_users` table. **Fields**: - `IdentityPublicKey (string)` - Long-term identity public key (Curve25519) - `SignedPreKey (string)` - Signed pre-key (rotated periodically) - `SignedPreKeySignature (string)` - Signature of signed pre-key - `SignedPreKeyTimestamp (DateTime)` - When signed pre-key was generated **Methods**: `RotateSignedPreKey(newKey, newSignature)` - Returns new UserKeyBundle, `NeedsRotation(maxAgeDays=30)` - Check if rotation needed ### OneTimePreKey (Entity) **Table**: `one_time_pre_keys` **Fields**: - `Id (Guid)` - PK - `UserId (Guid)` - FK to chat_users - `KeyId (int)` - Client-assigned key identifier - `PublicKey (string)` - Curve25519 public key - `IsUsed (bool)` - Consumed flag - `CreatedAt (DateTime)` - Created timestamp - `UsedAt (DateTime?)` - Consumed timestamp **Methods**: `MarkAsUsed()` - Marks key as consumed (one-time use) ### Enumerations | Enumeration | Table | Values | |-------------|-------|--------| | ConversationType | `conversation_types` | Direct (1), Group (2) | | MessageType | `message_types` | Text (1), Image (2), File (3), Voice (4), System (5) | | MessageStatus | `message_statuses` | Sent (1), Delivered (2), Read (3), Failed (4) | | UserStatus | `user_statuses` | Offline (1), Online (2), Away (3), DoNotDisturb (4) | ### Domain Events | Event | Trigger | |-------|---------| | `ConversationCreatedDomainEvent` | New conversation created (direct or group) | | `MessageSentDomainEvent` | Message sent in conversation | | `MessageDeliveredDomainEvent` | Message delivered to recipient | | `MessageReadDomainEvent` | Message read by recipient | | `UserJoinedRoomDomainEvent` | User joined conversation room | | `UserLeftRoomDomainEvent` | User left conversation room | | `TypingDomainEvent` | User typing indicator | | `ChatUserCreatedDomainEvent` | New ChatUser created | | `UserKeyBundleUpdatedDomainEvent` | User's E2EE key bundle updated | --- ## Database Schema ### Tables #### `chat_users` | Column | Type | Constraints | |--------|------|-------------| | `id` | uuid | PK, not auto-generated | | `identity_user_id` | varchar(256) | Required, UNIQUE INDEX | | `display_name` | varchar(256) | Required | | `avatar_url` | varchar(2048) | Nullable | | `status_id` | int | Required, FK -> user_statuses | | `identity_public_key` | varchar(1024) | Nullable (owned: KeyBundle) | | `signed_pre_key` | varchar(1024) | Nullable (owned: KeyBundle) | | `signed_pre_key_signature` | varchar(1024) | Nullable (owned: KeyBundle) | | `signed_pre_key_timestamp` | timestamp | Nullable (owned: KeyBundle) | | `last_seen_at` | timestamp | | | `created_at` | timestamp | Required | | `updated_at` | timestamp | Required | #### `one_time_pre_keys` | Column | Type | Constraints | |--------|------|-------------| | `id` | uuid | PK | | `user_id` | uuid | Required, FK -> chat_users (CASCADE) | | `key_id` | int | Required | | `public_key` | varchar(1024) | Required | | `is_used` | bool | Required | | `created_at` | timestamp | Required | | `used_at` | timestamp | Nullable | #### `conversations` | Column | Type | Constraints | |--------|------|-------------| | `id` | uuid | PK | | `type_id` | int | Required, FK -> conversation_types | | `name` | varchar(256) | Nullable | | `avatar_url` | varchar(2048) | Nullable | | `last_message_id` | uuid | Nullable | | `last_message_at` | timestamp | Nullable | | `created_at` | timestamp | Required | | `updated_at` | timestamp | Required | #### `conversation_participants` | Column | Type | Constraints | |--------|------|-------------| | `id` | uuid | PK | | `conversation_id` | uuid | Required, FK -> conversations (CASCADE) | | `user_id` | uuid | Required | | `joined_at` | timestamp | Required | | `last_read_at` | timestamp | Nullable | | `is_admin` | bool | Required | | `is_muted` | bool | Required | | `left_at` | timestamp | Nullable | #### `messages` | Column | Type | Constraints | |--------|------|-------------| | `id` | uuid | PK | | `conversation_id` | uuid | Required, FK -> conversations (CASCADE) | | `sender_id` | uuid | Required | | `encrypted_content` | text | Required | | `nonce` | varchar(256) | Required | | `auth_tag` | varchar(256) | Nullable | | `type_id` | int | Required, FK -> message_types | | `status_id` | int | Required, FK -> message_statuses | | `metadata` | text | Nullable | | `reply_to_message_id` | uuid | Nullable | | `created_at` | timestamp | Required | | `updated_at` | timestamp | Nullable | #### Enumeration Lookup Tables - `conversation_types` (id, name) - Seeded: direct, group - `message_types` (id, name) - Seeded: text, image, file, voice, system - `message_statuses` (id, name) - Seeded: sent, delivered, read, failed - `user_statuses` (id, name) - Seeded: offline, online, away, donotdisturb ### Indexes | Table | Index | Columns | |-------|-------|---------| | `chat_users` | Unique | `identity_user_id` | | `one_time_pre_keys` | Composite | `user_id, is_used` | | `conversations` | Single | `last_message_at` | | `conversation_participants` | Composite | `conversation_id, user_id` | | `conversation_participants` | Single | `user_id` | | `messages` | Composite | `conversation_id, created_at` | | `messages` | Single | `sender_id` | ### Migration - `20260115165500_InitialCreate` - Creates all tables, indexes, and seed data --- ## Dependencies ### NuGet Packages **API Layer**: - MediatR 12.4.1 - FluentValidation 11.11.0 + DI Extensions - Swashbuckle.AspNetCore 7.2.0 - Asp.Versioning.Mvc 8.1.0 - AspNetCore.HealthChecks.NpgSql 8.0.2 - AspNetCore.HealthChecks.Redis 8.0.1 - Hellang.Middleware.ProblemDetails 6.5.1 - Serilog.AspNetCore 8.0.3, Serilog.Sinks.Console 6.0.0, Serilog.Sinks.Seq 8.0.0 - Microsoft.AspNetCore.SignalR.StackExchangeRedis 9.0.0 - Microsoft.AspNetCore.SignalR.Protocols.MessagePack 9.0.0 - Microsoft.EntityFrameworkCore.Design 10.0.0 **Domain Layer**: - MediatR.Contracts 2.0.1 (only dependency) **Infrastructure Layer**: - Microsoft.EntityFrameworkCore 10.0.0 - Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0 - MediatR 12.4.1 - Dapper 2.1.35 - Polly 8.5.0 + Microsoft.Extensions.Http.Polly 9.0.0 - StackExchange.Redis 2.8.16 ### Service Dependencies - **IAM Service**: User identity (IdentityUserId links to IAM user ID) - **Redis**: SignalR backplane + channel prefix "ChatService" - **PostgreSQL**: Primary data store - **OpenAI API** (optional): AI chat assistant via `OPENAI_API_KEY` env var --- ## Configuration ### appsettings.json ```json { "ConnectionStrings": { "DefaultConnection": "Host=...;Database=chat_service;...", "Redis": "localhost:6379" }, "SignalR": { "EnableMessagePack": true, "StatefulReconnectBufferSize": 32768, "KeepAliveInterval": 15, "ClientTimeoutInterval": 30 }, "AI": { "Provider": "OpenAI", "Model": "gpt-4", "MaxHistoryMessages": 20, "MaxTokens": 1000, "Temperature": 0.7, "SystemPrompt": "You are a helpful assistant..." }, "Jwt": { "Secret": "...", "Issuer": "goodgo-platform", "Audience": "goodgo-services", "AccessTokenExpiryMinutes": 15, "RefreshTokenExpiryDays": 7 } } ``` ### Environment Variables - `DATABASE_URL` - Alternative to ConnectionStrings:DefaultConnection - `OPENAI_API_KEY` - Required for AI features (falls back to NullAIService if absent) - `ASPNETCORE_ENVIRONMENT` - Development enables Swagger, sensitive data logging, detailed errors ### MediatR Pipeline 1. `LoggingBehavior` - Logs request name + elapsed time (ms) 2. `ValidatorBehavior` - FluentValidation in pipeline (throws ValidationException) 3. `TransactionBehavior` - Auto-wraps Commands in transactions (skips Queries), uses ExecutionStrategy with retry ### DI Registration (DependencyInjection.cs) - `ChatServiceContext` (DbContext, Npgsql, retry 5x / 30s delay) - `IChatUserRepository` -> `ChatUserRepository` (scoped) - `IConversationRepository` -> `ConversationRepository` (scoped) - `IRequestManager` -> `RequestManager` (scoped) - `IAIService` -> `AIService` (HttpClient) or `NullAIService` (singleton fallback) - `IUserIdProvider` -> `ClaimsUserIdProvider` (singleton) ### SignalR Configuration - Redis backplane with channel prefix "ChatService" - MessagePack protocol enabled by default - Stateful reconnect enabled (buffer 32KB) - KeepAlive: 15s, Client timeout: 30s - Hub endpoint: `/hubs/chat` with AllowStatefulReconnects=true ### CORS - Configured origins from `AllowedOrigins` config section - Defaults: `http://localhost:3000`, `http://localhost:5173` - AllowCredentials required for SignalR --- ## Tests ### Unit Tests (`tests/ChatService.UnitTests/`) - `Application/Hubs/ClaimsUserIdProviderTests.cs` - `Domain/Contracts/AIServiceContractTests.cs` - `Domain/Contracts/ChatHubClientTests.cs` - `Domain/Events/ConversationDomainEventsTests.cs` - `Infrastructure/Services/AIServiceTests.cs` ### Functional Tests (`tests/ChatService.FunctionalTests/`) - `Controllers/ConversationsControllerTests.cs` - `CustomWebApplicationFactory.cs` (InMemory DB swap) --- ## Architecture Notes 1. **E2EE Protocol**: Implements X3DH (Extended Triple Diffie-Hellman) key exchange. Server stores ONLY public keys and encrypted content -- cannot decrypt without client's private key. Keys are Curve25519, encryption is AES-256-GCM. 2. **Key Rotation**: Signed pre-key should be rotated every 30 days (`NeedsRotation()` check). One-time pre-keys are consumed on first use for forward secrecy. 3. **User ID Provider**: `ClaimsUserIdProvider` extracts user ID from JWT claims (NameIdentifier, "sub", or "user_id") for SignalR user targeting. 4. **Connection Tracking**: Static `Dictionary>` tracks multiple connections per user (multi-device). Online/offline status broadcast only on first/last connection. 5. **AI Integration**: Triggered by `@gpt ` prefix in hub messages. Streams via OpenAI SSE. Includes conversation history (last 20 messages) for context. Falls back to `NullAIService` when API key not configured. 6. **Idempotency**: `RequestManager` + `ClientRequest` entity available but not actively wired into controllers/commands (infrastructure ready). 7. **Note**: Controllers use `api/[controller]` routing (no versioned prefix `api/v{version}`) -- differs from platform convention.