Files
pos-system/services/chat-service-net/SERVICE_DOCS.md
Ho Ngoc Hai f3779c4ebe docs: add SERVICE_DOCS.md for all 24 microservices from per-service code audit
Each SERVICE_DOCS.md documents: Overview, API Endpoints, Commands, Queries,
Domain Model, Database Schema, Integration Events, Dependencies, Configuration.
Generated by 23 parallel audit agents reading actual source code.

Key corrections from audit:
- inventory-service: 12 commands/6 queries (was listed as scaffold)
- promotion-service: 12 commands/10 queries (was listed as 0)
- mission-service: 4 commands/7 queries (was listed as 0)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:54:53 +07:00

24 KiB

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<Guid>), 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<OneTimePreKeyDto>)
  • 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<OneTimePreKeyDto>)
  • 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<ConversationParticipant>) - Participants collection
  • _messages (List<Message>) - 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<Guid> 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<OneTimePreKey>) - 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

{
  "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<string, HashSet<string>> 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.