diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..61b70ec
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,17 @@
+.git/
+.github/
+.vs/
+**/bin/
+**/obj/
+**/TestResults/
+coverage*/
+tests/
+docs/
+scripts/
+*.md
+*.suo
+*.user
+*.log
+src/SimPle.Api/.env
+src/SimPle.Api/appsettings.Development.json
+src/SimPle.Api/appsettings.Local.json
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..2eeff3b
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,12 @@
+version: 2
+updates:
+ - package-ecosystem: nuget
+ directory: "/"
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
+ - package-ecosystem: github-actions
+ directory: "/"
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0bd216a..75f48bb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,30 +6,138 @@ on:
pull_request:
branches: ["main"]
+# Keep the normal validation token read-only. Artifact provenance gets its write
+# permissions only in the dedicated, push-only job below.
+permissions:
+ contents: read
+
jobs:
backend:
- name: Build, test, vulnerability scan
+ name: Build, test, package, and container smoke
runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: postgres
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres -d postgres"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 12
+
+ env:
+ MIGRATION_TEST_CONNECTION_STRING: Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- - name: Set up .NET 8
- uses: actions/setup-dotnet@v4
+ - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
with:
dotnet-version: "8.0.x"
- name: Restore
- run: dotnet restore
+ # NuGet lock files are not in the repository yet. Do not use
+ # --locked-mode until they are intentionally committed, or every CI
+ # run would fail before it can test the application.
+ run: dotnet restore SimPle.sln
- name: Build
- run: dotnet build --no-restore --configuration Release
+ run: dotnet build SimPle.sln --no-restore --configuration Release
- name: Unit tests
- run: dotnet test tests/SimPle.UnitTests/SimPle.UnitTests.csproj --no-build --configuration Release
+ run: dotnet test tests/SimPle.UnitTests/SimPle.UnitTests.csproj --no-build --configuration Release --logger "trx;LogFileName=unit.trx" --results-directory TestResults
+
+ - name: Integration and real-PostgreSQL migration tests
+ run: dotnet test tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj --no-build --configuration Release --logger "trx;LogFileName=integration.trx" --results-directory TestResults
+
+ - name: Fail on high or critical NuGet vulnerabilities
+ shell: bash
+ run: |
+ set -o pipefail
+ dotnet list SimPle.sln package --vulnerable --include-transitive | tee TestResults/nuget-vulnerabilities.txt
+ if grep -Eq ' (High|Critical) ' TestResults/nuget-vulnerabilities.txt; then
+ echo "High or critical NuGet vulnerability found. Update it or add an owned, time-bounded waiver."
+ exit 1
+ fi
+
+ - name: Generate CycloneDX SBOM
+ run: |
+ dotnet tool install --tool-path .tools cyclonedx --version 6.2.0
+ ./.tools/dotnet-CycloneDX SimPle.sln --output artifacts/sbom --json
+
+ - name: Build container image
+ run: docker build --tag simple-backend:${{ github.sha }} .
+
+ - name: Smoke the non-root, read-only container
+ shell: bash
+ run: |
+ set -euo pipefail
+ # Liveness deliberately proves only that the process serves HTTP. The
+ # integration job above owns real PostgreSQL migration verification;
+ # readiness is expected to stay unhealthy until a migration job runs.
+ docker run --detach --name simple-backend-smoke --network host \
+ --read-only --tmpfs /tmp:rw,nosuid,nodev,noexec \
+ -e 'ConnectionStrings__DefaultConnection=Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres' \
+ -e 'Jwt__SecretKey=ci-smoke-jwt-secret-that-is-at-least-thirty-two-characters' \
+ -e 'LobbyCredential__Key=ci-smoke-lobby-key-that-is-at-least-thirty-two-characters' \
+ -e 'Recaptcha__SecretKey=ci-smoke-recaptcha-secret' \
+ -e 'Google__ClientId=ci-smoke.apps.googleusercontent.com' \
+ -e 'Email__SmtpHost=localhost' \
+ -e 'Email__Username=ci-smoke@example.test' \
+ -e 'Email__Password=ci-smoke-password' \
+ -e 'Email__From=ci-smoke@example.test' \
+ -e 'Google__ClientId=ci-smoke-google-client-id' \
+ -e 'Storage__Provider=S3Compatible' \
+ -e 'Storage__BucketName=ci-smoke-assets' \
+ -e 'Storage__Region=us-east-1' \
+ -e 'Storage__AccessKey=ci-smoke-access-key' \
+ -e 'Storage__SecretKey=ci-smoke-secret-key' \
+ -e 'Storage__ProfilePrefix=profile-assets' \
+ simple-backend:${{ github.sha }}
+ for attempt in {1..30}; do
+ if curl --fail --silent --show-error http://127.0.0.1:8080/health/live | grep -qx '{"status":"healthy"}'; then
+ docker exec simple-backend-smoke id | grep -q 'uid=1654(app)'
+ exit 0
+ fi
+ sleep 1
+ done
+ docker logs simple-backend-smoke
+ exit 1
- - name: Integration tests
- run: dotnet test tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj --no-build --configuration Release
+ - name: Remove smoke container
+ if: always()
+ run: docker rm --force simple-backend-smoke || true
- - name: NuGet vulnerability scan
- run: dotnet list package --vulnerable
+ - name: Upload test and SBOM evidence
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: backend-evidence-${{ github.sha }}
+ path: |
+ TestResults/
+ artifacts/sbom/
+ if-no-files-found: error
+ retention-days: 30
+
+ attest-sbom:
+ name: Attest backend SBOM
+ needs: backend
+ if: github.event_name == 'push'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ attestations: write
+ id-token: write
+ steps:
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ with:
+ name: backend-evidence-${{ github.sha }}
+ path: evidence
+
+ - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
+ with:
+ subject-path: evidence/artifacts/sbom/*.json
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..034c394
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,29 @@
+name: CodeQL
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ branches: ["main"]
+ schedule:
+ - cron: "23 4 * * 1"
+
+permissions:
+ contents: read
+ security-events: write
+
+jobs:
+ analyze:
+ name: Analyze C#
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
+ with:
+ languages: csharp
+ build-mode: manual
+ - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
+ with:
+ dotnet-version: "8.0.x"
+ - run: dotnet build SimPle.sln --configuration Release
+ - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml
new file mode 100644
index 0000000..679d589
--- /dev/null
+++ b/.github/workflows/publish-image.yml
@@ -0,0 +1,53 @@
+name: Publish immutable backend image
+
+on:
+ workflow_dispatch:
+
+# Run this only for an already-green commit on main. The resulting digest and
+# provenance attestation are release evidence; a mutable tag is never enough.
+permissions:
+ contents: read
+ packages: write
+ attestations: write
+ id-token: write
+
+jobs:
+ publish:
+ name: Publish and attest backend image
+ if: github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ env:
+ IMAGE_NAME: ghcr.io/simpleplatform/simple-backend
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+ - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+
+ - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push immutable candidate
+ id: push
+ uses: docker/build-push-action@ee4ca427a2f43b6a16632044ca514c076267da23 # v6.19.0
+ with:
+ context: .
+ push: true
+ tags: ${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
+ labels: |
+ org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
+ org.opencontainers.image.revision=${{ github.sha }}
+
+ - name: Attest image build provenance
+ uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
+ with:
+ subject-name: ${{ env.IMAGE_NAME }}
+ subject-digest: ${{ steps.push.outputs.digest }}
+ push-to-registry: true
+
+ - name: Record digest in job summary
+ run: |
+ echo '### Immutable backend image' >> "$GITHUB_STEP_SUMMARY"
+ echo "\`${IMAGE_NAME}@${{ steps.push.outputs.digest }}\`" >> "$GITHUB_STEP_SUMMARY"
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..c9511c3
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,30 @@
+# syntax=docker/dockerfile:1
+
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+WORKDIR /src
+
+COPY ["src/SimPle.Api/SimPle.Api.csproj", "src/SimPle.Api/"]
+COPY ["src/SimPle.Application/SimPle.Application.csproj", "src/SimPle.Application/"]
+COPY ["src/SimPle.Domain/SimPle.Domain.csproj", "src/SimPle.Domain/"]
+COPY ["src/SimPle.Infrastructure/SimPle.Infrastructure.csproj", "src/SimPle.Infrastructure/"]
+COPY ["src/SimPle.Shared/SimPle.Shared.csproj", "src/SimPle.Shared/"]
+RUN dotnet restore "src/SimPle.Api/SimPle.Api.csproj"
+
+COPY src/ ./src/
+RUN dotnet publish "src/SimPle.Api/SimPle.Api.csproj" --configuration Release --no-restore --output /app/publish /p:UseAppHost=false
+
+FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
+WORKDIR /app
+
+# Container Apps probes this port. Secrets are injected by the platform as environment variables, never copied here.
+ENV ASPNETCORE_URLS=http://+:8080 \
+ ASPNETCORE_ENVIRONMENT=Production \
+ DOTNET_EnableDiagnostics=0
+EXPOSE 8080
+
+COPY --from=build /app/publish ./
+
+# The official ASP.NET image supplies this non-root user (UID 1654). The application does not require a writable
+# filesystem, allowing the deployment manifest to set a read-only root filesystem as an additional hardening layer.
+USER $APP_UID
+ENTRYPOINT ["dotnet", "SimPle.Api.dll"]
diff --git a/src/SimPle.Api/Controllers/ChatController.cs b/src/SimPle.Api/Controllers/ChatController.cs
new file mode 100644
index 0000000..e70894f
--- /dev/null
+++ b/src/SimPle.Api/Controllers/ChatController.cs
@@ -0,0 +1,127 @@
+using System.IdentityModel.Tokens.Jwt;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using SimPle.Api.Models;
+using SimPle.Application.Chat;
+using SimPle.Application.Realtime.Contracts;
+using SimPle.Shared.Common;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace SimPle.Api.Controllers;
+
+///
+/// The Module 7 chat REST surface (docs/specs/module-07-realtime-presence-chat-spec.md, "REST"). Only history and
+/// delete are REST routes — sending a message is hub-only (RealtimeHub.SendLobbyMessage); there is no REST
+/// send endpoint in the approved API contract, so none is added here.
+///
+/// Follows 's conventions: the actor is always the JWT sub claim, state-
+/// changing routes require the X-Requested-With: XMLHttpRequest CSRF header, and privacy-safe 404s collapse
+/// missing/unauthorized/nonexistent into the identical .
+///
+[ApiController]
+[Route("api/chat")]
+[Authorize]
+[Produces("application/json")]
+[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status500InternalServerError)]
+public sealed class ChatController : ControllerBase
+{
+ private readonly IChatService _chat;
+
+ public ChatController(IChatService chat)
+ {
+ _chat = chat;
+ }
+
+ [HttpGet("lobbies/{lobbyId:guid}/messages")]
+ [SwaggerOperation(
+ Summary = "Lobby chat history (keyset cursor paged)",
+ Description = "limit default 30, cap 50. Ordered (createdAt, id). direction: before (scrollback, default) " +
+ "| after (reconnect repair). A private or foreign lobby is a privacy-safe Chat.NotFound.",
+ OperationId = "Chat_GetHistory", Tags = new[] { "Chat" })]
+ [ProducesResponseType(typeof(CursorPage), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ public async Task GetHistory(
+ [FromRoute] Guid lobbyId,
+ [FromQuery] string? cursor,
+ [FromQuery] string direction = "before",
+ [FromQuery] int? limit = null,
+ CancellationToken ct = default)
+ {
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ if (!Enum.TryParse(direction, ignoreCase: true, out var parsedDirection))
+ return BadRequest(Error(ChatErrors.ValidationFailed, "direction must be 'before' or 'after'."));
+
+ Response.Headers.CacheControl = "private, no-store";
+ var result = await _chat.GetHistoryAsync(userId, lobbyId, parsedDirection, cursor, limit, ct);
+ return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
+ }
+
+ [HttpDelete("messages/{messageId:guid}")]
+ [SwaggerOperation(
+ Summary = "Author: delete a chat message",
+ Description = "Produces a tombstone (body cleared, Deleted=true) and fans out ChatMessageDeleted. A retried " +
+ "delete on an already-deleted message is idempotent and replays the original DeletedAtUtc.",
+ OperationId = "Chat_DeleteMessage", Tags = new[] { "Chat" })]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ public async Task DeleteMessage([FromRoute] Guid messageId, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ var result = await _chat.DeleteAsync(userId, messageId, ct);
+ return result.IsSuccess ? NoContent() : MapError(result.Error!);
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ private const string CsrfHeader = "X-Requested-With";
+ private const string CsrfHeaderValue = "XMLHttpRequest";
+
+ private bool HasCsrfHeader() =>
+ string.Equals(Request.Headers[CsrfHeader], CsrfHeaderValue, StringComparison.Ordinal);
+
+ private IActionResult MissingCsrfHeader() => BadRequest(Error(
+ "Auth.CsrfHeaderRequired",
+ $"The {CsrfHeader} header is required for this request."));
+
+ private bool TryGetUserId(out Guid userId) =>
+ Guid.TryParse(User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value, out userId);
+
+ /// Maps the M07-B2 chat error catalogue to HTTP. Only (not the
+ /// author on delete) is a 403; everything else that would otherwise disclose existence collapses to 404.
+ private IActionResult MapError(Error error)
+ {
+ var body = new ApiErrorResponse(new ApiErrorDetail(error.Code, error.Message, error.RetryAfterUtc));
+
+ switch (error.Code)
+ {
+ case ChatErrors.NotFound:
+ return NotFound(body);
+
+ case ChatErrors.Forbidden:
+ return StatusCode(StatusCodes.Status403Forbidden, body);
+
+ case ChatErrors.RateLimitExceeded:
+ if (error.RetryAfterUtc is DateTime until)
+ {
+ var seconds = Math.Max(0, (int)Math.Ceiling((until - DateTime.UtcNow).TotalSeconds));
+ Response.Headers.RetryAfter = seconds.ToString();
+ }
+ return StatusCode(StatusCodes.Status429TooManyRequests, body);
+
+ default:
+ // Chat.InvalidBody, Chat.ProfanityRejected, Chat.MessageExpired, Validation.Failed,
+ // Pagination.InvalidCursor, Auth.CsrfHeaderRequired.
+ return BadRequest(body);
+ }
+ }
+
+ private static ApiErrorResponse Error(string code, string message) =>
+ new(new ApiErrorDetail(code, message));
+}
diff --git a/src/SimPle.Api/Health/DatabaseReadinessHealthCheck.cs b/src/SimPle.Api/Health/DatabaseReadinessHealthCheck.cs
new file mode 100644
index 0000000..628124d
--- /dev/null
+++ b/src/SimPle.Api/Health/DatabaseReadinessHealthCheck.cs
@@ -0,0 +1,49 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using SimPle.Infrastructure.Persistence;
+
+namespace SimPle.Api.Health;
+
+///
+/// Verifies that PostgreSQL accepts a connection and that the deployed schema is current. The endpoint response is
+/// intentionally generic; diagnostic detail remains in normal application logs and deployment tooling.
+///
+public sealed class DatabaseReadinessHealthCheck : IHealthCheck
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly ILogger _logger;
+
+ public DatabaseReadinessHealthCheck(
+ IServiceScopeFactory scopeFactory,
+ ILogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _logger = logger;
+ }
+
+ public async Task CheckHealthAsync(
+ HealthCheckContext context,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await using var scope = _scopeFactory.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ if (!await db.Database.CanConnectAsync(cancellationToken))
+ return HealthCheckResult.Unhealthy("Database readiness check failed.");
+
+ // WebApplicationFactory uses EF's in-memory provider. Production uses Npgsql, where this additionally
+ // prevents the app from receiving traffic before its migration job has completed.
+ if (db.Database.IsRelational() && (await db.Database.GetPendingMigrationsAsync(cancellationToken)).Any())
+ return HealthCheckResult.Unhealthy("Database readiness check failed.");
+
+ return HealthCheckResult.Healthy();
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ _logger.LogWarning(exception, "Database readiness check failed.");
+ return HealthCheckResult.Unhealthy("Database readiness check failed.");
+ }
+ }
+}
diff --git a/src/SimPle.Api/Health/StorageConfigurationHealthCheck.cs b/src/SimPle.Api/Health/StorageConfigurationHealthCheck.cs
new file mode 100644
index 0000000..8c857fd
--- /dev/null
+++ b/src/SimPle.Api/Health/StorageConfigurationHealthCheck.cs
@@ -0,0 +1,44 @@
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Options;
+using SimPle.Application.Common.Options;
+
+namespace SimPle.Api.Health;
+
+///
+/// Confirms that the S3-compatible storage integration is configured without issuing a network call on every probe.
+/// Media reachability belongs to deployment smoke tests; a readiness endpoint must remain fast and side-effect free.
+///
+public sealed class StorageConfigurationHealthCheck : IHealthCheck
+{
+ private readonly IOptions _options;
+
+ public StorageConfigurationHealthCheck(IOptions options) => _options = options;
+
+ public Task CheckHealthAsync(
+ HealthCheckContext context,
+ CancellationToken cancellationToken = default)
+ {
+ var options = _options.Value;
+ var isConfigured =
+ IsConfiguredValue(options.Provider) &&
+ IsConfiguredValue(options.BucketName) &&
+ IsConfiguredValue(options.Region) &&
+ IsConfiguredValue(options.AccessKey) &&
+ IsConfiguredValue(options.SecretKey) &&
+ IsConfiguredValue(options.ProfilePrefix) &&
+ options.UploadUrlExpiryMinutes > 0 &&
+ options.ReadUrlExpiryMinutes > 0 &&
+ (string.IsNullOrWhiteSpace(options.ServiceUrl) ||
+ Uri.TryCreate(options.ServiceUrl, UriKind.Absolute, out var serviceUri) &&
+ (serviceUri.Scheme == Uri.UriSchemeHttp || serviceUri.Scheme == Uri.UriSchemeHttps));
+
+ return Task.FromResult(isConfigured
+ ? HealthCheckResult.Healthy()
+ : HealthCheckResult.Unhealthy("Storage readiness check failed."));
+ }
+
+ private static bool IsConfiguredValue(string? value) =>
+ !string.IsNullOrWhiteSpace(value) &&
+ !value.StartsWith("CONFIGURE", StringComparison.OrdinalIgnoreCase) &&
+ !value.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/SimPle.Api/Health/WorkerReadinessHealthCheck.cs b/src/SimPle.Api/Health/WorkerReadinessHealthCheck.cs
new file mode 100644
index 0000000..02c2e57
--- /dev/null
+++ b/src/SimPle.Api/Health/WorkerReadinessHealthCheck.cs
@@ -0,0 +1,19 @@
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using SimPle.Infrastructure.Health;
+
+namespace SimPle.Api.Health;
+
+/// Ensures every required in-process background worker has started and has not reported a failed cycle.
+public sealed class WorkerReadinessHealthCheck : IHealthCheck
+{
+ private readonly IWorkerReadinessRegistry _workers;
+
+ public WorkerReadinessHealthCheck(IWorkerReadinessRegistry workers) => _workers = workers;
+
+ public Task CheckHealthAsync(
+ HealthCheckContext context,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(_workers.AreRequiredWorkersReady
+ ? HealthCheckResult.Healthy()
+ : HealthCheckResult.Unhealthy("Background worker readiness check failed."));
+}
diff --git a/src/SimPle.Api/Hubs/RealtimeHub.cs b/src/SimPle.Api/Hubs/RealtimeHub.cs
new file mode 100644
index 0000000..5e0dabb
--- /dev/null
+++ b/src/SimPle.Api/Hubs/RealtimeHub.cs
@@ -0,0 +1,219 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.SignalR;
+using SimPle.Api.Realtime;
+using SimPle.Application.Chat;
+using SimPle.Application.Common.Interfaces;
+using SimPle.Application.Realtime;
+using SimPle.Application.Realtime.Authorization;
+using SimPle.Application.Realtime.Contracts;
+using SimPle.Application.Realtime.Presence;
+
+namespace SimPle.Api.Hubs;
+
+///
+/// The single authenticated realtime endpoint (docs/specs/module-07-realtime-presence-chat-spec.md). Reuses the
+/// existing HttpOnly access_token cookie JWT auth already wired in Program.cs
+/// (OnMessageReceived/OnTokenValidated) — no separate auth path for realtime.
+///
+/// SendLobbyMessage (added in backend session B, M07-B2) delegates entirely to :
+/// idempotency, rate limiting, normalization, profanity screening, persistence, and fan-out all live there, not
+/// in this file — the hub method is a thin authenticated transport shim, same as every other method here.
+///
+/// Every method that touches a scope re-authorizes against current owner data — a cached principal from
+/// handshake time is never trusted alone for authorization (see "the load-bearing rule" in the spec). Groups are
+/// a delivery optimization only, never an authorization mechanism.
+///
+[Authorize]
+public sealed class RealtimeHub : Hub
+{
+ private readonly IPresenceRegistry _presence;
+ private readonly IRealtimeNotifier _notifier;
+ private readonly IPresenceViewerResolver _viewerResolver;
+ private readonly ILobbyRepository _lobbies;
+ private readonly IRealtimeRateLimiter _rateLimiter;
+ private readonly RealtimeConnectionTracker _tracker;
+ private readonly IReadOnlyDictionary _authorizers;
+ private readonly IChatService _chat;
+ private readonly TimeProvider _timeProvider;
+ private readonly ILogger _logger;
+
+ public RealtimeHub(
+ IPresenceRegistry presence,
+ IRealtimeNotifier notifier,
+ IPresenceViewerResolver viewerResolver,
+ ILobbyRepository lobbies,
+ IRealtimeRateLimiter rateLimiter,
+ RealtimeConnectionTracker tracker,
+ IEnumerable authorizers,
+ IChatService chat,
+ TimeProvider timeProvider,
+ ILogger logger)
+ {
+ _presence = presence;
+ _notifier = notifier;
+ _viewerResolver = viewerResolver;
+ _lobbies = lobbies;
+ _rateLimiter = rateLimiter;
+ _tracker = tracker;
+ _authorizers = authorizers.ToDictionary(a => a.ScopeKind);
+ _chat = chat;
+ _timeProvider = timeProvider;
+ _logger = logger;
+ }
+
+ public override async Task OnConnectedAsync()
+ {
+ var userId = GetUserId();
+
+ // Captured before any mutation, for the before/after diff below — the registry's own PresenceUpdateResult
+ // is only reliable straight off SetLobbyMembership; TryConnect returns a bare bool, and a GetStatus called
+ // immediately after it would recompute the *same* status it just set, so its own Changed flag is always
+ // false at that point. Comparing this snapshot against a later GetStatus is the only way to detect the
+ // real transition.
+ var beforeStatus = _presence.GetStatus(userId).Status;
+
+ var connectionAccepted = _rateLimiter.TryAcquireConnection(userId);
+ var presenceAccepted = connectionAccepted && _presence.TryConnect(userId, Context.ConnectionId);
+
+ if (!connectionAccepted || !presenceAccepted)
+ {
+ if (connectionAccepted)
+ _rateLimiter.ReleaseConnection(userId);
+
+ _logger.LogInformation(
+ "Realtime connection rejected: connection limit reached. UserId={UserId}", userId);
+ throw new HubException("Realtime.ConnectionLimit");
+ }
+
+ // Capture the HubCallerContext itself, not `this` — the Hub instance is transient and disposed right
+ // after this method returns, so a lambda that reads the `Context` property lazily (`() => Context.Abort()`)
+ // throws ObjectDisposedException the moment it's invoked from a later, unrelated request.
+ var callerContext = Context;
+ _tracker.Register(userId, callerContext.ConnectionId, () => callerContext.Abort());
+
+ try
+ {
+ // Reconnect gap: a lobby member whose connection dropped (network blip, server restart) and now
+ // reconnects never actually left the lobby, so they should show InLobby immediately — not wait for a
+ // lobby event that isn't coming, since nothing about the lobby itself changed.
+ var activeLobby = await _lobbies.GetActiveLobbyForUserAsync(userId, callerContext.ConnectionAborted);
+ if (activeLobby is not null)
+ _presence.SetLobbyMembership(userId, activeLobby.Id, true);
+
+ await BroadcastPresenceIfChangedAsync(userId, beforeStatus, callerContext.ConnectionAborted);
+
+ var envelope = RealtimeEnvelope.ForUser(userId, Now());
+ await Clients.Caller.Connected(envelope, _presence.ServerEpoch);
+
+ await base.OnConnectedAsync();
+ }
+ catch
+ {
+ // M07-002: everything acquired above (rate-limit lease, presence entry, tracker registration) must
+ // be released on any failure past this point — otherwise a client that connects then drops before
+ // the handshake reply completes leaks a permanent slot against its connection/rate limits.
+ _tracker.Unregister(userId, callerContext.ConnectionId);
+ _rateLimiter.ReleaseConnection(userId);
+ _presence.Disconnect(userId, callerContext.ConnectionId);
+ throw;
+ }
+ }
+
+ public override async Task OnDisconnectedAsync(Exception? exception)
+ {
+ var userId = GetUserId();
+ var beforeStatus = _presence.GetStatus(userId).Status;
+
+ _tracker.Unregister(userId, Context.ConnectionId);
+ _rateLimiter.ReleaseConnection(userId);
+ _presence.Disconnect(userId, Context.ConnectionId);
+
+ // CancellationToken.None, not Context.ConnectionAborted: that token belongs to the connection that just
+ // died, and is typically already cancelling by the time this runs — it must not cancel a broadcast meant
+ // for the *other* users watching this one's presence.
+ await BroadcastPresenceIfChangedAsync(userId, beforeStatus, CancellationToken.None);
+
+ await base.OnDisconnectedAsync(exception);
+ }
+
+ /// Privacy-safe: a missing lobby, a private lobby the caller cannot see, and an existing-but-
+ /// unauthorized lobby all surface the identical Lobbies.NotFound error — existence is never
+ /// disclosed. A lobby-scope authorizer always exists in B1; a request for a scope kind with no registered
+ /// authorizer (defensive only — should be unreachable while only "lobby" is ever routed here) also denies via
+ /// .
+ public async Task SubscribeLobby(Guid lobbyId)
+ {
+ var userId = GetUserId();
+ var result = await AuthorizeAsync(RealtimeEnvelope.LobbyScope, userId, lobbyId, RealtimeAction.Subscribe);
+ if (!result.IsAllowed)
+ throw new HubException(result.ErrorCode);
+
+ await Groups.AddToGroupAsync(Context.ConnectionId, RealtimeGroups.Lobby(lobbyId), Context.ConnectionAborted);
+
+ // Revision is not known here without a repository round trip beyond what the authorizer already did;
+ // callers fetch the authoritative snapshot via GET /api/lobbies/{lobbyId} immediately after subscribing
+ // (see spec API Contract). 0 signals "fetch the snapshot yourself", never a real revision.
+ return new SubscribeLobbyResultDto(0);
+ }
+
+ public async Task UnsubscribeLobby(Guid lobbyId)
+ {
+ await Groups.RemoveFromGroupAsync(
+ Context.ConnectionId, RealtimeGroups.Lobby(lobbyId), Context.ConnectionAborted);
+ }
+
+ /// A duplicate returns the original message rather than erroring
+ /// or creating a second row — 's idempotency contract. Detailed failure
+ /// reasons never reach the client as free text: the message is always the stable
+ /// ChatErrors code, matching every other authorization failure in this hub.
+ public async Task SendLobbyMessage(Guid lobbyId, string body, Guid clientCommandId)
+ {
+ var userId = GetUserId();
+ var result = await _chat.SendAsync(userId, lobbyId, body, clientCommandId, Context.ConnectionAborted);
+ if (!result.IsSuccess)
+ throw new HubException(result.Error!.Code);
+
+ return new SendLobbyMessageResultDto(result.Value!);
+ }
+
+ /// Throttled to at most once per 60s per connection at the presence-registry level; a throttled
+ /// (rejected) signal is a silent no-op — it is not an error, and it mutates nothing.
+ public async Task ReportActivity()
+ {
+ var userId = GetUserId();
+ var beforeStatus = _presence.GetStatus(userId).Status;
+
+ if (!_presence.TryReportActivity(userId, Context.ConnectionId))
+ return;
+
+ await BroadcastPresenceIfChangedAsync(userId, beforeStatus, Context.ConnectionAborted);
+ }
+
+ private async Task AuthorizeAsync(
+ string scopeKind, Guid userId, Guid scopeId, RealtimeAction action)
+ {
+ if (!_authorizers.TryGetValue(scopeKind, out var authorizer))
+ return RealtimeScopeAuthorizationResult.Deny(NullMatchScopeAuthorizer.ScopeNotAvailableCode);
+
+ return await authorizer.AuthorizeAsync(userId, scopeId, action, Context.ConnectionAborted);
+ }
+
+ /// Broadcasts the caller's current presence only if it actually differs from — the external diff this hub relies on throughout, since none of TryConnect/
+ /// Disconnect/TryReportActivity hand back a trustworthy Changed flag of their own once a second GetStatus call
+ /// has already re-observed the same state.
+ private async Task BroadcastPresenceIfChangedAsync(Guid userId, PresenceStatus beforeStatus, CancellationToken ct)
+ {
+ var after = _presence.GetStatus(userId);
+ if (after.Status == beforeStatus)
+ return;
+
+ var viewers = await _viewerResolver.ResolveAsync(userId, ct);
+ await _notifier.NotifyPresenceChangedAsync(
+ userId, viewers, after.Status.ToString(), after.ServerEpoch, after.UserVersion, ct);
+ }
+
+ private Guid GetUserId() => Guid.Parse(Context.UserIdentifier!);
+
+ private DateTime Now() => _timeProvider.GetUtcNow().UtcDateTime;
+}
diff --git a/src/SimPle.Api/Middleware/CorrelationIdMiddleware.cs b/src/SimPle.Api/Middleware/CorrelationIdMiddleware.cs
new file mode 100644
index 0000000..80bcb9e
--- /dev/null
+++ b/src/SimPle.Api/Middleware/CorrelationIdMiddleware.cs
@@ -0,0 +1,68 @@
+using System.Diagnostics;
+using SimPle.Api.Observability;
+
+namespace SimPle.Api.Middleware;
+
+///
+/// Propagates a safe correlation token to application logs and the response. Untrusted or malformed input is replaced
+/// rather than logged, preventing a request header from becoming a log-injection vector.
+///
+public sealed class CorrelationIdMiddleware
+{
+ public const string HeaderName = "X-Correlation-ID";
+
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+
+ public CorrelationIdMiddleware(RequestDelegate next, ILogger logger)
+ {
+ _next = next;
+ _logger = logger;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ var correlationId = GetCorrelationId(context.Request.Headers[HeaderName]);
+ context.TraceIdentifier = correlationId;
+ context.Response.Headers[HeaderName] = correlationId;
+ Activity.Current?.SetTag("simple.correlation_id", correlationId);
+
+ var startedAt = Stopwatch.GetTimestamp();
+
+ try
+ {
+ using (_logger.BeginScope(new Dictionary { ["CorrelationId"] = correlationId }))
+ {
+ await _next(context);
+ }
+ }
+ finally
+ {
+ RequestTelemetry.Record(context, Stopwatch.GetElapsedTime(startedAt));
+ }
+ }
+
+ private static string GetCorrelationId(Microsoft.Extensions.Primitives.StringValues suppliedValues)
+ {
+ if (suppliedValues.Count == 1 && IsSafeCorrelationId(suppliedValues[0]))
+ return suppliedValues[0]!;
+
+ return Activity.Current?.TraceId.ToString() is { Length: > 0 } traceId
+ ? traceId
+ : Guid.NewGuid().ToString("N");
+ }
+
+ private static bool IsSafeCorrelationId(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value) || value.Length > 64)
+ return false;
+
+ foreach (var character in value)
+ {
+ if (!char.IsAsciiLetterOrDigit(character) && character is not '-' and not '_' and not '.')
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/SimPle.Api/Observability/RequestTelemetry.cs b/src/SimPle.Api/Observability/RequestTelemetry.cs
new file mode 100644
index 0000000..f8c99bb
--- /dev/null
+++ b/src/SimPle.Api/Observability/RequestTelemetry.cs
@@ -0,0 +1,30 @@
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+
+namespace SimPle.Api.Observability;
+
+///
+/// Low-cardinality request measurements. Exporters are intentionally configured by the deployment environment so
+/// telemetry endpoints and credentials never need to be committed to the application repository.
+///
+public static class RequestTelemetry
+{
+ public static readonly Meter Meter = new("SimPle.Api", "1.0.0");
+
+ private static readonly Counter RequestCount = Meter.CreateCounter("simple.http.server.requests");
+ private static readonly Histogram RequestDuration = Meter.CreateHistogram("simple.http.server.duration", "ms");
+
+ public static void Record(HttpContext context, TimeSpan duration)
+ {
+ // Do not attach a path, user ID, correlation ID, or exception message: those cause high-cardinality or
+ // sensitive telemetry. An observability backend can safely aggregate these tags for alerting.
+ var tags = new TagList
+ {
+ { "http.request.method", context.Request.Method },
+ { "http.response.status_code", context.Response.StatusCode }
+ };
+
+ RequestCount.Add(1, tags);
+ RequestDuration.Record(duration.TotalMilliseconds, tags);
+ }
+}
diff --git a/src/SimPle.Api/Program.cs b/src/SimPle.Api/Program.cs
index 345c7ac..0efd57e 100644
--- a/src/SimPle.Api/Program.cs
+++ b/src/SimPle.Api/Program.cs
@@ -6,20 +6,26 @@
using FluentValidation;
using FluentValidation.AspNetCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Microsoft.IdentityModel.Tokens;
+using SimPle.Api.Hubs;
using SimPle.Api.Middleware;
+using SimPle.Api.Health;
using SimPle.Api.Models;
using SimPle.Api.OpenApi;
+using SimPle.Api.Realtime;
using SimPle.Application;
using SimPle.Application.Auth.Validators;
using SimPle.Application.Common.Interfaces;
using SimPle.Application.Common.Options;
using SimPle.Application.GameHost.Services;
+using SimPle.Application.Realtime.Contracts;
using SimPle.Domain.GameHost;
using SimPle.Domain.Games;
using SimPle.Domain.Lobbies;
@@ -31,6 +37,17 @@
var builder = WebApplication.CreateBuilder(args);
+// Container platforms consume structured stdout. Development keeps the familiar readable console provider while
+// production JSON includes logging scopes such as CorrelationId for request-to-log and request-to-response tracing.
+if (!builder.Environment.IsDevelopment())
+{
+ builder.Logging.ClearProviders();
+ builder.Logging.AddJsonConsole(options => options.IncludeScopes = true);
+ // EF parameter values are redacted by default, but command text is still high-volume operational noise and can
+ // reveal data shape. Keep production logs structured and useful without turning them into a query transcript.
+ builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Warning);
+}
+
if (builder.Environment.IsDevelopment())
{
var localEnvPath = Path.Combine(builder.Environment.ContentRootPath, ".env");
@@ -77,6 +94,41 @@
builder.Services.AddApplicationServices();
builder.Services.AddInfrastructureServices(builder.Configuration);
+// Module 7 (docs/specs/module-07-realtime-presence-chat-spec.md), backend session A (M07-B1): transport,
+// authorization, presence only — no chat, no migration. The 16 KiB caps are enforced twice, deliberately: once
+// as the hub-wide message size (MaximumReceiveMessageSize, a HubOptions concern) and once as the connection's
+// buffer size (ApplicationMaxBufferSize, set where the hub is mapped below) — neither is ever set to 0 (which
+// would disable the limit entirely rather than bound it).
+builder.Services.AddOptions()
+ .Bind(builder.Configuration.GetSection(RealtimeOptions.SectionName))
+ .Validate(
+ options => options.AllowedOrigins.Length > 0 && options.AllowedOrigins.All(o => o != "*"),
+ "Realtime:AllowedOrigins must list one or more exact origins and must never contain a wildcard.")
+ .ValidateOnStart();
+
+builder.Services.AddSignalR(options =>
+{
+ options.MaximumReceiveMessageSize = 16 * 1024;
+ // MaximumParallelInvocationsPerClient is deliberately left unset — the default of 1 is exactly what B1
+ // relies on (see config tests asserting this default is untouched).
+});
+
+// Maps SignalR's "user" to the JWT sub claim, matching every other consumer of the access_token cookie.
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+// These two need the concrete RealtimeHub type (via IHubContext), which
+// Infrastructure cannot reference — hence registered here rather than in AddInfrastructureServices. The
+// IRealtimeConnectionCloser registration below deliberately overrides AddInfrastructureServices' no-op default
+// now that the hub actually exists.
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+builder.Services.AddHealthChecks()
+ .AddCheck("database", tags: ["ready"])
+ .AddCheck("storage-configuration", tags: ["ready"])
+ .AddCheck("background-workers", tags: ["ready"]);
+
// The composition root's list of installed Phase 2 game engines. Empty today — Module 5 hosts no product
// game yet, only the test-only HiddenTokenDraft reference engine, which is never registered here. A duplicate
// (Slug, EngineVersion) across two real entries throws from Create() and fails application startup, never a
@@ -171,11 +223,25 @@ await context.Response.WriteAsJsonAsync(new ApiErrorResponse(
});
builder.Services.AddAuthorization();
-builder.Services.AddCors(options => options.AddPolicy("AllowFrontend", policy =>
- policy.WithOrigins(builder.Configuration["Cors:AllowedOrigin"] ?? "http://localhost:3000")
- .AllowAnyHeader()
- .AllowAnyMethod()
- .AllowCredentials()));
+builder.Services.AddCors(options =>
+{
+ options.AddPolicy("AllowFrontend", policy =>
+ policy.WithOrigins(builder.Configuration["Cors:AllowedOrigin"] ?? "http://localhost:3000")
+ .AllowAnyHeader()
+ .AllowAnyMethod()
+ .AllowCredentials());
+
+ // Realtime hub CORS: exact-origin only, sourced from the same Realtime:AllowedOrigins list the handshake
+ // middleware (RealtimeOriginValidationMiddleware) enforces directly — browsers do not apply CORS to
+ // WebSocket upgrades, so this policy alone would not be sufficient; both exist together deliberately.
+ var realtimeAllowedOrigins = builder.Configuration.GetSection("Realtime:AllowedOrigins").Get()
+ ?? Array.Empty();
+ options.AddPolicy("RealtimeHub", policy =>
+ policy.WithOrigins(realtimeAllowedOrigins)
+ .AllowAnyHeader()
+ .AllowAnyMethod()
+ .AllowCredentials());
+});
builder.Services.AddOptions()
.Bind(builder.Configuration.GetSection(GoogleOptions.SectionName))
@@ -255,7 +321,6 @@ await context.Response.WriteAsJsonAsync(new ApiErrorResponse(
}
// Security event (brief: rate-limit rejections are logged with actor, target, action, result).
- // No correlation-id infrastructure exists in this codebase yet (pre-existing gap, tracked separately).
var rateLimitLogger = context.HttpContext.RequestServices
.GetRequiredService().CreateLogger("RateLimiting");
var policyName = context.HttpContext.GetEndpoint()?.Metadata
@@ -405,8 +470,10 @@ await context.HttpContext.Response.WriteAsJsonAsync(new ApiErrorResponse(
// Must be first — sets RemoteIpAddress from X-Forwarded-For before any other middleware reads it.
app.UseForwardedHeaders();
+app.UseMiddleware();
app.UseMiddleware();
app.UseMiddleware();
+app.UseMiddleware();
if (app.Environment.IsDevelopment())
{
@@ -427,8 +494,47 @@ await context.HttpContext.Response.WriteAsJsonAsync(new ApiErrorResponse(
app.UseAuthorization();
app.MapControllers();
+
+// The single authenticated realtime endpoint (docs/specs/module-07-realtime-presence-chat-spec.md). Reuses the
+// same JWT cookie auth as REST (OnMessageReceived/OnTokenValidated above) — [Authorize] on RealtimeHub is what
+// enforces it. CloseOnAuthenticationExpiration closes a connection the instant its token naturally expires; it
+// is only one of three independent mechanisms the authorization model needs (see "the load-bearing rule" in the
+// spec) — proactive close (AuthService -> IRealtimeConnectionCloser) and the per-method scope recheck
+// (IRealtimeScopeAuthorizer, called from inside RealtimeHub) are the other two, and none of the three is
+// sufficient alone. ApplicationMaxBufferSize bounds the connection's receive buffer at the same 16 KiB as
+// HubOptions.MaximumReceiveMessageSize above — neither is ever set to 0, which would disable the limit.
+app.MapHub("/hubs/realtime", options =>
+{
+ options.CloseOnAuthenticationExpiration = true;
+ options.ApplicationMaxBufferSize = 16 * 1024;
+ options.TransportMaxBufferSize = 16 * 1024;
+}).RequireCors("RealtimeHub");
+
+app.MapHealthChecks("/health/live", new HealthCheckOptions
+{
+ // A liveness probe answers only whether this process can serve HTTP. It must never restart the container because
+ // PostgreSQL, storage, or a background worker is temporarily unavailable.
+ Predicate = _ => false,
+ ResponseWriter = WriteHealthResponse,
+});
+app.MapHealthChecks("/health/ready", new HealthCheckOptions
+{
+ Predicate = registration => registration.Tags.Contains("ready"),
+ ResponseWriter = WriteHealthResponse,
+});
app.MapGet("/health", () => Results.Ok(new { status = "healthy", utc = DateTime.UtcNow }));
+static Task WriteHealthResponse(HttpContext context, HealthReport report)
+{
+ // Dependency names, connection errors, pending migration IDs, and worker state stay out of this public endpoint.
+ // The HTTP status and this single status field are sufficient for Container Apps / Caddy probes.
+ context.Response.ContentType = "application/json";
+ return context.Response.WriteAsJsonAsync(new
+ {
+ status = report.Status == HealthStatus.Healthy ? "healthy" : "unhealthy",
+ });
+}
+
static RateLimitPartition AuthWindow(HttpContext context, int permitLimit, TimeSpan window) =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
@@ -459,6 +565,39 @@ static RateLimitPartition FriendWindow(HttpContext context, string prefi
});
}
+// Explicit, one-shot release jobs. They reuse the application composition root so migrations and seeders use the
+// same configuration and provider as the running API, but they never start the HTTP server or background workers.
+// A deployment must invoke them one at a time and record their exit code as release evidence.
+if (args.Contains("--apply-migrations"))
+{
+ using var scope = app.Services.CreateScope();
+ var migrationDb = scope.ServiceProvider.GetRequiredService();
+ migrationDb.Database.Migrate();
+ Console.WriteLine("Database migrations applied successfully.");
+ Environment.Exit(0);
+}
+
+if (args.Contains("--seed"))
+{
+ using var scope = app.Services.CreateScope();
+ var seedDb = scope.ServiceProvider.GetRequiredService();
+ var seedLogger = scope.ServiceProvider.GetRequiredService().CreateLogger();
+ var gameSeeder = new GameCatalogSeeder(seedDb, seedLogger);
+ var gameResult = gameSeeder.SeedAsync().GetAwaiter().GetResult();
+ if (!gameResult.Success)
+ {
+ Console.Error.WriteLine(gameResult.Message);
+ Environment.Exit(1);
+ }
+
+ var capabilityClock = scope.ServiceProvider.GetRequiredService();
+ var capabilityLogger = scope.ServiceProvider.GetRequiredService().CreateLogger();
+ var capabilitySeeder = new GameCapabilitySeeder(seedDb, capabilityClock, capabilityLogger);
+ var capabilityResult = capabilitySeeder.SeedAsync().GetAwaiter().GetResult();
+ Console.WriteLine(capabilityResult.Message);
+ Environment.Exit(capabilityResult.Success ? 0 : 1);
+}
+
if (args.Contains("--seed-game-catalog"))
{
using var scope = app.Services.CreateScope();
diff --git a/src/SimPle.Api/Realtime/RealtimeConnectionCloser.cs b/src/SimPle.Api/Realtime/RealtimeConnectionCloser.cs
new file mode 100644
index 0000000..3691171
--- /dev/null
+++ b/src/SimPle.Api/Realtime/RealtimeConnectionCloser.cs
@@ -0,0 +1,28 @@
+using SimPle.Application.Common.Interfaces;
+using SimPle.Application.Realtime.Contracts;
+
+namespace SimPle.Api.Realtime;
+
+///
+/// Real, SignalR-backed : one of the three independent mechanisms Module
+/// 7's authorization model requires (docs/specs/module-07-realtime-presence-chat-spec.md, "the load-bearing
+/// rule"). Sends AccessRevoked (best-effort) then forcibly aborts every live connection for the user via
+/// , rather than waiting for token expiry or the next per-method recheck.
+///
+public sealed class RealtimeConnectionCloser : IRealtimeConnectionCloser
+{
+ private readonly IRealtimeNotifier _notifier;
+ private readonly RealtimeConnectionTracker _tracker;
+
+ public RealtimeConnectionCloser(IRealtimeNotifier notifier, RealtimeConnectionTracker tracker)
+ {
+ _notifier = notifier;
+ _tracker = tracker;
+ }
+
+ public async Task CloseUserConnectionsAsync(Guid userId, string reason, CancellationToken ct = default)
+ {
+ await _notifier.NotifyAccessRevokedAsync(userId, reason, ct);
+ _tracker.AbortAll(userId);
+ }
+}
diff --git a/src/SimPle.Api/Realtime/RealtimeConnectionTracker.cs b/src/SimPle.Api/Realtime/RealtimeConnectionTracker.cs
new file mode 100644
index 0000000..bb6c4b8
--- /dev/null
+++ b/src/SimPle.Api/Realtime/RealtimeConnectionTracker.cs
@@ -0,0 +1,43 @@
+using System.Collections.Concurrent;
+
+namespace SimPle.Api.Realtime;
+
+///
+/// Maps a user id to their live hub connections' abort actions. SignalR's IHubContext can message a user
+/// (Clients.User(id)) but cannot forcibly close their connection — only HubCallerContext.Abort(),
+/// called from inside the hub instance that owns that connection, can. This singleton is the bridge:
+/// RealtimeHub registers each connection's abort delegate on connect and removes it on disconnect;
+/// RealtimeConnectionCloser (the real, SignalR-backed IRealtimeConnectionCloser) invokes them by
+/// user id from anywhere in the app (e.g. AuthService.LogoutAsync).
+///
+public sealed class RealtimeConnectionTracker
+{
+ private readonly ConcurrentDictionary> _byUser = new();
+
+ public void Register(Guid userId, string connectionId, Action abort)
+ {
+ var connections = _byUser.GetOrAdd(userId, _ => new ConcurrentDictionary());
+ connections[connectionId] = abort;
+ }
+
+ public void Unregister(Guid userId, string connectionId)
+ {
+ if (_byUser.TryGetValue(userId, out var connections))
+ {
+ connections.TryRemove(connectionId, out _);
+ if (connections.IsEmpty)
+ _byUser.TryRemove(userId, out _);
+ }
+ }
+
+ /// Aborts every live connection currently registered for this user. Never throws for a user with
+ /// no connections.
+ public void AbortAll(Guid userId)
+ {
+ if (!_byUser.TryRemove(userId, out var connections))
+ return;
+
+ foreach (var abort in connections.Values)
+ abort();
+ }
+}
diff --git a/src/SimPle.Api/Realtime/RealtimeNotifier.cs b/src/SimPle.Api/Realtime/RealtimeNotifier.cs
new file mode 100644
index 0000000..9371c46
--- /dev/null
+++ b/src/SimPle.Api/Realtime/RealtimeNotifier.cs
@@ -0,0 +1,83 @@
+using Microsoft.AspNetCore.SignalR;
+using SimPle.Api.Hubs;
+using SimPle.Application.Realtime.Contracts;
+
+namespace SimPle.Api.Realtime;
+
+///
+/// Real, SignalR-backed . Lives in the API layer (not Infrastructure) because it
+/// needs the concrete type via , and Infrastructure
+/// cannot reference Api in this codebase's Clean Architecture layering (Api references Infrastructure, never the
+/// reverse) — a deliberate, documented deviation from the literal "implementations live in Infrastructure"
+/// instruction (see final report). No caller invokes these methods yet in B1 (no chat, no lobby command wiring);
+/// they exist now so B2 needs zero new hub/client contract surface.
+///
+public sealed class RealtimeNotifier : IRealtimeNotifier
+{
+ private readonly IHubContext _hub;
+ private readonly TimeProvider _timeProvider;
+
+ public RealtimeNotifier(IHubContext hub, TimeProvider timeProvider)
+ {
+ _hub = hub;
+ _timeProvider = timeProvider;
+ }
+
+ public async Task NotifyLobbyChangedAsync(
+ Guid lobbyId, int revision, string changeType, CancellationToken ct = default)
+ {
+ var envelope = RealtimeEnvelope.ForLobby(lobbyId, Now());
+ await _hub.Clients.Group(RealtimeGroups.Lobby(lobbyId)).LobbyChanged(envelope, revision, changeType);
+ }
+
+ public async Task NotifyPresenceChangedAsync(
+ Guid subjectUserId, IReadOnlyCollection viewerUserIds, string status, Guid serverEpoch,
+ long userVersion, CancellationToken ct = default)
+ {
+ if (viewerUserIds.Count == 0)
+ return;
+
+ var envelope = RealtimeEnvelope.ForUser(subjectUserId, Now());
+ await _hub.Clients.Users(viewerUserIds.Select(id => id.ToString()).ToList())
+ .PresenceChanged(envelope, subjectUserId, status, serverEpoch, userVersion);
+ }
+
+ public async Task NotifyChatMessageCreatedAsync(
+ Guid lobbyId, IReadOnlyCollection recipientUserIds, ChatMessageDto message,
+ CancellationToken ct = default)
+ {
+ if (recipientUserIds.Count == 0)
+ return;
+
+ var envelope = RealtimeEnvelope.ForLobby(lobbyId, Now());
+ await _hub.Clients.Users(recipientUserIds.Select(id => id.ToString()).ToList())
+ .ChatMessageCreated(envelope, message);
+ }
+
+ public async Task NotifyChatMessageDeletedAsync(
+ Guid lobbyId, IReadOnlyCollection recipientUserIds, Guid messageId, DateTime deletedAtUtc,
+ CancellationToken ct = default)
+ {
+ if (recipientUserIds.Count == 0)
+ return;
+
+ var envelope = RealtimeEnvelope.ForLobby(lobbyId, Now());
+ await _hub.Clients.Users(recipientUserIds.Select(id => id.ToString()).ToList())
+ .ChatMessageDeleted(envelope, messageId, deletedAtUtc);
+ }
+
+ public async Task NotifyAccessRevokedAsync(Guid userId, string reason, CancellationToken ct = default)
+ {
+ var envelope = RealtimeEnvelope.ForUser(userId, Now());
+ await _hub.Clients.User(userId.ToString()).AccessRevoked(envelope, reason);
+ }
+
+ public async Task NotifyResyncRequiredAsync(
+ Guid lobbyId, string reason, int? currentRevision, CancellationToken ct = default)
+ {
+ var envelope = RealtimeEnvelope.ForLobby(lobbyId, Now());
+ await _hub.Clients.Group(RealtimeGroups.Lobby(lobbyId)).ResyncRequired(envelope, reason, currentRevision);
+ }
+
+ private DateTime Now() => _timeProvider.GetUtcNow().UtcDateTime;
+}
diff --git a/src/SimPle.Api/Realtime/RealtimeOptions.cs b/src/SimPle.Api/Realtime/RealtimeOptions.cs
new file mode 100644
index 0000000..1778c64
--- /dev/null
+++ b/src/SimPle.Api/Realtime/RealtimeOptions.cs
@@ -0,0 +1,14 @@
+namespace SimPle.Api.Realtime;
+
+///
+/// Config-driven exact-origin allowlist for the realtime hub handshake (docs/specs/module-07-realtime-presence-
+/// chat-spec.md). Deliberately separate from the general Cors:AllowedOrigin policy: browsers do not apply
+/// CORS to WebSocket upgrade requests, so the hub's origin check cannot rely on UseCors alone and must be
+/// enforced explicitly, in application code, against an exact-match allowlist — never a wildcard.
+///
+public sealed class RealtimeOptions
+{
+ public const string SectionName = "Realtime";
+
+ public string[] AllowedOrigins { get; set; } = Array.Empty();
+}
diff --git a/src/SimPle.Api/Realtime/RealtimeOriginValidationMiddleware.cs b/src/SimPle.Api/Realtime/RealtimeOriginValidationMiddleware.cs
new file mode 100644
index 0000000..df93faf
--- /dev/null
+++ b/src/SimPle.Api/Realtime/RealtimeOriginValidationMiddleware.cs
@@ -0,0 +1,48 @@
+using Microsoft.Extensions.Options;
+using SimPle.Api.Models;
+
+namespace SimPle.Api.Realtime;
+
+///
+/// Rejects any request to the realtime hub whose Origin header is not an exact match against the
+/// configured allowlist (). Runs ahead of SignalR's own request
+/// handling for both the negotiate call and the WebSocket upgrade — WebSocket upgrades are not subject to browser
+/// CORS enforcement, so this check is the actual security boundary, not UseCors.
+///
+public sealed class RealtimeOriginValidationMiddleware
+{
+ public const string HubPathPrefix = "/hubs/realtime";
+
+ private readonly RequestDelegate _next;
+ private readonly IOptionsMonitor _options;
+
+ public RealtimeOriginValidationMiddleware(RequestDelegate next, IOptionsMonitor options)
+ {
+ _next = next;
+ _options = options;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ if (context.Request.Path.StartsWithSegments(HubPathPrefix))
+ {
+ var origin = context.Request.Headers.Origin.ToString();
+ var allowedOrigins = _options.CurrentValue.AllowedOrigins;
+
+ // M07-003: a missing Origin header is rejected, not waved through. Real browsers always send Origin
+ // on the negotiate POST and the WebSocket upgrade — same-origin or not, unsafe-method fetches carry
+ // it per the Fetch spec — so the only client that reaches this path with no Origin at all is a
+ // non-browser tool forging the request, exactly the actor this check exists to stop. A
+ // present-but-unlisted Origin remains rejected too.
+ if (string.IsNullOrEmpty(origin) || !allowedOrigins.Contains(origin, StringComparer.Ordinal))
+ {
+ context.Response.StatusCode = StatusCodes.Status403Forbidden;
+ await context.Response.WriteAsJsonAsync(new ApiErrorResponse(
+ new ApiErrorDetail("Realtime.OriginRejected", "Origin not allowed for the realtime hub.")));
+ return;
+ }
+ }
+
+ await _next(context);
+ }
+}
diff --git a/src/SimPle.Api/Realtime/SubjectUserIdProvider.cs b/src/SimPle.Api/Realtime/SubjectUserIdProvider.cs
new file mode 100644
index 0000000..c82be22
--- /dev/null
+++ b/src/SimPle.Api/Realtime/SubjectUserIdProvider.cs
@@ -0,0 +1,17 @@
+using System.IdentityModel.Tokens.Jwt;
+using Microsoft.AspNetCore.SignalR;
+
+namespace SimPle.Api.Realtime;
+
+///
+/// Maps SignalR's notion of "user" to the JWT sub claim, matching every other consumer of the access
+/// token cookie in this codebase (Program.cs uses options.MapInboundClaims = false, so the claim
+/// type stays the literal "sub" rather than being remapped to ClaimTypes.NameIdentifier). This is
+/// what makes Clients.User(userId) and HubCallerContext.UserIdentifier resolve to the same value
+/// used everywhere else ( parsed from the claim).
+///
+public sealed class SubjectUserIdProvider : IUserIdProvider
+{
+ public string? GetUserId(HubConnectionContext connection) =>
+ connection.User?.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
+}
diff --git a/src/SimPle.Api/appsettings.Development.example.json b/src/SimPle.Api/appsettings.Development.example.json
index 2033f54..4cffe19 100644
--- a/src/SimPle.Api/appsettings.Development.example.json
+++ b/src/SimPle.Api/appsettings.Development.example.json
@@ -45,5 +45,8 @@
},
"Cors": {
"AllowedOrigin": "http://localhost:3000"
+ },
+ "Realtime": {
+ "AllowedOrigins": [ "http://localhost:3000" ]
}
}
diff --git a/src/SimPle.Api/appsettings.json b/src/SimPle.Api/appsettings.json
index 661a835..33df597 100644
--- a/src/SimPle.Api/appsettings.json
+++ b/src/SimPle.Api/appsettings.json
@@ -9,6 +9,9 @@
"Cors": {
"AllowedOrigin": "http://localhost:3000"
},
+ "Realtime": {
+ "AllowedOrigins": [ "http://localhost:3000" ]
+ },
"ConnectionStrings": {
"DefaultConnection": "See appsettings.Development.json or environment variables"
},
diff --git a/src/SimPle.Application/Auth/Services/AuthService.cs b/src/SimPle.Application/Auth/Services/AuthService.cs
index fe251b4..4a1b76e 100644
--- a/src/SimPle.Application/Auth/Services/AuthService.cs
+++ b/src/SimPle.Application/Auth/Services/AuthService.cs
@@ -19,6 +19,7 @@ public sealed class AuthService : IAuthService
private readonly IEmailService _email;
private readonly IGoogleTokenValidationService _googleValidator;
private readonly IRevokedJtiStore _revokedJtis;
+ private readonly IRealtimeConnectionCloser _realtimeCloser;
private readonly AuthOptions _authOptions;
private readonly EmailOptions _emailOptions;
private readonly ILogger _logger;
@@ -33,6 +34,7 @@ public AuthService(
IEmailService email,
IGoogleTokenValidationService googleValidator,
IRevokedJtiStore revokedJtis,
+ IRealtimeConnectionCloser realtimeCloser,
IOptions authOptions,
IOptions emailOptions,
ILogger logger)
@@ -46,6 +48,7 @@ public AuthService(
_email = email;
_googleValidator = googleValidator;
_revokedJtis = revokedJtis;
+ _realtimeCloser = realtimeCloser;
_authOptions = authOptions.Value;
_emailOptions = emailOptions.Value;
_logger = logger;
@@ -237,8 +240,19 @@ public async Task LogoutAsync(string rawRefreshToken, CancellationToken
if (stored is { IsRevoked: false })
{
+ // Module 7: also revoke the session family, matching LogoutAllAsync/RevokeSessionAsync. Previously
+ // this method revoked only the refresh-token row, leaving a stale-but-still-valid access cookie live
+ // until natural expiry — harmless on plain REST, but a realtime hub connection would otherwise survive
+ // a "logout" indefinitely and simply reconnect with that cookie. This is an approved, intentional
+ // Module 1 behavior change (see docs/specs/module-07-realtime-presence-chat-spec.md).
+ _revokedJtis.Revoke(stored.FamilyId.ToString(), TimeSpan.FromMinutes(20));
+
stored.Revoke("", "Logout");
await _tokens.UpdateAsync(stored, ct);
+
+ // Realtime connections cache the authenticated principal for their lifetime and are not
+ // automatically revalidated — close them proactively rather than letting a live socket outlive logout.
+ await _realtimeCloser.CloseUserConnectionsAsync(stored.UserId, "auth.session_revoked", ct);
}
return Result.Ok();
@@ -252,6 +266,7 @@ public async Task LogoutAllAsync(Guid userId, CancellationToken ct = def
_revokedJtis.Revoke(t.FamilyId.ToString(), TimeSpan.FromMinutes(20));
await _tokens.RevokeAllByUserIdAsync(userId, "Logout all", ct);
+ await _realtimeCloser.CloseUserConnectionsAsync(userId, "auth.session_revoked", ct);
_logger.LogInformation("Security: Logout-all. UserId={UserId}", userId);
return Result.Ok();
}
@@ -517,6 +532,7 @@ public async Task RevokeSessionAsync(Guid userId, Guid sessionId, Cancel
token.Revoke(string.Empty, "user_revoked");
await _tokens.UpdateAsync(token, ct);
+ await _realtimeCloser.CloseUserConnectionsAsync(userId, "auth.session_revoked", ct);
_logger.LogInformation("Session {SessionId} revoked by user {UserId}", sessionId, userId);
return Result.Ok();
}
@@ -531,6 +547,7 @@ public async Task DeleteAccountAsync(Guid userId, string password, Cance
await _tokens.RevokeAllByUserIdAsync(userId, "account_deleted", ct);
await _users.DeleteAsync(user, ct);
+ await _realtimeCloser.CloseUserConnectionsAsync(userId, "auth.session_revoked", ct);
_logger.LogInformation("Account deleted for user {UserId}", userId);
return Result.Ok();
}
diff --git a/src/SimPle.Application/Chat/ChatBodyNormalizer.cs b/src/SimPle.Application/Chat/ChatBodyNormalizer.cs
new file mode 100644
index 0000000..1b722cc
--- /dev/null
+++ b/src/SimPle.Application/Chat/ChatBodyNormalizer.cs
@@ -0,0 +1,54 @@
+using System.Text;
+using SimPle.Shared.Common;
+
+namespace SimPle.Application.Chat;
+
+///
+/// Normalizes and validates a raw chat body before it is ever stored (docs/specs/module-07-realtime-presence-chat-
+/// spec.md, "Test Matrix": "NFC normalization; CRLF/CR to LF; outer Unicode whitespace trimmed; LF allowed; other
+/// C0/C1 controls rejected; 0 scalars rejected, 1 accepted, 1000 accepted, 1001 rejected; astral-plane scalars
+/// counted correctly.").
+///
+/// Counts Unicode scalar values (), never UTF-16 code units — a single astral
+/// character (e.g. an emoji outside the BMP) is one scalar even though it is two chars.
+///
+public static class ChatBodyNormalizer
+{
+ public const int MinScalars = 1;
+ public const int MaxScalars = 1000;
+
+ public static Result Normalize(string? rawBody)
+ {
+ if (rawBody is null) return Fail();
+
+ // CRLF/CR -> LF first, so a lone CR or a CRLF pair both collapse to the one allowed control character.
+ var unified = rawBody.Replace("\r\n", "\n").Replace("\r", "\n");
+
+ // NFC normalization. Combining-mark sequences and their precomposed equivalents must count and compare
+ // identically; skipping this would let visually-identical bodies evade the profanity deny-list.
+ var normalizedForm = unified.Normalize(NormalizationForm.FormC);
+
+ // Outer Unicode whitespace trim only — String.Trim() uses Char.IsWhiteSpace, which is Unicode-aware, and
+ // only removes from the ends, so an internal LF (a deliberate multi-line message) survives untouched.
+ var trimmed = normalizedForm.Trim();
+
+ var scalarCount = 0;
+ foreach (var rune in trimmed.EnumerateRunes())
+ {
+ if (IsRejectedControl(rune.Value)) return Fail();
+ scalarCount++;
+ }
+
+ if (scalarCount is < MinScalars or > MaxScalars) return Fail();
+
+ return Result.Ok(trimmed);
+ }
+
+ /// LF (U+000A) is the one allowed control character (it is how a multi-line body is represented post
+ /// CRLF/CR collapse). Every other C0 control (U+0000-U+001F) and every C1 control plus DEL (U+007F-U+009F) is
+ /// rejected.
+ private static bool IsRejectedControl(int scalarValue) =>
+ (scalarValue <= 0x1F && scalarValue != 0x0A) || (scalarValue is >= 0x7F and <= 0x9F);
+
+ private static Result Fail() => Result.Fail(ChatErrors.InvalidBody, "Message body is invalid.");
+}
diff --git a/src/SimPle.Application/Chat/ChatErrors.cs b/src/SimPle.Application/Chat/ChatErrors.cs
new file mode 100644
index 0000000..4ec78f4
--- /dev/null
+++ b/src/SimPle.Application/Chat/ChatErrors.cs
@@ -0,0 +1,33 @@
+namespace SimPle.Application.Chat;
+
+/// Error catalogue for Module 7 chat (docs/specs/module-07-realtime-presence-chat-spec.md, "Error
+/// catalogue"). PascalCase dot-namespaced codes, matching every other module's per-module error class.
+public static class ChatErrors
+{
+ /// Message absent or not visible to the viewer. Privacy-safe: existence is never disclosed, so an
+ /// unauthorized viewer and a genuinely-missing message collapse to the same code.
+ public const string NotFound = "Chat.NotFound";
+
+ /// The one legitimate distinguishing code in this module: not the author on delete.
+ public const string Forbidden = "Chat.Forbidden";
+
+ /// Failed normalization/length/control-character rules.
+ public const string InvalidBody = "Chat.InvalidBody";
+
+ /// Deny-list match. A normal validation error, not a security event — message is not stored, sender
+ /// is told why.
+ public const string ProfanityRejected = "Chat.ProfanityRejected";
+
+ /// Hold requested (M12) on a message that already aged out of retention.
+ public const string MessageExpired = "Chat.MessageExpired";
+
+ /// Shared literal with every other module's own rate-limit constant — same string, independently
+ /// declared, matching this codebase's per-module-duplicate-constant convention.
+ public const string RateLimitExceeded = "RateLimit.Exceeded";
+
+ /// Shared cross-module literal (matches LobbyErrors.ValidationFailed).
+ public const string ValidationFailed = "Validation.Failed";
+
+ /// Shared cross-module literal (matches LobbyErrors.InvalidCursor).
+ public const string InvalidCursor = "Pagination.InvalidCursor";
+}
diff --git a/src/SimPle.Application/Chat/ChatHistoryDirection.cs b/src/SimPle.Application/Chat/ChatHistoryDirection.cs
new file mode 100644
index 0000000..704d240
--- /dev/null
+++ b/src/SimPle.Application/Chat/ChatHistoryDirection.cs
@@ -0,0 +1,13 @@
+namespace SimPle.Application.Chat;
+
+/// docs/specs/module-07-realtime-presence-chat-spec.md, API contract: "direction: before (scrollback,
+/// default) | after (reconnect repair)."
+public enum ChatHistoryDirection
+{
+ /// Scrollback. No cursor => the most recent page. With a cursor => the page immediately older than
+ /// it. Default.
+ Before,
+
+ /// Reconnect repair: the page immediately newer than the cursor.
+ After,
+}
diff --git a/src/SimPle.Application/Chat/ChatProfanityFilter.cs b/src/SimPle.Application/Chat/ChatProfanityFilter.cs
new file mode 100644
index 0000000..d8d23de
--- /dev/null
+++ b/src/SimPle.Application/Chat/ChatProfanityFilter.cs
@@ -0,0 +1,25 @@
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Options;
+
+namespace SimPle.Application.Chat;
+
+/// Default : whole-word, case-insensitive matching against
+/// . Deliberately simple and dependency-free — this is a first-pass automated
+/// screen, not a replacement for M12's manual moderation pipeline.
+public sealed class ChatProfanityFilter : IChatProfanityFilter
+{
+ private readonly IReadOnlyList _patterns;
+
+ public ChatProfanityFilter(IOptions options)
+ {
+ _patterns = options.Value.Terms
+ .Where(term => !string.IsNullOrWhiteSpace(term))
+ .Select(term => new Regex(
+ $@"\b{Regex.Escape(term.Trim())}\b",
+ RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled))
+ .ToList();
+ }
+
+ public bool IsProfane(string normalizedBody) =>
+ _patterns.Count > 0 && _patterns.Any(pattern => pattern.IsMatch(normalizedBody));
+}
diff --git a/src/SimPle.Application/Chat/ChatRetentionOptions.cs b/src/SimPle.Application/Chat/ChatRetentionOptions.cs
new file mode 100644
index 0000000..e5d42f2
--- /dev/null
+++ b/src/SimPle.Application/Chat/ChatRetentionOptions.cs
@@ -0,0 +1,18 @@
+namespace SimPle.Application.Chat;
+
+/// Backs (docs/specs/
+/// module-07-realtime-presence-chat-spec.md, "Ownership, retention, deletion" and Risk #5). Mirrors
+/// TokenCleanupOptions's shape: an interval plus a per-run bound, both environment/appsettings-supplied.
+///
+public sealed class ChatRetentionOptions
+{
+ public const string SectionName = "ChatRetention";
+
+ /// How often the sweep runs.
+ public TimeSpan Interval { get; set; } = TimeSpan.FromHours(1);
+
+ /// Rows deleted per sweep pass. Bounded on purpose — the sweep is one statement per batch
+ /// (LIMIT + FOR UPDATE OF m SKIP LOCKED), so a large backlog is drained over several passes
+ /// rather than one unbounded transaction.
+ public int BatchSize { get; set; } = 500;
+}
diff --git a/src/SimPle.Application/Chat/ChatService.cs b/src/SimPle.Application/Chat/ChatService.cs
new file mode 100644
index 0000000..6d661f6
--- /dev/null
+++ b/src/SimPle.Application/Chat/ChatService.cs
@@ -0,0 +1,296 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using SimPle.Application.Common.Interfaces;
+using SimPle.Application.Common.Options;
+using SimPle.Application.Common.Pagination;
+using SimPle.Application.Realtime;
+using SimPle.Application.Realtime.Authorization;
+using SimPle.Application.Realtime.Contracts;
+using SimPle.Domain.Chat;
+using SimPle.Domain.Users;
+using SimPle.Shared.Common;
+
+namespace SimPle.Application.Chat;
+
+///
+/// Chat command/query implementation (docs/specs/module-07-realtime-presence-chat-spec.md). Reuses the same
+/// surface the hub uses for subscribe, so send/delete/history all
+/// re-authorize against current owner data on every call — never a cached principal from connection time.
+///
+public sealed class ChatService : IChatService
+{
+ private const int DefaultLimit = 30;
+ private const int MaxLimit = 50;
+
+ /// No precise retry-after instant is available from
+ /// (bool-only contract) — this is a conservative synthesized value matching the documented 5-per-5s burst
+ /// window, not a measured one.
+ private static readonly TimeSpan RateLimitRetryAfter = TimeSpan.FromSeconds(5);
+
+ /// Safe non-navigable placeholder (spec: "no username leak") for a sender absent from
+ /// 's result (deleted/unresolvable account) — never the real
+ /// fields.
+ private static readonly PublicIdentityDto TombstoneSender = new(
+ Guid.Empty, "deleted-user", "Chat participant", "??", "#6B7280", null, "Player");
+
+ private readonly IChatRepository _chat;
+ private readonly IReadOnlyDictionary _authorizers;
+ private readonly IRealtimeNotifier _notifier;
+ private readonly IRealtimeRateLimiter _rateLimiter;
+ private readonly IChatProfanityFilter _profanity;
+ private readonly IFileStorageService _storage;
+ private readonly StorageOptions _storageOptions;
+ private readonly ILobbyRepository _lobbies;
+ private readonly IFriendRepository _friends;
+ private readonly TimeProvider _clock;
+ private readonly ILogger _logger;
+
+ public ChatService(
+ IChatRepository chat,
+ IEnumerable authorizers,
+ IRealtimeNotifier notifier,
+ IRealtimeRateLimiter rateLimiter,
+ IChatProfanityFilter profanity,
+ IFileStorageService storage,
+ IOptions storageOptions,
+ ILobbyRepository lobbies,
+ IFriendRepository friends,
+ TimeProvider clock,
+ ILogger logger)
+ {
+ _chat = chat;
+ _authorizers = authorizers.ToDictionary(a => a.ScopeKind);
+ _notifier = notifier;
+ _rateLimiter = rateLimiter;
+ _profanity = profanity;
+ _storage = storage;
+ _storageOptions = storageOptions.Value;
+ _lobbies = lobbies;
+ _friends = friends;
+ _clock = clock;
+ _logger = logger;
+ }
+
+ private DateTime NowUtc => _clock.GetUtcNow().UtcDateTime;
+
+ public async Task> SendAsync(
+ Guid actorUserId, Guid lobbyId, string? body, Guid clientCommandId, CancellationToken ct = default)
+ {
+ if (!await IsAllowedAsync(actorUserId, lobbyId, RealtimeAction.Send, ct))
+ return Result.Fail(ChatErrors.NotFound, "Lobby not found.");
+
+ // Idempotency first: a client retry of the same command must never cost a second rate-limit slot or get
+ // a different profanity/validation verdict the second time around.
+ var existing = await _chat.FindByClientCommandIdAsync(actorUserId, clientCommandId, ct);
+ if (existing is not null)
+ return Result.Ok(await ToOwnMessageDtoAsync(existing, ct));
+
+ if (!_rateLimiter.TryAcquireMessage(actorUserId))
+ {
+ return Result.Fail(new Error(
+ ChatErrors.RateLimitExceeded, "You are sending messages too quickly.")
+ {
+ RetryAfterUtc = NowUtc + RateLimitRetryAfter,
+ });
+ }
+
+ var normalized = ChatBodyNormalizer.Normalize(body);
+ if (!normalized.IsSuccess)
+ return Result.Fail(ChatErrors.InvalidBody, normalized.Error!.Message);
+
+ if (_profanity.IsProfane(normalized.Value!))
+ return Result.Fail(ChatErrors.ProfanityRejected, "That message isn't allowed.");
+
+ var message = ChatMessage.Create(
+ ChatScope.Lobby, lobbyId, actorUserId, normalized.Value!, clientCommandId, NowUtc);
+
+ // AddAsync is itself idempotent against ux_chat_messages_sender_command: a concurrent duplicate send that
+ // raced past the FindByClientCommandIdAsync check above hits the unique index instead of throwing, and
+ // returns whichever row actually won.
+ var persisted = await _chat.AddAsync(message, ct);
+
+ var dto = await ToOwnMessageDtoAsync(persisted, ct);
+ var recipients = await GetDeliverableRecipientsAsync(lobbyId, actorUserId, ct);
+ await _notifier.NotifyChatMessageCreatedAsync(lobbyId, recipients, dto, ct);
+ return Result.Ok(dto);
+ }
+
+ public async Task DeleteAsync(Guid actorUserId, Guid messageId, CancellationToken ct = default)
+ {
+ var message = await _chat.GetByIdAsync(messageId, ct);
+ if (message is null)
+ return Result.Fail(ChatErrors.NotFound, "Message not found.");
+
+ if (!await IsAllowedAsync(actorUserId, message.ScopeId, RealtimeAction.Delete, ct))
+ return Result.Fail(ChatErrors.NotFound, "Message not found.");
+
+ if (message.SenderId != actorUserId)
+ return Result.Fail(ChatErrors.Forbidden, "Only the author can delete this message.");
+
+ message.Delete(actorUserId, NowUtc);
+ await _chat.SaveAsync(ct);
+
+ // Uses the message's own DeletedAtUtc (set on first delete, unchanged on a retried one) rather than
+ // "now" directly, so a retried delete re-broadcasts the original tombstone instant, not a new one.
+ var recipients = await GetDeliverableRecipientsAsync(message.ScopeId, actorUserId, ct);
+ await _notifier.NotifyChatMessageDeletedAsync(
+ message.ScopeId, recipients, message.Id, message.DeletedAtUtc!.Value, ct);
+ return Result.Ok();
+ }
+
+ public async Task>> GetHistoryAsync(
+ Guid actorUserId,
+ Guid lobbyId,
+ ChatHistoryDirection direction,
+ string? cursor,
+ int? limit,
+ CancellationToken ct = default)
+ {
+ var pageSize = limit ?? DefaultLimit;
+ if (pageSize < 1 || pageSize > MaxLimit)
+ return Result>.Fail(
+ ChatErrors.ValidationFailed, "Page size must be between 1 and 50.");
+
+ if (!await IsAllowedAsync(actorUserId, lobbyId, RealtimeAction.Subscribe, ct))
+ return Result>.Fail(ChatErrors.NotFound, "Lobby not found.");
+
+ DateTime? cursorCreatedAt = null;
+ Guid? cursorId = null;
+ if (cursor is not null)
+ {
+ if (!Cursor.TryDecodeTimeId(cursor, out var createdAt, out var id))
+ return Result>.Fail(
+ ChatErrors.InvalidCursor, "The pagination cursor is invalid.");
+ cursorCreatedAt = createdAt;
+ cursorId = id;
+ }
+
+ var rows = await _chat.GetHistoryPageAsync(
+ ChatScope.Lobby, lobbyId, direction, cursorCreatedAt, cursorId, pageSize, ct);
+
+ // Block-aware history (M07-F1-security fix, mirrors GetDeliverableRecipientsAsync's realtime
+ // fan-out filter): a blocked co-member's messages are excluded from every history page — including
+ // reconnect resync and a fresh page load, not just live broadcast — so "blocked users do not receive
+ // or infer presence/chat" holds for REST history the same way it already holds for realtime delivery.
+ var visibleRows = await FilterBlockedSendersAsync(actorUserId, rows, ct);
+
+ var senders = await _chat.GetSendersAsync(visibleRows.Select(m => m.SenderId).Distinct().ToList(), ct);
+
+ var items = new List(visibleRows.Count);
+ foreach (var row in visibleRows)
+ items.Add(ToMessageDto(row, await ResolveSenderAsync(row.SenderId, senders, ct)));
+
+ // The cursor always advances past the last row the *query* saw in its own scan direction — for `Before`
+ // (queried DESC, then reversed to ascending output) that is the oldest row returned, i.e. items[0]; for
+ // `After` (queried ASC) that is the newest row returned, i.e. items[^1].
+ string? next = null;
+ if (rows.Count == pageSize)
+ {
+ var cursorRow = direction == ChatHistoryDirection.Before ? rows[0] : rows[^1];
+ next = Cursor.EncodeTimeId(cursorRow.CreatedAt, cursorRow.Id);
+ }
+
+ return Result>.Ok(new CursorPage(items, next));
+ }
+
+ /// Block-aware fan-out list (M07-001 fix): every currently-joined lobby member receives the
+ /// broadcast except one blocked (either direction) with — a plain
+ /// group broadcast would otherwise deliver a blocked co-member's chat activity to the blocker (and vice
+ /// versa). is always included so the sender's own other connections stay in
+ /// sync.
+ private async Task> GetDeliverableRecipientsAsync(
+ Guid lobbyId, Guid senderId, CancellationToken ct)
+ {
+ var lobby = await _lobbies.GetByIdAsync(lobbyId, ct);
+ if (lobby is null)
+ return Array.Empty();
+
+ var recipients = new List();
+ foreach (var member in lobby.JoinedMembers)
+ {
+ if (member.UserId == senderId)
+ {
+ recipients.Add(member.UserId);
+ continue;
+ }
+
+ if (!await _friends.IsBlockedInEitherDirectionAsync(senderId, member.UserId, ct))
+ recipients.Add(member.UserId);
+ }
+
+ return recipients;
+ }
+
+ /// Excludes any row whose sender is blocked (either direction) with
+ /// from a history page. The actor's own messages are always visible. Mirrors
+ /// 's per-member check; lobby history pages are bounded (max 50
+ /// rows, capped distinct senders), so a per-sender pairwise check is cheap.
+ private async Task> FilterBlockedSendersAsync(
+ Guid actorUserId, IReadOnlyList rows, CancellationToken ct)
+ {
+ if (rows.Count == 0)
+ return new List();
+
+ var blockedSenderIds = new HashSet();
+ foreach (var senderId in rows.Select(r => r.SenderId).Distinct())
+ {
+ if (senderId != actorUserId && await _friends.IsBlockedInEitherDirectionAsync(actorUserId, senderId, ct))
+ blockedSenderIds.Add(senderId);
+ }
+
+ return blockedSenderIds.Count == 0
+ ? rows.ToList()
+ : rows.Where(r => !blockedSenderIds.Contains(r.SenderId)).ToList();
+ }
+
+ private async Task IsAllowedAsync(
+ Guid actorUserId, Guid lobbyId, RealtimeAction action, CancellationToken ct)
+ {
+ if (!_authorizers.TryGetValue(RealtimeEnvelope.LobbyScope, out var authorizer))
+ return false;
+
+ var result = await authorizer.AuthorizeAsync(actorUserId, lobbyId, action, ct);
+ return result.IsAllowed;
+ }
+
+ private async Task ToOwnMessageDtoAsync(ChatMessage message, CancellationToken ct)
+ {
+ var senders = await _chat.GetSendersAsync(new[] { message.SenderId }, ct);
+ return ToMessageDto(message, await ResolveSenderAsync(message.SenderId, senders, ct));
+ }
+
+ /// A sender absent from the batch lookup (deleted/unresolvable account) renders the safe
+ /// placeholder — IChatRepository.GetSendersAsync's documented cue. Mirrors
+ /// LobbiesService.ToIdentityAsync so a chat sender's avatar resolves through the same
+ /// presigned-URL path as every other surface that lists a .
+ private async Task ResolveSenderAsync(
+ Guid senderId, IReadOnlyDictionary senders, CancellationToken ct)
+ {
+ if (!senders.TryGetValue(senderId, out var user))
+ return TombstoneSender;
+
+ return new PublicIdentityDto(
+ user.Id, user.Username, user.DisplayName, user.Initials, user.Color,
+ await BuildAvatarUrlAsync(user.AvatarObjectKey, user.AvatarUrl, ct),
+ user.ProfileType.ToString());
+ }
+
+ private async Task BuildAvatarUrlAsync(string? objectKey, string? fallbackUrl, CancellationToken ct)
+ {
+ if (!string.IsNullOrWhiteSpace(objectKey))
+ {
+ var expiry = TimeSpan.FromMinutes(_storageOptions.ReadUrlExpiryMinutes);
+ return await _storage.CreatePresignedReadUrlAsync(objectKey, expiry, ct);
+ }
+ return fallbackUrl;
+ }
+
+ private static ChatMessageDto ToMessageDto(ChatMessage message, PublicIdentityDto sender) => new(
+ message.Id,
+ message.ScopeId,
+ sender,
+ message.IsDeleted ? null : message.Body,
+ message.IsDeleted,
+ message.CreatedAt,
+ message.SchemaVersion);
+}
diff --git a/src/SimPle.Application/Chat/IChatProfanityFilter.cs b/src/SimPle.Application/Chat/IChatProfanityFilter.cs
new file mode 100644
index 0000000..4296ebf
--- /dev/null
+++ b/src/SimPle.Application/Chat/IChatProfanityFilter.cs
@@ -0,0 +1,11 @@
+namespace SimPle.Application.Chat;
+
+/// Automated deny-list/regex profanity screen (docs/module-requirements/module-07-realtime-presence-
+/// chat.md, "CUSTOM" security requirement) run on every outgoing chat body before persistence, in addition to
+/// M12's existing manual report/triage pipeline. A match is a normal validation error
+/// (), never a security event — the message is simply not stored.
+public interface IChatProfanityFilter
+{
+ /// Already -normalized text.
+ bool IsProfane(string normalizedBody);
+}
diff --git a/src/SimPle.Application/Chat/IChatRepository.cs b/src/SimPle.Application/Chat/IChatRepository.cs
new file mode 100644
index 0000000..decc720
--- /dev/null
+++ b/src/SimPle.Application/Chat/IChatRepository.cs
@@ -0,0 +1,61 @@
+using SimPle.Domain.Chat;
+using SimPle.Domain.Users;
+using SimPle.Shared.Common;
+
+namespace SimPle.Application.Chat;
+
+/// Data access for chat persistence (docs/specs/module-07-realtime-presence-chat-spec.md, "Data Model").
+///
+public interface IChatRepository
+{
+ Task GetByIdAsync(Guid id, CancellationToken ct = default);
+
+ /// The idempotency lookup: a duplicate send (client retry) with the same
+ /// (SenderId, ClientCommandId) resolves here instead of ever reaching a unique-constraint race, and the
+ /// caller returns the original message unchanged.
+ Task FindByClientCommandIdAsync(Guid senderId, Guid clientCommandId, CancellationToken ct = default);
+
+ /// Idempotent insert against ux_chat_messages_sender_command: a concurrent duplicate send that
+ /// races past 's own check hits the unique index here instead of
+ /// throwing — the row that actually won the race is returned either way, so the caller never needs to catch
+ /// a persistence-specific exception.
+ Task AddAsync(ChatMessage message, CancellationToken ct = default);
+
+ /// Keyset (cursor) page always returned in (CreatedAt, Id) ascending order, matching
+ /// ix_chat_messages_scope_created_id, regardless of .
+ /// (scrollback): no cursor => the latest page; with a cursor
+ /// => the page immediately older than it. (reconnect repair): no
+ /// cursor => from the start of the scope's history; with a cursor => the page immediately newer than
+ /// it.
+ Task> GetHistoryPageAsync(
+ ChatScope scope,
+ Guid scopeId,
+ ChatHistoryDirection direction,
+ DateTime? cursorCreatedAtUtc,
+ Guid? cursorId,
+ int limit,
+ CancellationToken ct = default);
+
+ /// Batch sender identity resolution for DTO projection, keyed by user id. A sender absent from the
+ /// result (deleted/unresolvable account) is the caller's cue to render a deleted-sender placeholder.
+ Task> GetSendersAsync(IReadOnlyList senderIds, CancellationToken ct = default);
+
+ Task SaveAsync(CancellationToken ct = default);
+
+ /// The retention sweep (docs/specs/module-07-realtime-presence-chat-spec.md, "Cleanup vs moderation
+ /// hold"). Deletes up to rows whose RetainUntilUtc has passed and which
+ /// carry no active , in one bounded statement (CTE with LIMIT +
+ /// FOR UPDATE OF m SKIP LOCKED, then DELETE ... USING) so a row mid-
+ /// is skipped this pass rather than blocked on or deleted out from under it. Returns the number of rows
+ /// deleted.
+ Task DeleteExpiredAsync(DateTime nowUtc, int batchSize, CancellationToken ct = default);
+
+ /// The seam Module 7 builds but never calls (docs/specs/module-07-realtime-presence-chat-spec.md,
+ /// "Data Model" and Risk #5) — Module 12 places a moderation hold through this before the retention sweep can
+ /// delete the message. Takes the same row lock the sweep takes (blocking, not SKIP LOCKED, since a
+ /// hold request must wait its turn rather than give up) so the two race honestly: if the sweep already
+ /// committed the delete, this returns instead of silently losing
+ /// evidence.
+ Task> PlaceHoldAsync(
+ Guid messageId, string reasonCode, DateTime nowUtc, CancellationToken ct = default);
+}
diff --git a/src/SimPle.Application/Chat/IChatService.cs b/src/SimPle.Application/Chat/IChatService.cs
new file mode 100644
index 0000000..06d1b20
--- /dev/null
+++ b/src/SimPle.Application/Chat/IChatService.cs
@@ -0,0 +1,31 @@
+using SimPle.Application.Realtime.Contracts;
+using SimPle.Shared.Common;
+
+namespace SimPle.Application.Chat;
+
+///
+/// The chat command/query surface (docs/specs/module-07-realtime-presence-chat-spec.md, "API Contract"). The sole
+/// send/delete/history authorization path — both RealtimeHub.SendLobbyMessage and ChatController
+/// call into this service rather than re-implementing scope authorization themselves.
+///
+public interface IChatService
+{
+ /// Sends a lobby chat message. Idempotent on : a retried send with
+ /// the same (actorUserId, clientCommandId) pair returns the original message unchanged, before the
+ /// rate limiter or profanity filter ever runs again.
+ Task> SendAsync(
+ Guid actorUserId, Guid lobbyId, string? body, Guid clientCommandId, CancellationToken ct = default);
+
+ /// Author-only tombstone delete. Idempotent — a retried delete on an already-deleted message is a
+ /// no-op that still succeeds.
+ Task DeleteAsync(Guid actorUserId, Guid messageId, CancellationToken ct = default);
+
+ /// Keyset-paginated lobby chat history. defaults to 30, capped at 50.
+ Task>> GetHistoryAsync(
+ Guid actorUserId,
+ Guid lobbyId,
+ ChatHistoryDirection direction,
+ string? cursor,
+ int? limit,
+ CancellationToken ct = default);
+}
diff --git a/src/SimPle.Application/Chat/ProfanityOptions.cs b/src/SimPle.Application/Chat/ProfanityOptions.cs
new file mode 100644
index 0000000..e22bf70
--- /dev/null
+++ b/src/SimPle.Application/Chat/ProfanityOptions.cs
@@ -0,0 +1,16 @@
+namespace SimPle.Application.Chat;
+
+/// Backs . The deny list is a versioned server-side configuration
+/// value (docs/module-requirements/module-07-realtime-presence-chat.md, "CUSTOM" security requirement) —
+/// never a client-editable input.
+public sealed class ProfanityOptions
+{
+ public const string SectionName = "Profanity";
+
+ /// Bumped whenever changes. Not surfaced to clients today; exists so this stays
+ /// a versioned configuration value rather than an untracked ad hoc list.
+ public int Version { get; set; } = 1;
+
+ /// Case-insensitive whole-word deny-list terms. Environment/appsettings-supplied only.
+ public IReadOnlyList Terms { get; set; } = Array.Empty();
+}
diff --git a/src/SimPle.Application/Common/Interfaces/IRealtimeConnectionCloser.cs b/src/SimPle.Application/Common/Interfaces/IRealtimeConnectionCloser.cs
new file mode 100644
index 0000000..1e4de24
--- /dev/null
+++ b/src/SimPle.Application/Common/Interfaces/IRealtimeConnectionCloser.cs
@@ -0,0 +1,25 @@
+namespace SimPle.Application.Common.Interfaces;
+
+///
+/// Proactively closes a user's mapped realtime (SignalR) connections. This is one of three independent
+/// mechanisms Module 7's authorization model requires (see docs/specs/module-07-realtime-presence-chat-spec.md,
+/// "The load-bearing rule"): SignalR caches the authenticated principal for the connection's lifetime and does
+/// not revalidate it automatically, so logout / logout-all / revoke-session / delete-account must proactively
+/// close any live sockets rather than relying on token-expiry alone.
+///
+/// The default DI registration is NullRealtimeConnectionCloser — a no-op — so that Auth flows never throw
+/// when the realtime hub is disabled (e.g. as a rollback). The real SignalR-backed implementation lives in the
+/// API layer (it needs the concrete hub type) and overrides this registration when the hub is wired up.
+///
+public interface IRealtimeConnectionCloser
+{
+ ///
+ /// Closes every realtime connection currently mapped to , if any. Never throws for a
+ /// user with no connections.
+ ///
+ ///
+ /// One of: lobby.membership_removed, lobby.closed, auth.session_revoked,
+ /// auth.suspended, social.blocked — sent to the client as AccessRevoked before closing.
+ ///
+ Task CloseUserConnectionsAsync(Guid userId, string reason, CancellationToken ct = default);
+}
diff --git a/src/SimPle.Application/DependencyInjection.cs b/src/SimPle.Application/DependencyInjection.cs
index 9911f37..0616cb2 100644
--- a/src/SimPle.Application/DependencyInjection.cs
+++ b/src/SimPle.Application/DependencyInjection.cs
@@ -10,6 +10,8 @@
using SimPle.Application.Outbox.Handlers;
using SimPle.Application.People.Services;
using SimPle.Application.Profiles.Services;
+using SimPle.Application.Realtime.Authorization;
+using SimPle.Application.Realtime.Presence;
namespace SimPle.Application;
@@ -42,6 +44,19 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
services.AddScoped();
services.AddScoped();
+ // Module 7 (docs/specs/module-07-realtime-presence-chat-spec.md), backend session A (M07-B1): transport,
+ // authorization, presence only — no chat, no migration. IPresenceRegistry is a process-lifetime singleton
+ // (in-memory only, no cross-instance sync; see spec Risk Register). Both scope authorizers are registered
+ // even though only "lobby" has a caller in B1 — NullMatchScopeAuthorizer exists so a request against a
+ // scope kind with no real authorizer yet fails closed with realtime.scope_not_available rather than 500ing.
+ services.AddSingleton();
+ services.AddScoped();
+ services.AddScoped();
+
+ // Scoped, matching its ILobbyRepository dependency (EF Core's DbContext is scoped) — resolves who should
+ // see a PresenceChanged broadcast (self + current lobby co-members, block-filtered).
+ services.AddScoped();
+
return services;
}
}
diff --git a/src/SimPle.Application/Lobbies/Outbox/LobbyOutbox.cs b/src/SimPle.Application/Lobbies/Outbox/LobbyOutbox.cs
index fd09138..57faeb8 100644
--- a/src/SimPle.Application/Lobbies/Outbox/LobbyOutbox.cs
+++ b/src/SimPle.Application/Lobbies/Outbox/LobbyOutbox.cs
@@ -41,6 +41,11 @@ public static class LobbyOutbox
public const string LobbyCredentialRotated = "LobbyCredentialRotatedV1";
public const string MatchRequested = "MatchRequestedV1";
+ /// Added for M07-B2: LobbyRealtimeHandler's one new consumed event type, so a readiness
+ /// toggle produces the same LobbyChanged hint every other Lobby-aggregate mutation does. Ids only,
+ /// matching every other event on this aggregate.
+ public const string LobbyReadinessChanged = "LobbyReadinessChangedV1";
+
public static OutboxMessage LobbyCreatedEvent(Lobby lobby) =>
LobbyEvent(lobby, LobbyCreated, new { lobbyId = lobby.Id, hostUserId = lobby.HostUserId, gameSlug = lobby.GameSlug });
@@ -66,6 +71,9 @@ public static OutboxMessage LobbyClosedEvent(Lobby lobby) =>
public static OutboxMessage CredentialRotatedEvent(Lobby lobby, int generation) =>
LobbyEvent(lobby, LobbyCredentialRotated, new { lobbyId = lobby.Id, generation });
+ public static OutboxMessage ReadinessChangedEvent(Lobby lobby, Guid userId, bool isReady) =>
+ LobbyEvent(lobby, LobbyReadinessChanged, new { lobbyId = lobby.Id, userId, isReady });
+
///
/// The event M8 consumes. Ids only: M8 re-reads the lobby it names rather than trusting a settings snapshot
/// that could already be stale by the time it is delivered.
diff --git a/src/SimPle.Application/Lobbies/Services/LobbiesService.cs b/src/SimPle.Application/Lobbies/Services/LobbiesService.cs
index 75d73fd..f31d448 100644
--- a/src/SimPle.Application/Lobbies/Services/LobbiesService.cs
+++ b/src/SimPle.Application/Lobbies/Services/LobbiesService.cs
@@ -349,7 +349,7 @@ public Task> SetReadinessAsync(
var outcome = lobby.SetReadiness(actorUserId, request.IsReady, nowUtc);
if (outcome != LobbyOutcome.Ok) return MapOutcome(outcome);
- await _lobbies.SaveAsync(Array.Empty(), token);
+ await _lobbies.SaveAsync(new[] { LobbyOutbox.ReadinessChangedEvent(lobby, actorUserId, request.IsReady) }, token);
return Result.Ok(await ProjectAsync(lobby, actorUserId, token));
}, ct);
diff --git a/src/SimPle.Application/Outbox/IOutboxActivationStore.cs b/src/SimPle.Application/Outbox/IOutboxActivationStore.cs
new file mode 100644
index 0000000..8d9f046
--- /dev/null
+++ b/src/SimPle.Application/Outbox/IOutboxActivationStore.cs
@@ -0,0 +1,26 @@
+using SimPle.Domain.Outbox;
+
+namespace SimPle.Application.Outbox;
+
+/// Data access for the per-handler activation watermark (docs/specs/module-07-realtime-presence-chat-
+/// spec.md, "Activation watermark"). See for why this exists.
+public interface IOutboxActivationStore
+{
+ ///
+ /// Returns this handler's activation row, creating it on first call. The watermark captured on that first
+ /// call is MAX(OccurredAtUtc, Id) over in the outbox at that moment —
+ /// everything at or before it is pre-existing history the handler must never replay as live traffic.
+ ///
+ ///
+ /// Computed only on the creating call, inside the same idempotent-insert attempt — never on a call that finds
+ /// the row already present, so the watermark is always the activation moment, never a later re-check.
+ /// Concurrent first-activation callers race on the unique HandlerName primary key: the loser's insert
+ /// catches 23505 and simply re-reads the winner's row rather than computing its own.
+ ///
+ ///
+ Task GetOrActivateAsync(
+ string handlerName,
+ IReadOnlyList eventTypes,
+ DateTime nowUtc,
+ CancellationToken ct = default);
+}
diff --git a/src/SimPle.Application/Realtime/Authorization/IRealtimeScopeAuthorizer.cs b/src/SimPle.Application/Realtime/Authorization/IRealtimeScopeAuthorizer.cs
new file mode 100644
index 0000000..58e9421
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Authorization/IRealtimeScopeAuthorizer.cs
@@ -0,0 +1,16 @@
+namespace SimPle.Application.Realtime.Authorization;
+
+///
+/// Authorizes a user's action against a single realtime scope (a lobby, a match, ...). Every hub method that
+/// touches a scope must call this — cached principal claims from handshake time are never trusted alone (see
+/// "the load-bearing rule" in docs/specs/module-07-realtime-presence-chat-spec.md): membership, blocks, and
+/// suspension can all change after a connection is already open, so every method rechecks current owner data.
+///
+public interface IRealtimeScopeAuthorizer
+{
+ /// The scope kind this authorizer handles: "lobby" | "match" (see RealtimeEnvelope scope constants).
+ string ScopeKind { get; }
+
+ Task AuthorizeAsync(
+ Guid actorUserId, Guid scopeId, RealtimeAction action, CancellationToken ct = default);
+}
diff --git a/src/SimPle.Application/Realtime/Authorization/LobbyScopeAuthorizer.cs b/src/SimPle.Application/Realtime/Authorization/LobbyScopeAuthorizer.cs
new file mode 100644
index 0000000..b5d0fab
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Authorization/LobbyScopeAuthorizer.cs
@@ -0,0 +1,67 @@
+using SimPle.Application.Common.Interfaces;
+using SimPle.Application.Lobbies.Services;
+using SimPle.Application.Realtime.Contracts;
+using SimPle.Domain.Lobbies;
+
+namespace SimPle.Application.Realtime.Authorization;
+
+///
+/// Authorizes lobby-scope realtime actions, mirroring LobbiesService.GetAsync's exact privacy rule
+/// (docs/specs/module-07-realtime-presence-chat-spec.md, "Authorization / Privacy rules"): existence is never
+/// disclosed, so every denial collapses to the same code already used by the
+/// REST API — a distinct "forbidden" code here would itself leak that the lobby exists.
+///
+/// Called on every hub method that touches the scope, never only at connect/subscribe time — the "load-bearing
+/// rule": a cached principal from handshake time is not trusted for authorization, only for identity.
+///
+public sealed class LobbyScopeAuthorizer : IRealtimeScopeAuthorizer
+{
+ private readonly ILobbyRepository _lobbies;
+ private readonly IUserRepository _users;
+ private readonly IFriendRepository _friends;
+
+ public LobbyScopeAuthorizer(ILobbyRepository lobbies, IUserRepository users, IFriendRepository friends)
+ {
+ _lobbies = lobbies;
+ _users = users;
+ _friends = friends;
+ }
+
+ public string ScopeKind => RealtimeEnvelope.LobbyScope;
+
+ public async Task AuthorizeAsync(
+ Guid actorUserId, Guid scopeId, RealtimeAction action, CancellationToken ct = default)
+ {
+ var lobby = await _lobbies.GetByIdAsync(scopeId, ct);
+ if (lobby is null)
+ return Deny();
+
+ var isMember = lobby.FindJoinedMember(actorUserId) is not null;
+ var isPubliclyVisible = lobby.Privacy == LobbyPrivacy.Public && lobby.State == LobbyState.Open;
+
+ // Subscribing (read-only) allows public-and-open lobbies even for non-members, matching the REST
+ // GetAsync visibility rule exactly. Send/Delete always require membership regardless of visibility —
+ // an anonymous observer of a public lobby is never allowed to act inside it.
+ var visibilityAllows = action == RealtimeAction.Subscribe ? isMember || isPubliclyVisible : isMember;
+ if (!visibilityAllows)
+ return Deny();
+
+ var actor = await _users.GetByIdAsync(actorUserId, ct);
+ if (actor is null || actor.IsAccountSuspended())
+ return Deny();
+
+ // Block check: only meaningful against the host (the only other party guaranteed to matter for every
+ // lobby, public or private). Self-guard avoids a false positive when the actor is the host.
+ if (lobby.HostUserId != actorUserId)
+ {
+ var blocked = await _friends.IsBlockedInEitherDirectionAsync(actorUserId, lobby.HostUserId, ct);
+ if (blocked)
+ return Deny();
+ }
+
+ return RealtimeScopeAuthorizationResult.Allow();
+ }
+
+ private static RealtimeScopeAuthorizationResult Deny() =>
+ RealtimeScopeAuthorizationResult.Deny(LobbyErrors.NotFound);
+}
diff --git a/src/SimPle.Application/Realtime/Authorization/NullMatchScopeAuthorizer.cs b/src/SimPle.Application/Realtime/Authorization/NullMatchScopeAuthorizer.cs
new file mode 100644
index 0000000..50ff007
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Authorization/NullMatchScopeAuthorizer.cs
@@ -0,0 +1,19 @@
+using SimPle.Application.Realtime.Contracts;
+
+namespace SimPle.Application.Realtime.Authorization;
+
+///
+/// Match-scope realtime does not exist yet — there is no Module 8 (matches) to authorize against. This
+/// authorizer always denies with realtime.scope_not_available so the hub can declare the "match" scope
+/// kind in its routing without special-casing "scope kind doesn't exist" as a distinct code path.
+///
+public sealed class NullMatchScopeAuthorizer : IRealtimeScopeAuthorizer
+{
+ public const string ScopeNotAvailableCode = "realtime.scope_not_available";
+
+ public string ScopeKind => RealtimeEnvelope.MatchScope;
+
+ public Task AuthorizeAsync(
+ Guid actorUserId, Guid scopeId, RealtimeAction action, CancellationToken ct = default) =>
+ Task.FromResult(RealtimeScopeAuthorizationResult.Deny(ScopeNotAvailableCode));
+}
diff --git a/src/SimPle.Application/Realtime/Authorization/RealtimeAction.cs b/src/SimPle.Application/Realtime/Authorization/RealtimeAction.cs
new file mode 100644
index 0000000..6bb57ad
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Authorization/RealtimeAction.cs
@@ -0,0 +1,14 @@
+namespace SimPle.Application.Realtime.Authorization;
+
+///
+/// The action being authorized against a realtime scope (docs/specs/module-07-realtime-presence-chat-spec.md,
+/// "Authorization / Privacy rules"). B1 only ever calls (via SubscribeLobby); the
+/// authorizer must still implement / now because B2's chat commands will
+/// call them and the authorization surface must not change shape between backend sessions.
+///
+public enum RealtimeAction
+{
+ Subscribe,
+ Send,
+ Delete,
+}
diff --git a/src/SimPle.Application/Realtime/Authorization/RealtimeScopeAuthorizationResult.cs b/src/SimPle.Application/Realtime/Authorization/RealtimeScopeAuthorizationResult.cs
new file mode 100644
index 0000000..a2c324f
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Authorization/RealtimeScopeAuthorizationResult.cs
@@ -0,0 +1,24 @@
+namespace SimPle.Application.Realtime.Authorization;
+
+///
+/// Allow/deny result for a realtime scope authorization check. Mirrors the Result/Result<T> Allow/Fail
+/// static-factory convention already used across the Application layer (see SimPle.Shared.Common.Result), but is
+/// intentionally its own type: authorization denials here always collapse to a single privacy-safe error code so
+/// existence is never disclosed (docs/specs/module-07-realtime-presence-chat-spec.md, "privacy-safe: existence
+/// never disclosed").
+///
+public sealed class RealtimeScopeAuthorizationResult
+{
+ public bool IsAllowed { get; }
+ public string? ErrorCode { get; }
+
+ private RealtimeScopeAuthorizationResult(bool isAllowed, string? errorCode)
+ {
+ IsAllowed = isAllowed;
+ ErrorCode = errorCode;
+ }
+
+ public static RealtimeScopeAuthorizationResult Allow() => new(true, null);
+
+ public static RealtimeScopeAuthorizationResult Deny(string errorCode) => new(false, errorCode);
+}
diff --git a/src/SimPle.Application/Realtime/Contracts/ChatMessageDto.cs b/src/SimPle.Application/Realtime/Contracts/ChatMessageDto.cs
new file mode 100644
index 0000000..cf732f5
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Contracts/ChatMessageDto.cs
@@ -0,0 +1,19 @@
+using SimPle.Shared.Common;
+
+namespace SimPle.Application.Realtime.Contracts;
+
+///
+/// Safe-fields-only chat message DTO (docs/specs/module-07-realtime-presence-chat-spec.md, "Response DTO").
+/// Sender is the shared M3 player-identity contract, — the same shape
+/// LobbySeatDto.Identity/Host/Inviter already embed (see LobbyDtos.cs). Composing
+/// modules must never clone an incompatible identity shape ('s own doc comment).
+/// A deleted, blocked, or hidden sender renders a non-navigable safe tombstone — never the real profile fields.
+///
+public sealed record ChatMessageDto(
+ Guid Id,
+ Guid LobbyId,
+ PublicIdentityDto Sender,
+ string? Body,
+ bool Deleted,
+ DateTime CreatedAt,
+ int SchemaVersion);
diff --git a/src/SimPle.Application/Realtime/Contracts/IRealtimeClient.cs b/src/SimPle.Application/Realtime/Contracts/IRealtimeClient.cs
new file mode 100644
index 0000000..c32aa29
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Contracts/IRealtimeClient.cs
@@ -0,0 +1,39 @@
+namespace SimPle.Application.Realtime.Contracts;
+
+///
+/// The typed client contract for Hub<IRealtimeClient>
+/// (docs/specs/module-07-realtime-presence-chat-spec.md, "Server->client events"). All seven events —
+/// including the two chat events — are declared and implemented in backend session A (M07-B1), even though
+/// nothing sends / yet: this is the one place B1
+/// does work it does not consume, so that B2 (chat persistence) adds zero new hub/client contract surface.
+///
+/// Every event carries a . Delivery is at-least-once, never exactly-once — callers
+/// must dedupe (chat by message id, presence by (serverEpoch, userVersion), lobby hints by revision).
+///
+public interface IRealtimeClient
+{
+ /// Sent from OnConnectedAsync. This is where the client learns the server epoch.
+ Task Connected(RealtimeEnvelope envelope, Guid serverEpoch);
+
+ /// A thin hint carrying only a revision — never lobby state. Three events at one revision collapse
+ /// into exactly one of these.
+ Task LobbyChanged(RealtimeEnvelope envelope, int revision, string changeType);
+
+ /// Dedupe by (serverEpoch, userVersion); a changed epoch clears all cached presence.
+ Task PresenceChanged(RealtimeEnvelope envelope, Guid userId, string status, Guid serverEpoch, long userVersion);
+
+ /// Not sent by anything in B1 — chat does not exist yet. Declared for B2.
+ Task ChatMessageCreated(RealtimeEnvelope envelope, ChatMessageDto message);
+
+ /// Not sent by anything in B1 — chat does not exist yet. Declared for B2.
+ Task ChatMessageDeleted(RealtimeEnvelope envelope, Guid messageId, DateTime deletedAtUtc);
+
+ ///
+ /// Reasons: lobby.membership_removed | lobby.closed | auth.session_revoked |
+ /// auth.suspended | social.blocked.
+ ///
+ Task AccessRevoked(RealtimeEnvelope envelope, string reason);
+
+ /// Exactly two reasons: gap, slow_consumer.
+ Task ResyncRequired(RealtimeEnvelope envelope, string reason, int? currentRevision);
+}
diff --git a/src/SimPle.Application/Realtime/Contracts/IRealtimeNotifier.cs b/src/SimPle.Application/Realtime/Contracts/IRealtimeNotifier.cs
new file mode 100644
index 0000000..116f0e2
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Contracts/IRealtimeNotifier.cs
@@ -0,0 +1,38 @@
+namespace SimPle.Application.Realtime.Contracts;
+
+///
+/// Server-side fan-out abstraction wrapping plus envelope construction and
+/// group/user targeting. Declared and implemented in B1 (the concrete SignalR-backed implementation lives in the
+/// API layer, since it needs the concrete hub type) but has no caller yet — B2's outbox handler
+/// (LobbyRealtimeHandler) and chat commands are what will actually invoke these methods. Shipping this now
+/// with its final shape is what keeps B2 from needing to touch the hub/client contract at all.
+///
+public interface IRealtimeNotifier
+{
+ Task NotifyLobbyChangedAsync(Guid lobbyId, int revision, string changeType, CancellationToken ct = default);
+
+ Task NotifyPresenceChangedAsync(
+ Guid subjectUserId, IReadOnlyCollection viewerUserIds, string status, Guid serverEpoch,
+ long userVersion, CancellationToken ct = default);
+
+ /// Delivered only to , not the whole lobby group — the caller
+ /// (ChatService) has already excluded any member blocked (either direction) with the sender, so a
+ /// group broadcast here would bypass that filtering. The sender is always included, for their own other
+ /// connections.
+ Task NotifyChatMessageCreatedAsync(
+ Guid lobbyId, IReadOnlyCollection recipientUserIds, ChatMessageDto message,
+ CancellationToken ct = default);
+
+ /// See — same block-aware recipient filtering
+ /// applies to delete/tombstone fan-out.
+ Task NotifyChatMessageDeletedAsync(
+ Guid lobbyId, IReadOnlyCollection recipientUserIds, Guid messageId, DateTime deletedAtUtc,
+ CancellationToken ct = default);
+
+ /// Sends AccessRevoked to the user's connections. Does not close them — pair with
+ /// IRealtimeConnectionCloser for an actual proactive close.
+ Task NotifyAccessRevokedAsync(Guid userId, string reason, CancellationToken ct = default);
+
+ Task NotifyResyncRequiredAsync(
+ Guid lobbyId, string reason, int? currentRevision, CancellationToken ct = default);
+}
diff --git a/src/SimPle.Application/Realtime/Contracts/RealtimeEnvelope.cs b/src/SimPle.Application/Realtime/Contracts/RealtimeEnvelope.cs
new file mode 100644
index 0000000..68aba0f
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Contracts/RealtimeEnvelope.cs
@@ -0,0 +1,26 @@
+namespace SimPle.Application.Realtime.Contracts;
+
+///
+/// Every server->client realtime event carries this envelope (docs/specs/module-07-realtime-presence-chat-spec.md,
+/// API Contract). is one of , ,
+/// — never anything else.
+///
+/// Forward-compatibility marker for the envelope/payload shape.
+/// Unique per emitted event; used for client-side dedupe.
+/// The server's clock at emission time. Never used for security decisions.
+/// "lobby" | "match" | "user".
+/// The lobby id, match id, or user id the event concerns.
+public sealed record RealtimeEnvelope(int SchemaVersion, Guid EventId, DateTime ServerUtc, string Scope, Guid ScopeId)
+{
+ public const string LobbyScope = "lobby";
+ public const string MatchScope = "match";
+ public const string UserScope = "user";
+
+ public const int CurrentSchemaVersion = 1;
+
+ public static RealtimeEnvelope ForLobby(Guid lobbyId, DateTime nowUtc) =>
+ new(CurrentSchemaVersion, Guid.NewGuid(), nowUtc, LobbyScope, lobbyId);
+
+ public static RealtimeEnvelope ForUser(Guid userId, DateTime nowUtc) =>
+ new(CurrentSchemaVersion, Guid.NewGuid(), nowUtc, UserScope, userId);
+}
diff --git a/src/SimPle.Application/Realtime/Contracts/RealtimeGroups.cs b/src/SimPle.Application/Realtime/Contracts/RealtimeGroups.cs
new file mode 100644
index 0000000..4708c5c
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Contracts/RealtimeGroups.cs
@@ -0,0 +1,11 @@
+namespace SimPle.Application.Realtime.Contracts;
+
+///
+/// SignalR group names are server-derived, never client-supplied — clients pass lobby ids only (never group
+/// names, never lobby codes). Groups are a delivery optimization only; they are never checked for authorization
+/// and never used as membership storage (docs/specs/module-07-realtime-presence-chat-spec.md).
+///
+public static class RealtimeGroups
+{
+ public static string Lobby(Guid lobbyId) => $"lobby:{lobbyId:N}";
+}
diff --git a/src/SimPle.Application/Realtime/Contracts/SendLobbyMessageResultDto.cs b/src/SimPle.Application/Realtime/Contracts/SendLobbyMessageResultDto.cs
new file mode 100644
index 0000000..4d56693
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Contracts/SendLobbyMessageResultDto.cs
@@ -0,0 +1,5 @@
+namespace SimPle.Application.Realtime.Contracts;
+
+/// Result of the hub's SendLobbyMessage method (docs/specs/module-07-realtime-presence-chat-spec.
+/// md, API contract). Mirrors 's shape.
+public sealed record SendLobbyMessageResultDto(ChatMessageDto Message);
diff --git a/src/SimPle.Application/Realtime/Contracts/SubscribeLobbyResultDto.cs b/src/SimPle.Application/Realtime/Contracts/SubscribeLobbyResultDto.cs
new file mode 100644
index 0000000..d6ac892
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Contracts/SubscribeLobbyResultDto.cs
@@ -0,0 +1,8 @@
+namespace SimPle.Application.Realtime.Contracts;
+
+///
+/// Ack returned from RealtimeHub.SubscribeLobby. Carries the lobby's current revision so the client can
+/// immediately fetch the authorized snapshot (GET /api/lobbies/{lobbyId}) without waiting for a
+/// ResyncRequired — subscribing never needs one.
+///
+public sealed record SubscribeLobbyResultDto(int Revision);
diff --git a/src/SimPle.Application/Realtime/IRealtimeRateLimiter.cs b/src/SimPle.Application/Realtime/IRealtimeRateLimiter.cs
new file mode 100644
index 0000000..0b88f44
--- /dev/null
+++ b/src/SimPle.Application/Realtime/IRealtimeRateLimiter.cs
@@ -0,0 +1,22 @@
+namespace SimPle.Application.Realtime;
+
+///
+/// Hub-invocation rate limiting. ASP.NET Core's built-in Microsoft.AspNetCore.RateLimiting middleware only
+/// applies to HTTP endpoints, not SignalR hub method invocations, so this is a small hand-rolled abstraction over
+/// System.Threading.RateLimiting.PartitionedRateLimiter<Guid> (docs/specs/module-07-realtime-presence-chat-spec.md).
+/// Unused by any caller in B1 (no SendLobbyMessage exists yet) but declared now so B2's chat send path has
+/// a stable rate-limiting contract to call into.
+///
+public interface IRealtimeRateLimiter
+{
+ /// True if may open one more realtime connection (max 5 concurrent).
+ /// Does not reserve/consume anything by itself — pair with on disconnect.
+ bool TryAcquireConnection(Guid userId);
+
+ /// Releases one connection slot previously acquired via .
+ void ReleaseConnection(Guid userId);
+
+ /// True if may send one more message right now: 5 per 5s burst AND
+ /// 20 per 60s sustained — both must pass.
+ bool TryAcquireMessage(Guid userId);
+}
diff --git a/src/SimPle.Application/Realtime/Outbox/LobbyRealtimeHandler.cs b/src/SimPle.Application/Realtime/Outbox/LobbyRealtimeHandler.cs
new file mode 100644
index 0000000..292ce55
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Outbox/LobbyRealtimeHandler.cs
@@ -0,0 +1,224 @@
+using System.Collections.Concurrent;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using SimPle.Application.Common.Interfaces;
+using SimPle.Application.Lobbies.Outbox;
+using SimPle.Application.Outbox;
+using SimPle.Application.Realtime.Contracts;
+using SimPle.Application.Realtime.Presence;
+using SimPle.Domain.Outbox;
+
+namespace SimPle.Application.Realtime.Outbox;
+
+///
+/// Consumes every Lobby-aggregate integration event (docs/specs/module-07-realtime-presence-chat-spec.md,
+/// "Activation watermark") and turns it into the thin LobbyChanged hint — never lobby state itself. Only
+/// events built via 's Lobby-aggregate helper are consumed: LobbyInvite* and
+/// MatchRequestedV1 key off a different aggregate id (the invite/request, not the lobby) and a different
+/// domain-version counter, so mixing them into this handler's revision bookkeeping would be wrong, not just
+/// unnecessary.
+///
+///
+/// Backfill suppression. The first time this handler ever runs, OutboxRepository
+/// .BackfillMissingDeliveriesAsync materializes a delivery row for every historical Lobby-aggregate event —
+/// without a watermark, that first boot would replay the platform's entire lobby history as live realtime
+/// traffic. captures MAX(OccurredAtUtc, Id) over this handler's own
+/// event types at first activation; any message at or before that instant is pre-existing history and is
+/// suppressed with no fan-out at all.
+///
+///
+///
+/// Revision bookkeeping is deliberately in-memory and per-process (), the same tradeoff B1's presence registry already makes — OutboxDelivery
+/// is the durable at-least-once record; this dictionary only exists to collapse same-revision fan-out and detect
+/// gaps within one process's lifetime. Losing it on restart costs at most a redundant hint or two, never a
+/// correctness problem, because LobbyChanged is a hint the client always resolves by re-fetching the
+/// authoritative snapshot.
+///
+///
+public sealed class LobbyRealtimeHandler : IOutboxHandler
+{
+ public const string Name = "lobby-realtime";
+
+ private static readonly IReadOnlyList ConsumedEventTypes = new[]
+ {
+ LobbyOutbox.LobbyCreated,
+ LobbyOutbox.LobbyMemberJoined,
+ LobbyOutbox.LobbyMemberLeft,
+ LobbyOutbox.LobbyMemberKicked,
+ LobbyOutbox.LobbyHostTransferred,
+ LobbyOutbox.LobbySettingsChanged,
+ LobbyOutbox.LobbyClosed,
+ LobbyOutbox.LobbyCredentialRotated,
+ LobbyOutbox.LobbyReadinessChanged,
+ };
+
+ private readonly IOutboxActivationStore _activation;
+ private readonly IRealtimeNotifier _notifier;
+ private readonly IPresenceRegistry _presence;
+ private readonly IPresenceViewerResolver _viewerResolver;
+ private readonly ILobbyRepository _lobbies;
+ private readonly TimeProvider _clock;
+ private readonly ILogger _logger;
+
+ /// lobbyId -> last-notified Lobby.Revision (the event's AggregateDomainVersion).
+ private readonly ConcurrentDictionary _lastNotifiedRevision = new();
+
+ private static readonly JsonSerializerOptions PayloadOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ public LobbyRealtimeHandler(
+ IOutboxActivationStore activation,
+ IRealtimeNotifier notifier,
+ IPresenceRegistry presence,
+ IPresenceViewerResolver viewerResolver,
+ ILobbyRepository lobbies,
+ TimeProvider clock,
+ ILogger logger)
+ {
+ _activation = activation;
+ _notifier = notifier;
+ _presence = presence;
+ _viewerResolver = viewerResolver;
+ _lobbies = lobbies;
+ _clock = clock;
+ _logger = logger;
+ }
+
+ /// Persisted in every delivery row. Renaming it replays the entire lobby-realtime history.
+ public string HandlerName => Name;
+
+ public IReadOnlyList EventTypes => ConsumedEventTypes;
+
+ public async Task HandleAsync(OutboxMessage message, CancellationToken ct = default)
+ {
+ var nowUtc = _clock.GetUtcNow().UtcDateTime;
+ var activation = await _activation.GetOrActivateAsync(HandlerName, ConsumedEventTypes, nowUtc, ct);
+
+ if (IsAtOrBeforeWatermark(message, activation))
+ return;
+
+ // Independent of the revision-collapse dedup below: that dedup only exists to collapse the LobbyChanged
+ // *hint* when several events share one Lobby.Revision (e.g. Leave + HostTransfer + Close), never to
+ // suppress a genuine membership-flag mutation. Presence must react to every one of those sibling events.
+ await ApplyPresenceSideEffectAsync(message, ct);
+
+ // For every event built via LobbyOutbox's LobbyEvent() helper — every type in ConsumedEventTypes — the
+ // aggregate id *is* the lobby id and the domain version *is* Lobby.Revision. The handler never trusts the
+ // payload for anything (docs: "the handler never trusts the payload") — this is metadata on the envelope
+ // itself, not the JSON body.
+ var lobbyId = message.AggregateId;
+ var revision = message.AggregateDomainVersion;
+
+ var lastSeen = _lastNotifiedRevision.GetOrAdd(lobbyId, 0L);
+
+ if (revision <= lastSeen)
+ {
+ // At or behind the last-announced revision: a duplicate at-least-once redelivery of this exact
+ // message, a sibling event collapsed at the same Lobby.Revision (Leave legitimately emits
+ // MemberLeftV1 + HostTransferredV1 + LobbyClosedV1 at one revision — they must collapse into
+ // exactly one LobbyChanged), or a rare out-of-order older event arriving after a newer one already
+ // announced. Nothing new to tell clients either way.
+ return;
+ }
+
+ // A first-ever post-watermark sighting of a lobby (lastSeen == 0, its GetOrAdd default) has no prior
+ // baseline to compare against, so it is accepted as the new baseline rather than flagged as a gap — the
+ // client's own GET /api/lobbies/{lobbyId} is the source of truth; this hint only tells it to fetch one.
+ if (lastSeen != 0 && revision > lastSeen + 1)
+ {
+ _lastNotifiedRevision[lobbyId] = revision;
+ await _notifier.NotifyResyncRequiredAsync(lobbyId, "gap", (int)revision, ct);
+ _logger.LogInformation(
+ "Realtime: lobby revision gap detected. LobbyId={LobbyId} LastSeen={LastSeen} Revision={Revision}",
+ lobbyId, lastSeen, revision);
+ return;
+ }
+
+ _lastNotifiedRevision[lobbyId] = revision;
+ await _notifier.NotifyLobbyChangedAsync(lobbyId, (int)revision, message.EventType, ct);
+ }
+
+ /// The outbox has no global sequence, so (OccurredAtUtc, Id) — the same key the watermark was
+ /// captured with — is the only ordering comparison available.
+ private static bool IsAtOrBeforeWatermark(OutboxMessage message, OutboxHandlerActivation activation)
+ {
+ if (message.OccurredAtUtc < activation.WatermarkOccurredAtUtc) return true;
+ if (message.OccurredAtUtc > activation.WatermarkOccurredAtUtc) return false;
+
+ return activation.WatermarkEventId is Guid watermarkId && message.Id.CompareTo(watermarkId) <= 0;
+ }
+
+ ///
+ /// Turns a membership-affecting Lobby event into the presence-registry mutation
+ /// never gets told about on its own — nothing previously called SetLobbyMembership or
+ /// NotifyPresenceChangedAsync for any of these transitions, which is why a lobby seat's presence dot
+ /// never lit up (the bug this handler exists to fix).
+ ///
+ private async Task ApplyPresenceSideEffectAsync(OutboxMessage message, CancellationToken ct)
+ {
+ switch (message.EventType)
+ {
+ case LobbyOutbox.LobbyCreated:
+ {
+ var payload = JsonSerializer.Deserialize(message.Payload, PayloadOptions);
+ if (payload is null || payload.HostUserId == Guid.Empty) { LogMalformedPayload(message); return; }
+ await SetMembershipAndBroadcastAsync(payload.HostUserId, payload.LobbyId, true, ct);
+ return;
+ }
+ case LobbyOutbox.LobbyMemberJoined:
+ {
+ var payload = JsonSerializer.Deserialize(message.Payload, PayloadOptions);
+ if (payload is null || payload.UserId == Guid.Empty) { LogMalformedPayload(message); return; }
+ await SetMembershipAndBroadcastAsync(payload.UserId, payload.LobbyId, true, ct);
+ return;
+ }
+ case LobbyOutbox.LobbyMemberLeft:
+ case LobbyOutbox.LobbyMemberKicked:
+ {
+ var payload = JsonSerializer.Deserialize(message.Payload, PayloadOptions);
+ if (payload is null || payload.UserId == Guid.Empty) { LogMalformedPayload(message); return; }
+ await SetMembershipAndBroadcastAsync(payload.UserId, payload.LobbyId, false, ct);
+ return;
+ }
+ case LobbyOutbox.LobbyClosed:
+ {
+ // The event payload carries only lobbyId/reason, so the roster to clear has to come from a
+ // re-read. Close/expiry now releases every seat (Lobby.CloseInternal/TryExpire call
+ // ReleaseAllJoinedMembers), so JoinedMembers is already empty by the time this handler runs —
+ // Members (every seat regardless of state) is the only list that still names who to clear.
+ var lobby = await _lobbies.GetByIdAsync(message.AggregateId, ct);
+ if (lobby is null) return;
+
+ foreach (var member in lobby.Members)
+ await SetMembershipAndBroadcastAsync(member.UserId, lobby.Id, false, ct);
+ return;
+ }
+ }
+ }
+
+ private async Task SetMembershipAndBroadcastAsync(Guid userId, Guid lobbyId, bool isMember, CancellationToken ct)
+ {
+ var result = _presence.SetLobbyMembership(userId, lobbyId, isMember);
+ if (!result.Changed)
+ return;
+
+ var viewers = await _viewerResolver.ResolveAsync(userId, ct);
+ await _notifier.NotifyPresenceChangedAsync(
+ userId, viewers, result.Status.ToString(), result.ServerEpoch, result.UserVersion, ct);
+ }
+
+ private void LogMalformedPayload(OutboxMessage message) =>
+ _logger.LogWarning(
+ "Realtime: presence side-effect payload could not be read. EventType={EventType} EventId={EventId}",
+ message.EventType, message.Id);
+
+ /// Matches LobbyOutbox.LobbyCreatedEvent's payload.
+ private sealed record LobbyCreatedPayload(Guid LobbyId, Guid HostUserId, string GameSlug);
+
+ /// Matches LobbyOutbox.MemberJoinedEvent/MemberLeftEvent/MemberKickedEvent's
+ /// shared ids — the kicker's id is irrelevant to presence, so it is not modeled here.
+ private sealed record MemberPayload(Guid LobbyId, Guid UserId);
+}
diff --git a/src/SimPle.Application/Realtime/Presence/IPresenceRegistry.cs b/src/SimPle.Application/Realtime/Presence/IPresenceRegistry.cs
new file mode 100644
index 0000000..7404c11
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Presence/IPresenceRegistry.cs
@@ -0,0 +1,40 @@
+namespace SimPle.Application.Realtime.Presence;
+
+///
+/// A single presence change worth telling clients about. is false when the call was a no-op
+/// (status didn't actually change) — callers must not fan out a PresenceChanged event when this is false,
+/// or UserVersion would appear to move without a real change.
+///
+public sealed record PresenceUpdateResult(bool Changed, PresenceStatus Status, Guid ServerEpoch, long UserVersion);
+
+///
+/// Tracks per-user, per-connection presence in memory only (no persistence, no cross-instance sync — single
+/// instance for B1, see spec Risk Register). Every mutating/query method is lazy: status is computed from
+/// timestamps against the injected at call time, there is no background timer.
+///
+public interface IPresenceRegistry
+{
+ /// Stable per-instance identifier, regenerated on every process restart. Clients compare this
+ /// against their cached value and discard stale presence when it changes.
+ Guid ServerEpoch { get; }
+
+ /// Registers a new connection for the user. Returns false (and registers nothing) if the user is
+ /// already at the connection cap — the hub must reject the connection with Realtime.ConnectionLimit.
+ bool TryConnect(Guid userId, string connectionId);
+
+ /// Removes one connection. If it was the user's last live connection, the user enters the offline
+ /// debounce window rather than going Offline immediately.
+ void Disconnect(Guid userId, string connectionId);
+
+ /// Records activity on one connection, resetting its away timer. Throttled to at most once per 60s
+ /// per connection — a rejected (throttled) signal returns false and mutates nothing.
+ bool TryReportActivity(Guid userId, string connectionId);
+
+ /// Adds the lobby to the user's membership set (drives ). Distinct
+ /// from subscription — subscribing to a lobby's updates never by itself implies membership presence.
+ PresenceUpdateResult SetLobbyMembership(Guid userId, Guid lobbyId, bool isMember);
+
+ /// Computes the user's current aggregated status as of now, evaluating away/offline debounce lazily.
+ /// Returns Offline with no user-version history if the user has never connected.
+ PresenceUpdateResult GetStatus(Guid userId);
+}
diff --git a/src/SimPle.Application/Realtime/Presence/IPresenceViewerResolver.cs b/src/SimPle.Application/Realtime/Presence/IPresenceViewerResolver.cs
new file mode 100644
index 0000000..85ae4b7
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Presence/IPresenceViewerResolver.cs
@@ -0,0 +1,17 @@
+namespace SimPle.Application.Realtime.Presence;
+
+///
+/// Resolves who is authorized to receive a PresenceChanged broadcast for a given subject right now
+/// (docs/specs/module-07-realtime-presence-chat-spec.md, "Presence visibility (approved)").
+///
+/// Only the lobby-co-member case has a real UI consumer today (LobbyPage.tsx's seat presence dot) — the
+/// broader Public/FriendsOnly fan-out the approved policy describes has no caller yet (no friends-list or
+/// profile-page presence indicator exists), so this resolver deliberately narrows to what is actually rendered:
+/// the subject themself (their own sidebar/topbar/dashboard avatar updates live) plus their current lobby's
+/// other joined members, minus anyone blocked in either direction. Extending this to the full policy is a new
+/// UI feature, not a bug fix, and should be scoped and approved separately.
+///
+public interface IPresenceViewerResolver
+{
+ Task> ResolveAsync(Guid subjectUserId, CancellationToken ct = default);
+}
diff --git a/src/SimPle.Application/Realtime/Presence/PresenceRegistry.cs b/src/SimPle.Application/Realtime/Presence/PresenceRegistry.cs
new file mode 100644
index 0000000..258411d
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Presence/PresenceRegistry.cs
@@ -0,0 +1,192 @@
+namespace SimPle.Application.Realtime.Presence;
+
+///
+/// In-memory presence tracking (docs/specs/module-07-realtime-presence-chat-spec.md, "Domain Invariants").
+/// Single-instance only — no cross-instance sync (see spec Risk Register; a future multi-instance deployment
+/// needs a backplane, out of scope for B1). Every computation is lazy: there is no background timer, status is
+/// derived from timestamps against the injected at call time (test convention mirrors
+/// tests/SimPle.UnitTests/Matchmaking/ExpirySweeperTests.cs's FakeTimeProvider usage).
+///
+public sealed class PresenceRegistry : IPresenceRegistry
+{
+ internal const int MaxConnectionsPerUser = 5;
+ internal static readonly TimeSpan AwayThreshold = TimeSpan.FromMinutes(5);
+ internal static readonly TimeSpan ActivityThrottle = TimeSpan.FromSeconds(60);
+ internal static readonly TimeSpan OfflineDebounce = TimeSpan.FromSeconds(10);
+
+ private readonly TimeProvider _timeProvider;
+ private readonly Guid _serverEpoch = Guid.NewGuid();
+ private readonly object _lock = new();
+ private readonly Dictionary _users = new();
+
+ public PresenceRegistry(TimeProvider timeProvider)
+ {
+ _timeProvider = timeProvider;
+ }
+
+ public Guid ServerEpoch => _serverEpoch;
+
+ public bool TryConnect(Guid userId, string connectionId)
+ {
+ lock (_lock)
+ {
+ var state = GetOrCreate(userId);
+ if (!state.Connections.ContainsKey(connectionId) && state.Connections.Count >= MaxConnectionsPerUser)
+ return false;
+
+ var now = _timeProvider.GetUtcNow().UtcDateTime;
+ state.Connections[connectionId] = now;
+ state.DisconnectedAtUtc = null;
+ Recompute(state, now);
+ return true;
+ }
+ }
+
+ public void Disconnect(Guid userId, string connectionId)
+ {
+ lock (_lock)
+ {
+ if (!_users.TryGetValue(userId, out var state))
+ return;
+
+ state.Connections.Remove(connectionId);
+ var now = _timeProvider.GetUtcNow().UtcDateTime;
+ if (state.Connections.Count == 0)
+ state.DisconnectedAtUtc = now;
+
+ Recompute(state, now);
+ EvictIfExpired(userId, state, now);
+ }
+ }
+
+ public bool TryReportActivity(Guid userId, string connectionId)
+ {
+ lock (_lock)
+ {
+ if (!_users.TryGetValue(userId, out var state) ||
+ !state.Connections.TryGetValue(connectionId, out var lastActivity))
+ return false;
+
+ var now = _timeProvider.GetUtcNow().UtcDateTime;
+ if (now - lastActivity < ActivityThrottle)
+ return false;
+
+ state.Connections[connectionId] = now;
+ Recompute(state, now);
+ return true;
+ }
+ }
+
+ public PresenceUpdateResult SetLobbyMembership(Guid userId, Guid lobbyId, bool isMember)
+ {
+ lock (_lock)
+ {
+ var state = GetOrCreate(userId);
+ if (isMember)
+ state.MemberOfLobbyIds.Add(lobbyId);
+ else
+ state.MemberOfLobbyIds.Remove(lobbyId);
+
+ var now = _timeProvider.GetUtcNow().UtcDateTime;
+ var result = Recompute(state, now);
+ EvictIfExpired(userId, state, now);
+ return result;
+ }
+ }
+
+ public PresenceUpdateResult GetStatus(Guid userId)
+ {
+ lock (_lock)
+ {
+ if (!_users.TryGetValue(userId, out var state))
+ return new PresenceUpdateResult(false, PresenceStatus.Offline, _serverEpoch, 0);
+
+ var now = _timeProvider.GetUtcNow().UtcDateTime;
+ var result = Recompute(state, now);
+ EvictIfExpired(userId, state, now);
+ return result;
+ }
+ }
+
+ private UserState GetOrCreate(Guid userId)
+ {
+ if (!_users.TryGetValue(userId, out var state))
+ {
+ state = new UserState();
+ _users[userId] = state;
+ }
+ return state;
+ }
+
+ private PresenceUpdateResult Recompute(UserState state, DateTime now)
+ {
+ var status = ComputeStatus(state, now);
+ var changed = status != state.LastStatus;
+ if (changed)
+ {
+ state.LastStatus = status;
+ state.UserVersion++;
+ }
+ return new PresenceUpdateResult(changed, status, _serverEpoch, state.UserVersion);
+ }
+
+ private static PresenceStatus ComputeStatus(UserState state, DateTime now)
+ {
+ PresenceStatus baseStatus;
+ if (state.Connections.Count > 0)
+ {
+ baseStatus = PresenceStatus.Offline;
+ foreach (var lastActivity in state.Connections.Values)
+ {
+ var connectionStatus = now - lastActivity >= AwayThreshold
+ ? PresenceStatus.Away
+ : PresenceStatus.Online;
+ if (connectionStatus > baseStatus)
+ baseStatus = connectionStatus;
+ }
+
+ state.LastAliveBase = baseStatus;
+ }
+ else if (state.DisconnectedAtUtc is { } disconnectedAt && now - disconnectedAt < OfflineDebounce)
+ {
+ // Debounce grace: preserve the last known live status rather than flapping to Offline immediately
+ // (e.g. a page refresh reconnects within a second or two).
+ baseStatus = state.LastAliveBase;
+ }
+ else
+ {
+ baseStatus = PresenceStatus.Offline;
+ }
+
+ // InLobby is a separate axis driven only by MemberOfLobbyIds (never by SubscribedLobbyIds, which drives
+ // fan-out only) — and only overlays a genuinely-connected/grace-period user, never a truly Offline one.
+ if (baseStatus != PresenceStatus.Offline &&
+ state.MemberOfLobbyIds.Count > 0 &&
+ baseStatus < PresenceStatus.InLobby)
+ {
+ baseStatus = PresenceStatus.InLobby;
+ }
+
+ return baseStatus;
+ }
+
+ private void EvictIfExpired(Guid userId, UserState state, DateTime now)
+ {
+ if (state.Connections.Count == 0 &&
+ state.DisconnectedAtUtc is { } disconnectedAt &&
+ now - disconnectedAt >= OfflineDebounce)
+ {
+ _users.Remove(userId);
+ }
+ }
+
+ private sealed class UserState
+ {
+ public Dictionary Connections { get; } = new();
+ public HashSet MemberOfLobbyIds { get; } = new();
+ public DateTime? DisconnectedAtUtc { get; set; }
+ public PresenceStatus LastAliveBase { get; set; } = PresenceStatus.Offline;
+ public PresenceStatus LastStatus { get; set; } = PresenceStatus.Offline;
+ public long UserVersion { get; set; }
+ }
+}
diff --git a/src/SimPle.Application/Realtime/Presence/PresenceStatus.cs b/src/SimPle.Application/Realtime/Presence/PresenceStatus.cs
new file mode 100644
index 0000000..d42c33c
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Presence/PresenceStatus.cs
@@ -0,0 +1,16 @@
+namespace SimPle.Application.Realtime.Presence;
+
+///
+/// Ephemeral, in-memory-only presence status (docs/specs/module-07-realtime-presence-chat-spec.md, "Domain
+/// Invariants: presence precedence"). Ordinal value is precedence for Max-based aggregation across a user's
+/// connections — never persisted, never confused with the pre-existing persisted User.Status
+/// (SimPle.Domain.Users.UserStatus), which is a different field with different semantics.
+///
+public enum PresenceStatus
+{
+ Offline = 0,
+ Away = 1,
+ Online = 2,
+ InLobby = 3,
+ Playing = 4,
+}
diff --git a/src/SimPle.Application/Realtime/Presence/PresenceViewerResolver.cs b/src/SimPle.Application/Realtime/Presence/PresenceViewerResolver.cs
new file mode 100644
index 0000000..66263da
--- /dev/null
+++ b/src/SimPle.Application/Realtime/Presence/PresenceViewerResolver.cs
@@ -0,0 +1,44 @@
+using SimPle.Application.Common.Interfaces;
+
+namespace SimPle.Application.Realtime.Presence;
+
+///
+/// Self + current lobby co-members, block-filtered in either direction. See
+/// for why the broader Public/FriendsOnly policy is not implemented here.
+///
+public sealed class PresenceViewerResolver : IPresenceViewerResolver
+{
+ private readonly ILobbyRepository _lobbies;
+
+ public PresenceViewerResolver(ILobbyRepository lobbies)
+ {
+ _lobbies = lobbies;
+ }
+
+ public async Task> ResolveAsync(Guid subjectUserId, CancellationToken ct = default)
+ {
+ var lobby = await _lobbies.GetActiveLobbyForUserAsync(subjectUserId, ct);
+ if (lobby is null)
+ return new[] { subjectUserId };
+
+ var coMemberIds = lobby.JoinedMembers
+ .Select(m => m.UserId)
+ .Where(id => id != subjectUserId)
+ .ToList();
+
+ if (coMemberIds.Count == 0)
+ return new[] { subjectUserId };
+
+ var blocked = await _lobbies.GetBlockedCounterpartsAsync(subjectUserId, coMemberIds, ct);
+ var blockedSet = blocked.Count == 0 ? null : new HashSet(blocked);
+
+ var viewers = new List(coMemberIds.Count + 1) { subjectUserId };
+ foreach (var id in coMemberIds)
+ {
+ if (blockedSet is null || !blockedSet.Contains(id))
+ viewers.Add(id);
+ }
+
+ return viewers;
+ }
+}
diff --git a/src/SimPle.Domain/Chat/ChatMessage.cs b/src/SimPle.Domain/Chat/ChatMessage.cs
index 7e58a9d..e0b4428 100644
--- a/src/SimPle.Domain/Chat/ChatMessage.cs
+++ b/src/SimPle.Domain/Chat/ChatMessage.cs
@@ -2,27 +2,97 @@
namespace SimPle.Domain.Chat;
+///
+/// Chat scope. Deliberately no DirectMessage member — direct messages are an explicit Module 7 non-goal
+/// (docs/specs/module-07-realtime-presence-chat-spec.md, "Non-Goals"). Do not add one back.
+///
+public enum ChatScope
+{
+ Lobby = 0,
+ Match = 1,
+}
+
+///
+/// A persisted lobby/match chat message (docs/specs/module-07-realtime-presence-chat-spec.md, "Data Model").
+/// Replaces the M07-B1-era orphan stub outright (no DbSet, no EF config, no migration, no consumer ever existed
+/// for it) rather than migrating it.
+///
+///
+/// Author deletion does not clear . The spec states this explicitly: "a
+/// deleted body persists on disk for up to 30 days, by design" so M12's evidence copy can still run after an
+/// author deletes. The read path (ChatService/DTO projection) is what returns
+/// body: null, deleted: true — the body itself never leaves the server again once deleted. Never project
+/// directly for a deleted row.
+///
+///
+///
+/// is overwritten from the caller's injected TimeProvider at construction
+/// (Risk #7) — Entity's own default is a raw , which would silently defeat the
+/// fake-clock retention/hold tests.
+///
+///
public class ChatMessage : Entity
{
+ /// Chat is retained 30 days. There is no separate M7 evidence-retention policy — M12 owns its own
+ /// two-year moderation-evidence domain in its own table via the seam.
+ public static readonly TimeSpan RetentionPeriod = TimeSpan.FromDays(30);
+
+ public ChatScope Scope { get; private set; }
+ public Guid ScopeId { get; private set; }
public Guid SenderId { get; private set; }
- public ChatContext Context { get; private set; }
- public Guid ContextId { get; private set; } // LobbyId or SessionId
- public string Content { get; private set; } = default!;
- public bool IsDeleted { get; private set; }
- public bool IsFlagged { get; private set; }
+
+ /// NFC-normalized, LF-only, 1-1000 Unicode scalars. Normalized by ChatBodyNormalizer BEFORE
+ /// this is constructed — this type does not re-validate it.
+ public string Body { get; private set; } = default!;
+
+ public int SchemaVersion { get; private set; } = 1;
+
+ /// Idempotency key supplied by the client. Unique with
+ /// (ux_chat_messages_sender_command) — a duplicate send catches 23505 and returns the original.
+ public Guid ClientCommandId { get; private set; }
+
+ public DateTime? DeletedAtUtc { get; private set; }
+ public Guid? DeletedByUserId { get; private set; }
+
+ /// + , fixed at creation. The cleanup
+ /// sweep's scan key (ix_chat_messages_retain).
+ public DateTime RetainUntilUtc { get; private set; }
+
+ public bool IsDeleted => DeletedAtUtc is not null;
private ChatMessage() { }
- public static ChatMessage Create(Guid senderId, ChatContext context, Guid contextId, string content) => new()
+ public static ChatMessage Create(
+ ChatScope scope, Guid scopeId, Guid senderId, string normalizedBody, Guid clientCommandId, DateTime nowUtc)
{
- SenderId = senderId,
- Context = context,
- ContextId = contextId,
- Content = content,
- };
-
- public void Delete() { IsDeleted = true; Touch(); }
- public void Flag() { IsFlagged = true; Touch(); }
-}
+ var message = new ChatMessage
+ {
+ Scope = scope,
+ ScopeId = scopeId,
+ SenderId = senderId,
+ Body = normalizedBody,
+ SchemaVersion = 1,
+ ClientCommandId = clientCommandId,
+ RetainUntilUtc = nowUtc + RetentionPeriod,
+ };
+
+ // Overwrite Entity's DateTime.UtcNow default with the injected clock (Risk #7) — accessible here because
+ // CreatedAt/UpdatedAt are `protected set` and this factory runs inside the derived type.
+ message.CreatedAt = nowUtc;
+ message.UpdatedAt = nowUtc;
-public enum ChatContext { Lobby, Match, DirectMessage }
+ return message;
+ }
+
+ /// Tombstones the message. Idempotent — a retried delete finds already true
+ /// and does nothing further, matching a chat command's UUID-idempotency convention elsewhere in this module.
+ /// The id is never reused and the body is never cleared (see class doc).
+ public void Delete(Guid deletedByUserId, DateTime nowUtc)
+ {
+ if (IsDeleted) return;
+
+ DeletedAtUtc = nowUtc;
+ DeletedByUserId = deletedByUserId;
+ UpdatedAt = nowUtc;
+ }
+}
diff --git a/src/SimPle.Domain/Chat/ChatMessageHold.cs b/src/SimPle.Domain/Chat/ChatMessageHold.cs
new file mode 100644
index 0000000..2a4bfe2
--- /dev/null
+++ b/src/SimPle.Domain/Chat/ChatMessageHold.cs
@@ -0,0 +1,61 @@
+using SimPle.Domain.Common;
+
+namespace SimPle.Domain.Chat;
+
+///
+/// A moderation hold against one row (docs/specs/module-07-realtime-presence-chat-spec.md,
+/// "Data Model"). Created by Module 7, ships empty, written only by Module 12 through the
+/// IChatRepository.PlaceHoldAsync seam this module builds but never calls.
+///
+///
+/// A hold table, deliberately not a HoldCount column on chat_messages: a counter
+/// column would make M12 write into an M7-owned aggregate — a boundary violation, and a field M7 could never
+/// validate. This table lets M12 add its own evidence table keyed by with no schema break.
+///
+///
+/// is M12's vocabulary; M7 never interprets it.
+///
+public class ChatMessageHold : Entity
+{
+ public Guid MessageId { get; private set; }
+ public string ReasonCode { get; private set; } = default!;
+ public DateTime PlacedAtUtc { get; private set; }
+
+ /// Null = active hold. The partial index (ix_chat_message_holds_active) that the cleanup
+ /// sweep's NOT EXISTS probe uses is built on exactly this predicate.
+ public DateTime? ReleasedAtUtc { get; private set; }
+
+ /// Set once M12's evidence copy is durable.
+ public DateTime? AcknowledgedAtUtc { get; private set; }
+
+ public bool IsActive => ReleasedAtUtc is null;
+
+ private ChatMessageHold() { }
+
+ public static ChatMessageHold Place(Guid messageId, string reasonCode, DateTime nowUtc)
+ {
+ var hold = new ChatMessageHold
+ {
+ MessageId = messageId,
+ ReasonCode = reasonCode,
+ PlacedAtUtc = nowUtc,
+ };
+
+ hold.CreatedAt = nowUtc;
+ hold.UpdatedAt = nowUtc;
+
+ return hold;
+ }
+
+ public void Release(DateTime nowUtc)
+ {
+ ReleasedAtUtc = nowUtc;
+ UpdatedAt = nowUtc;
+ }
+
+ public void Acknowledge(DateTime nowUtc)
+ {
+ AcknowledgedAtUtc = nowUtc;
+ UpdatedAt = nowUtc;
+ }
+}
diff --git a/src/SimPle.Domain/Lobbies/Lobby.cs b/src/SimPle.Domain/Lobbies/Lobby.cs
index 7eeea0e..521a6be 100644
--- a/src/SimPle.Domain/Lobbies/Lobby.cs
+++ b/src/SimPle.Domain/Lobbies/Lobby.cs
@@ -170,7 +170,7 @@ public LobbyLeaveResult Leave(Guid userId, DateTime nowUtc)
var successor = JoinedMembers.FirstOrDefault();
if (successor is null)
{
- CloseInternal(LobbyClosedReason.NoEligibleHost);
+ CloseInternal(LobbyClosedReason.NoEligibleHost, nowUtc);
Mutated();
return new LobbyLeaveResult(LobbyOutcome.Ok, null, LobbyClosedReason.NoEligibleHost);
}
@@ -279,7 +279,7 @@ public LobbyOutcome ReturnToOpen(bool resetReadiness, DateTime nowUtc)
// A lobby that expired while M8 was working does not silently reopen.
if (IsExpired(nowUtc))
{
- CloseInternal(LobbyClosedReason.Expired);
+ CloseInternal(LobbyClosedReason.Expired, nowUtc);
State = LobbyState.Expired;
Mutated();
return LobbyOutcome.Expired;
@@ -293,11 +293,11 @@ public LobbyOutcome ReturnToOpen(bool resetReadiness, DateTime nowUtc)
return LobbyOutcome.Ok;
}
- public LobbyOutcome Close(LobbyClosedReason reason)
+ public LobbyOutcome Close(LobbyClosedReason reason, DateTime nowUtc)
{
if (IsTerminal) return LobbyOutcome.Closed;
- CloseInternal(reason);
+ CloseInternal(reason, nowUtc);
Mutated();
return LobbyOutcome.Ok;
}
@@ -312,6 +312,7 @@ public bool TryExpire(DateTime nowUtc)
State = LobbyState.Expired;
ClosedReason = LobbyClosedReason.Expired;
+ ReleaseAllJoinedMembers(nowUtc);
Mutated();
return true;
}
@@ -347,10 +348,24 @@ private void ResetNonHostReadiness()
member.SetReadiness(false);
}
- private void CloseInternal(LobbyClosedReason reason)
+ private void CloseInternal(LobbyClosedReason reason, DateTime nowUtc)
{
State = LobbyState.Closed;
ClosedReason = reason;
+ ReleaseAllJoinedMembers(nowUtc);
+ }
+
+ ///
+ /// Releases every still-joined seat when the lobby itself goes terminal. Without this, a member who was never
+ /// individually removed (e.g. the sole host of a lobby that expires) stays at LobbyMemberState.Joined
+ /// forever — invisible to GetActiveLobbyForUserAsync (which filters on the lobby's own terminal state)
+ /// but not to the DB's partial unique index on (UserId, Joined), permanently blocking that user from ever
+ /// joining or creating another lobby.
+ ///
+ private void ReleaseAllJoinedMembers(DateTime nowUtc)
+ {
+ foreach (var member in _members.Where(m => m.IsJoined))
+ member.Leave(nowUtc);
}
private void Mutated()
diff --git a/src/SimPle.Domain/Outbox/OutboxHandlerActivation.cs b/src/SimPle.Domain/Outbox/OutboxHandlerActivation.cs
new file mode 100644
index 0000000..3d2e69e
--- /dev/null
+++ b/src/SimPle.Domain/Outbox/OutboxHandlerActivation.cs
@@ -0,0 +1,39 @@
+namespace SimPle.Domain.Outbox;
+
+///
+/// Generic per-handler activation watermark (docs/specs/module-07-realtime-presence-chat-spec.md, "Activation
+/// watermark"). Built by Module 7 for LobbyRealtimeHandler; M8/M11 reuse it for their own consumers.
+///
+///
+/// OutboxRepository.BackfillMissingDeliveriesAsync materializes a delivery row for every historical
+/// message the moment a new handler registers. Without a watermark, a handler's first boot would replay
+/// every historical event of its subscribed types as if it were live traffic. This row is created exactly once per
+/// : the first activation captures the outbox's own high-water mark
+/// (MAX(OccurredAtUtc), tie-broken by ) at that moment, so pre-watermark
+/// backfilled deliveries can be recognized and suppressed rather than acted on.
+///
+///
+public sealed class OutboxHandlerActivation
+{
+ /// Same identifier as . Renaming a handler orphans its
+ /// activation row exactly the same way it orphans its delivery rows.
+ public string HandlerName { get; private set; } = default!;
+
+ public DateTime ActivatedAtUtc { get; private set; }
+ public DateTime WatermarkOccurredAtUtc { get; private set; }
+
+ /// Tie-break within the same instant. Null only when the outbox
+ /// held zero matching events at activation time (nothing to tie-break against).
+ public Guid? WatermarkEventId { get; private set; }
+
+ private OutboxHandlerActivation() { }
+
+ public static OutboxHandlerActivation Activate(
+ string handlerName, DateTime activatedAtUtc, DateTime watermarkOccurredAtUtc, Guid? watermarkEventId) => new()
+ {
+ HandlerName = handlerName,
+ ActivatedAtUtc = activatedAtUtc,
+ WatermarkOccurredAtUtc = watermarkOccurredAtUtc,
+ WatermarkEventId = watermarkEventId,
+ };
+}
diff --git a/src/SimPle.Infrastructure/Auth/TokenCleanupService.cs b/src/SimPle.Infrastructure/Auth/TokenCleanupService.cs
index 630d0a8..e0cdb89 100644
--- a/src/SimPle.Infrastructure/Auth/TokenCleanupService.cs
+++ b/src/SimPle.Infrastructure/Auth/TokenCleanupService.cs
@@ -4,6 +4,7 @@
using Microsoft.Extensions.Options;
using SimPle.Application.Common.Interfaces;
using SimPle.Application.Common.Options;
+using SimPle.Infrastructure.Health;
namespace SimPle.Infrastructure.Auth;
@@ -15,19 +16,24 @@ public sealed class TokenCleanupService : BackgroundService
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger _logger;
private readonly TokenCleanupOptions _options;
+ private readonly IWorkerReadinessRegistry _readiness;
public TokenCleanupService(
IServiceScopeFactory scopeFactory,
ILogger logger,
- IOptions options)
+ IOptions options,
+ IWorkerReadinessRegistry readiness)
{
_scopeFactory = scopeFactory;
_logger = logger;
_options = options.Value;
+ _readiness = readiness;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
+ _readiness.MarkStarted(RequiredWorkers.TokenCleanup);
+
// Stagger the first run so it doesn't run immediately on startup.
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
@@ -59,9 +65,12 @@ private async Task RunCleanupAsync(CancellationToken ct)
_logger.LogInformation(
"Token cleanup: deleted {Refresh} refresh, {EmailVerif} email-verification, {PasswordReset} password-reset rows (cutoff: {Cutoff:u})",
refreshDeleted, emailVerifDeleted, passwordResetDeleted, cutoff);
+
+ _readiness.MarkHealthy(RequiredWorkers.TokenCleanup);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
+ _readiness.MarkUnhealthy(RequiredWorkers.TokenCleanup);
_logger.LogError(ex, "Token cleanup failed. Will retry in {Interval}.", _options.Interval);
}
}
diff --git a/src/SimPle.Infrastructure/Chat/ChatRepository.cs b/src/SimPle.Infrastructure/Chat/ChatRepository.cs
new file mode 100644
index 0000000..520a585
--- /dev/null
+++ b/src/SimPle.Infrastructure/Chat/ChatRepository.cs
@@ -0,0 +1,221 @@
+using Microsoft.EntityFrameworkCore;
+using SimPle.Application.Chat;
+using SimPle.Domain.Chat;
+using SimPle.Domain.Users;
+using SimPle.Infrastructure.Persistence;
+using SimPle.Shared.Common;
+
+namespace SimPle.Infrastructure.Chat;
+
+public sealed class ChatRepository : IChatRepository
+{
+ private readonly AppDbContext _db;
+
+ public ChatRepository(AppDbContext db) => _db = db;
+
+ public Task GetByIdAsync(Guid id, CancellationToken ct = default) =>
+ _db.ChatMessages.FirstOrDefaultAsync(m => m.Id == id, ct);
+
+ public Task FindByClientCommandIdAsync(
+ Guid senderId, Guid clientCommandId, CancellationToken ct = default) =>
+ _db.ChatMessages.FirstOrDefaultAsync(
+ m => m.SenderId == senderId && m.ClientCommandId == clientCommandId, ct);
+
+ public async Task AddAsync(ChatMessage message, CancellationToken ct = default)
+ {
+ await _db.ChatMessages.AddAsync(message, ct);
+
+ try
+ {
+ await _db.SaveChangesAsync(ct);
+ return message;
+ }
+ catch (DbUpdateException ex) when (PostgresContention.IsContention(ex))
+ {
+ _db.Entry(message).State = EntityState.Detached;
+ return await _db.ChatMessages.AsNoTracking().FirstAsync(
+ m => m.SenderId == message.SenderId && m.ClientCommandId == message.ClientCommandId, ct);
+ }
+ }
+
+ public async Task> GetHistoryPageAsync(
+ ChatScope scope,
+ Guid scopeId,
+ ChatHistoryDirection direction,
+ DateTime? cursorCreatedAtUtc,
+ Guid? cursorId,
+ int limit,
+ CancellationToken ct = default)
+ {
+ var query = _db.ChatMessages
+ .AsNoTracking()
+ .Where(m => m.Scope == scope && m.ScopeId == scopeId);
+
+ if (direction == ChatHistoryDirection.Before)
+ {
+ if (cursorCreatedAtUtc is DateTime beforeAt && cursorId is Guid beforeId)
+ {
+ query = query.Where(m =>
+ m.CreatedAt < beforeAt || (m.CreatedAt == beforeAt && m.Id.CompareTo(beforeId) < 0));
+ }
+
+ var descPage = await query
+ .OrderByDescending(m => m.CreatedAt).ThenByDescending(m => m.Id)
+ .Take(limit)
+ .ToListAsync(ct);
+
+ descPage.Reverse();
+ return descPage;
+ }
+
+ if (cursorCreatedAtUtc is DateTime afterAt && cursorId is Guid afterId)
+ {
+ query = query.Where(m =>
+ m.CreatedAt > afterAt || (m.CreatedAt == afterAt && m.Id.CompareTo(afterId) > 0));
+ }
+
+ return await query
+ .OrderBy(m => m.CreatedAt).ThenBy(m => m.Id)
+ .Take(limit)
+ .ToListAsync(ct);
+ }
+
+ public async Task> GetSendersAsync(
+ IReadOnlyList senderIds, CancellationToken ct = default)
+ {
+ if (senderIds.Count == 0) return new Dictionary();
+
+ var ids = senderIds.Distinct().ToArray();
+ return await _db.Users
+ .AsNoTracking()
+ .Where(u => ids.Contains(u.Id))
+ .ToDictionaryAsync(u => u.Id, ct);
+ }
+
+ public Task SaveAsync(CancellationToken ct = default) => _db.SaveChangesAsync(ct);
+
+ // ── Retention sweep + hold seam (docs/specs/module-07-realtime-presence-chat-spec.md, "Cleanup vs moderation
+ // hold", Risk #5) ───────────────────────────────────────────────────────────
+
+ public async Task DeleteExpiredAsync(DateTime nowUtc, int batchSize, CancellationToken ct = default)
+ {
+ // InMemory (unit tests) has neither raw SQL nor row locks. The SKIP LOCKED race this method exists for is
+ // asserted where it can actually be proven — against real PostgreSQL (ChatRetentionHoldRaceTests).
+ if (!_db.Database.IsRelational())
+ {
+ var heldIds = await _db.ChatMessageHolds
+ .AsNoTracking()
+ .Where(h => h.ReleasedAtUtc == null)
+ .Select(h => h.MessageId)
+ .ToListAsync(ct);
+
+ var expired = await _db.ChatMessages
+ .Where(m => m.RetainUntilUtc <= nowUtc && !heldIds.Contains(m.Id))
+ .OrderBy(m => m.RetainUntilUtc)
+ .Take(batchSize)
+ .ToListAsync(ct);
+
+ // Every hold row still attached to a candidate id is, by construction, released (an active one would
+ // have excluded the message above via heldIds) — the FK is RESTRICT, so these must go with the
+ // message or the delete is orphaning evidence rows pointing at nothing. Real PostgreSQL enforces this
+ // as a 23503 (see the relational branch below); InMemory does not, but the cleanup responsibility is
+ // identical either way.
+ var candidateIds = expired.Select(m => m.Id).ToList();
+ var releasedHolds = await _db.ChatMessageHolds
+ .Where(h => candidateIds.Contains(h.MessageId))
+ .ToListAsync(ct);
+
+ _db.ChatMessageHolds.RemoveRange(releasedHolds);
+ _db.ChatMessages.RemoveRange(expired);
+ await _db.SaveChangesAsync(ct);
+ return expired.Count;
+ }
+
+ // One bounded statement per batch, not ExecuteDeleteAsync() (EF 8's bulk delete cannot express LIMIT +
+ // FOR UPDATE SKIP LOCKED) and not a select-then-delete pair (the window between those two statements is
+ // precisely the hold race Risk #5 exists to close). A single (multi-CTE) statement is still one atomic
+ // statement: the row locks "candidates" takes are held and consumed within that same statement, so there
+ // is no gap for PlaceHoldAsync to race into. SKIP LOCKED means a row PlaceHoldAsync is mid-transaction on
+ // is stepped over this pass, not blocked on and not deleted — it is picked up next cycle once uncontended.
+ //
+ // "candidates" already excludes any message with an ACTIVE hold (ReleasedAtUtc IS NULL) via the NOT EXISTS
+ // probe above — but the FK chat_message_holds.MessageId -> chat_messages.Id is ON DELETE RESTRICT
+ // unconditionally, so a RELEASED hold row (which the probe correctly ignores, per spec: "An active M12
+ // hold prevents cleanup... until the copy/hold is acknowledged" — released is not active) still blocks the
+ // message delete with 23503 unless it is removed in the same statement. "released_holds" is a second,
+ // data-modifying CTE that does exactly that: Postgres always executes every data-modifying CTE in a WITH
+ // clause to completion regardless of whether the primary statement reads its output, so this stays one
+ // atomic, race-free operation rather than reopening a second select-then-delete window.
+ return await _db.Database.ExecuteSqlAsync(
+ $"""
+ WITH candidates AS (
+ SELECT m."Id"
+ FROM chat_messages m
+ WHERE m."RetainUntilUtc" <= {nowUtc}
+ AND NOT EXISTS (
+ SELECT 1 FROM chat_message_holds h
+ WHERE h."MessageId" = m."Id" AND h."ReleasedAtUtc" IS NULL
+ )
+ ORDER BY m."RetainUntilUtc"
+ LIMIT {batchSize}
+ FOR UPDATE OF m SKIP LOCKED
+ ),
+ released_holds AS (
+ DELETE FROM chat_message_holds h
+ USING candidates c
+ WHERE h."MessageId" = c."Id"
+ RETURNING h."Id"
+ )
+ DELETE FROM chat_messages m
+ USING candidates c
+ WHERE m."Id" = c."Id"
+ """, ct);
+ }
+
+ public async Task> PlaceHoldAsync(
+ Guid messageId, string reasonCode, DateTime nowUtc, CancellationToken ct = default)
+ {
+ if (!_db.Database.IsRelational())
+ {
+ // No concurrent writers to race in a unit test against InMemory — the row lock this method takes on
+ // real PostgreSQL is asserted by ChatRetentionHoldRaceTests instead.
+ var exists = await _db.ChatMessages.AnyAsync(m => m.Id == messageId, ct);
+ if (!exists)
+ return Result.Fail(ChatErrors.MessageExpired, "The message has already aged out of retention.");
+
+ var placeholderHold = ChatMessageHold.Place(messageId, reasonCode, nowUtc);
+ await _db.ChatMessageHolds.AddAsync(placeholderHold, ct);
+ await _db.SaveChangesAsync(ct);
+ return Result.Ok(placeholderHold);
+ }
+
+ // Blocking FOR UPDATE — deliberately not SKIP LOCKED. A hold request must wait its turn on a row the
+ // sweep is mid-transaction on rather than give up, so the two race honestly (Risk #5): whichever side
+ // commits first wins, and the loser gets a truthful answer instead of a silently lost/duplicated hold.
+ // An explicit transaction is required here (unlike DeleteExpiredAsync's single statement) because the
+ // lock must be held across the existence check AND the subsequent insert.
+ await using var transaction = await _db.Database.BeginTransactionAsync(ct);
+
+ var lockedIds = await _db.Database
+ .SqlQuery($"""
+ SELECT "Id"
+ FROM chat_messages
+ WHERE "Id" = {messageId}
+ FOR UPDATE
+ """)
+ .ToListAsync(ct);
+
+ if (lockedIds.Count == 0)
+ {
+ await transaction.RollbackAsync(ct);
+ return Result.Fail(ChatErrors.MessageExpired, "The message has already aged out of retention.");
+ }
+
+ var hold = ChatMessageHold.Place(messageId, reasonCode, nowUtc);
+ await _db.ChatMessageHolds.AddAsync(hold, ct);
+ await _db.SaveChangesAsync(ct);
+ await transaction.CommitAsync(ct);
+
+ return Result.Ok(hold);
+ }
+}
diff --git a/src/SimPle.Infrastructure/Chat/ChatRetentionSweeper.cs b/src/SimPle.Infrastructure/Chat/ChatRetentionSweeper.cs
new file mode 100644
index 0000000..9741d43
--- /dev/null
+++ b/src/SimPle.Infrastructure/Chat/ChatRetentionSweeper.cs
@@ -0,0 +1,76 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using SimPle.Application.Chat;
+using SimPle.Infrastructure.Health;
+
+namespace SimPle.Infrastructure.Chat;
+
+///
+/// The retention sweep (docs/specs/module-07-realtime-presence-chat-spec.md, "Ownership, retention, deletion" and
+/// Risk #5): periodically deletes chat messages whose RetainUntilUtc (30 days) has passed and which carry
+/// no active , via .
+/// Modeled directly on SimPle.Infrastructure.Auth.TokenCleanupService — same shape, same readiness
+/// contract, same stagger-then-loop structure.
+///
+public sealed class ChatRetentionSweeper : BackgroundService
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly ILogger _logger;
+ private readonly ChatRetentionOptions _options;
+ private readonly IWorkerReadinessRegistry _readiness;
+ private readonly TimeProvider _clock;
+
+ public ChatRetentionSweeper(
+ IServiceScopeFactory scopeFactory,
+ ILogger logger,
+ IOptions options,
+ IWorkerReadinessRegistry readiness,
+ TimeProvider clock)
+ {
+ _scopeFactory = scopeFactory;
+ _logger = logger;
+ _options = options.Value;
+ _readiness = readiness;
+ _clock = clock;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _readiness.MarkStarted(RequiredWorkers.ChatRetention);
+
+ // Stagger the first run so it doesn't run immediately on startup, matching TokenCleanupService.
+ await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ await RunSweepAsync(stoppingToken);
+ await Task.Delay(_options.Interval, stoppingToken);
+ }
+ }
+
+ private async Task RunSweepAsync(CancellationToken ct)
+ {
+ var nowUtc = _clock.GetUtcNow().UtcDateTime;
+
+ try
+ {
+ await using var scope = _scopeFactory.CreateAsyncScope();
+ var chat = scope.ServiceProvider.GetRequiredService();
+
+ var deleted = await chat.DeleteExpiredAsync(nowUtc, _options.BatchSize, ct);
+ if (deleted > 0)
+ _logger.LogInformation(
+ "Chat retention sweep: deleted {Count} expired message(s) (cutoff: {Cutoff:u}).",
+ deleted, nowUtc);
+
+ _readiness.MarkHealthy(RequiredWorkers.ChatRetention);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _readiness.MarkUnhealthy(RequiredWorkers.ChatRetention);
+ _logger.LogError(ex, "Chat retention sweep failed. Will retry in {Interval}.", _options.Interval);
+ }
+ }
+}
diff --git a/src/SimPle.Infrastructure/DependencyInjection.cs b/src/SimPle.Infrastructure/DependencyInjection.cs
index 4b8229b..aeeeba5 100644
--- a/src/SimPle.Infrastructure/DependencyInjection.cs
+++ b/src/SimPle.Infrastructure/DependencyInjection.cs
@@ -2,16 +2,23 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+using SimPle.Application.Chat;
using SimPle.Application.Common.Interfaces;
using SimPle.Application.Common.Options;
using SimPle.Application.Lobbies.Services;
+using SimPle.Application.Outbox;
+using SimPle.Application.Realtime;
+using SimPle.Application.Realtime.Outbox;
using SimPle.Infrastructure.Auth;
+using SimPle.Infrastructure.Chat;
using SimPle.Infrastructure.Email;
+using SimPle.Infrastructure.Health;
using SimPle.Infrastructure.Lobbies;
using SimPle.Infrastructure.Matchmaking;
using SimPle.Infrastructure.Outbox;
using SimPle.Infrastructure.Persistence;
using SimPle.Infrastructure.Persistence.Repositories;
+using SimPle.Infrastructure.Realtime;
using SimPle.Infrastructure.Storage;
namespace SimPle.Infrastructure;
@@ -64,7 +71,7 @@ public static IServiceCollection AddInfrastructureServices(
// what makes Start a 503, `allowedActions` omit `start`, and the UI's disabled controls truthful rather
// than decorative. Each is replaced, not rewritten, when its module lands.
services.AddSingleton();
- services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
@@ -76,6 +83,10 @@ public static IServiceCollection AddInfrastructureServices(
services.AddScoped();
services.AddHttpClient();
+ // Readiness is intentionally process-local: this application is deployed as a single backend instance, so
+ // these durable workers and the API must share one lifecycle until a later distributed design is approved.
+ services.AddSingleton(_ => new WorkerReadinessRegistry(RequiredWorkers.All));
+
services.Configure(
configuration.GetSection(TokenCleanupOptions.SectionName));
services.AddHostedService();
@@ -103,6 +114,36 @@ public static IServiceCollection AddInfrastructureServices(
services.AddHostedService();
services.AddHostedService();
+ // Module 7 (docs/specs/module-07-realtime-presence-chat-spec.md), backend session A (M07-B1). The default
+ // IRealtimeConnectionCloser is a no-op so Auth flows never throw when the realtime hub is disabled (e.g. a
+ // rollback); the API composition root overrides this with the real SignalR-backed implementation once the
+ // hub is mapped (it needs the concrete hub type, which this project cannot reference). The rate limiter is
+ // SignalR-independent (pure System.Threading.RateLimiting), so it lives here rather than in the API layer.
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Module 7, backend session B (M07-B2). Registered now (ahead of the full step-8 DI pass) only so
+ // RealtimeHub's new IChatService constructor dependency and ChatController resolve — otherwise every
+ // existing hub-connection integration test would break the moment SendLobbyMessage was added to
+ // RealtimeHub.
+ services.Configure(configuration.GetSection(ProfanityOptions.SectionName));
+ services.AddScoped();
+ services.AddSingleton();
+ services.AddScoped();
+ services.AddScoped();
+
+ // Step 8: chat runtime is now live (LiveChatRuntimeProbe swap above), and LobbyRealtimeHandler joins the
+ // outbox dispatcher's handler set the same way LobbyBlockHandler does (OutboxDispatcherWorker resolves
+ // every IOutboxHandler via GetServices() — see SimPle.Application/DependencyInjection.cs).
+ // It is registered here rather than there because this session's ownership boundary scopes DI wiring to
+ // this file.
+ services.AddScoped();
+
+ // Step 9: the retention sweep (docs/specs/module-07-realtime-presence-chat-spec.md, Risk #5), modeled on
+ // TokenCleanupService above.
+ services.Configure(configuration.GetSection(ChatRetentionOptions.SectionName));
+ services.AddHostedService();
+
return services;
}
diff --git a/src/SimPle.Infrastructure/Games/GameCatalogSeeder.cs b/src/SimPle.Infrastructure/Games/GameCatalogSeeder.cs
index ca83325..c63d755 100644
--- a/src/SimPle.Infrastructure/Games/GameCatalogSeeder.cs
+++ b/src/SimPle.Infrastructure/Games/GameCatalogSeeder.cs
@@ -1,5 +1,6 @@
using System.Reflection;
using System.Security.Cryptography;
+using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -63,7 +64,7 @@ public async Task SeedAsync(CancellationToken ct = defaul
if (validationError is not null)
return Fail(validationError);
- var checksum = Convert.ToHexString(SHA256.HashData(manifestBytes)).ToLowerInvariant();
+ var checksum = ComputeManifestChecksum(manifestBytes);
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
await _db.Database.ExecuteSqlRawAsync(
@@ -178,6 +179,19 @@ private GameCatalogSeedResult Fail(string message)
return new GameCatalogSeedResult(false, message, 0, 0);
}
+ ///
+ /// Hashes the manifest with canonical LF line endings. Git's core.autocrlf must never make a
+ /// semantically identical checked-in manifest appear to be a different seed revision on Windows.
+ /// Formatting/content changes still change the checksum and therefore remain fail-closed.
+ ///
+ public static string ComputeManifestChecksum(ReadOnlySpan manifestBytes)
+ {
+ var normalized = Encoding.UTF8.GetString(manifestBytes)
+ .Replace("\r\n", "\n", StringComparison.Ordinal)
+ .Replace("\r", "\n", StringComparison.Ordinal);
+ return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))).ToLowerInvariant();
+ }
+
///
/// Structural + domain-invariant validation of the whole manifest before any database access.
/// Returns null when valid, or a message identifying the offending slug/field otherwise.
diff --git a/src/SimPle.Infrastructure/Health/WorkerReadinessRegistry.cs b/src/SimPle.Infrastructure/Health/WorkerReadinessRegistry.cs
new file mode 100644
index 0000000..e04aea0
--- /dev/null
+++ b/src/SimPle.Infrastructure/Health/WorkerReadinessRegistry.cs
@@ -0,0 +1,78 @@
+using System.Collections.Concurrent;
+
+namespace SimPle.Infrastructure.Health;
+
+///
+/// The worker names required for this single-process deployment. Keeping them central makes the readiness
+/// contract explicit: adding a durable background worker means adding its heartbeat here too.
+///
+public static class RequiredWorkers
+{
+ public const string TokenCleanup = "token-cleanup";
+ public const string DismissedSuggestionCleanup = "dismissed-suggestion-cleanup";
+ public const string Matchmaking = "matchmaking";
+ public const string LobbyExpiry = "lobby-expiry";
+ public const string OutboxDispatcher = "outbox-dispatcher";
+
+ /// Module 7, backend session B (M07-B2): the chat retention sweep
+ /// ().
+ public const string ChatRetention = "chat-retention";
+
+ public static readonly IReadOnlyCollection All =
+ [
+ TokenCleanup,
+ DismissedSuggestionCleanup,
+ Matchmaking,
+ LobbyExpiry,
+ OutboxDispatcher,
+ ChatRetention,
+ ];
+}
+
+///
+/// Process-local worker readiness state. It deliberately stores no exception, configuration, or dependency detail,
+/// because readiness is exposed to an unauthenticated infrastructure probe.
+///
+public interface IWorkerReadinessRegistry
+{
+ bool AreRequiredWorkersReady { get; }
+
+ void MarkStarted(string workerName);
+ void MarkHealthy(string workerName);
+ void MarkUnhealthy(string workerName);
+}
+
+public sealed class WorkerReadinessRegistry : IWorkerReadinessRegistry
+{
+ private readonly ConcurrentDictionary _states;
+
+ public WorkerReadinessRegistry(IEnumerable requiredWorkers)
+ {
+ _states = new ConcurrentDictionary(
+ requiredWorkers.Select(worker => new KeyValuePair(worker, WorkerState.NotStarted)),
+ StringComparer.Ordinal);
+ }
+
+ public bool AreRequiredWorkersReady => _states.Count > 0 && _states.Values.All(state => state == WorkerState.Healthy);
+
+ public void MarkStarted(string workerName) => SetState(workerName, WorkerState.Healthy);
+
+ public void MarkHealthy(string workerName) => SetState(workerName, WorkerState.Healthy);
+
+ public void MarkUnhealthy(string workerName) => SetState(workerName, WorkerState.Unhealthy);
+
+ private void SetState(string workerName, WorkerState state)
+ {
+ if (!_states.ContainsKey(workerName))
+ throw new ArgumentOutOfRangeException(nameof(workerName), "The worker is not part of the readiness contract.");
+
+ _states[workerName] = state;
+ }
+
+ private enum WorkerState
+ {
+ NotStarted,
+ Healthy,
+ Unhealthy,
+ }
+}
diff --git a/src/SimPle.Infrastructure/Lobbies/DependencyProbes.cs b/src/SimPle.Infrastructure/Lobbies/DependencyProbes.cs
index 628b540..127164d 100644
--- a/src/SimPle.Infrastructure/Lobbies/DependencyProbes.cs
+++ b/src/SimPle.Infrastructure/Lobbies/DependencyProbes.cs
@@ -24,10 +24,15 @@ public Task IsInActiveMatchAsync(Guid userId, CancellationToken ct = defau
Task.FromResult(false);
}
-/// Module 7 owns chat and live delivery. The lobby polls; there is no push here.
-public sealed class NoChatRuntimeProbe : IChatRuntimeProbe
+///
+/// Module 7, backend session B (M07-B2): chat persistence and lobby-event fan-out are live (docs/specs/
+/// module-07-realtime-presence-chat-spec.md) via IChatService/ChatController/hub
+/// SendLobbyMessage — always true because chat runtime availability does not depend on any external process
+/// being up (unlike the match runtime, it has no separate worker to poll).
+///
+public sealed class LiveChatRuntimeProbe : IChatRuntimeProbe
{
- public Task IsAvailableAsync(CancellationToken ct = default) => Task.FromResult(false);
+ public Task IsAvailableAsync(CancellationToken ct = default) => Task.FromResult(true);
}
///
diff --git a/src/SimPle.Infrastructure/Matchmaking/LobbyExpiryWorker.cs b/src/SimPle.Infrastructure/Matchmaking/LobbyExpiryWorker.cs
index 7449244..9f8f36f 100644
--- a/src/SimPle.Infrastructure/Matchmaking/LobbyExpiryWorker.cs
+++ b/src/SimPle.Infrastructure/Matchmaking/LobbyExpiryWorker.cs
@@ -4,6 +4,7 @@
using Microsoft.Extensions.Options;
using SimPle.Application.Common.Options;
using SimPle.Application.Expiry;
+using SimPle.Infrastructure.Health;
namespace SimPle.Infrastructure.Matchmaking;
@@ -23,15 +24,18 @@ public sealed class LobbyExpiryWorker : BackgroundService
private readonly IServiceScopeFactory _scopeFactory;
private readonly ExpiryOptions _options;
private readonly ILogger _logger;
+ private readonly IWorkerReadinessRegistry _readiness;
public LobbyExpiryWorker(
IServiceScopeFactory scopeFactory,
IOptions options,
- ILogger logger)
+ ILogger logger,
+ IWorkerReadinessRegistry readiness)
{
_scopeFactory = scopeFactory;
_options = options.Value;
_logger = logger;
+ _readiness = readiness;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -39,9 +43,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
if (!_options.WorkerEnabled)
{
_logger.LogInformation("Lobby expiry worker is disabled by configuration; not starting.");
+ _readiness.MarkUnhealthy(RequiredWorkers.LobbyExpiry);
return;
}
+ _readiness.MarkStarted(RequiredWorkers.LobbyExpiry);
+
_logger.LogInformation(
"Lobby expiry worker started. Interval={Interval} BatchSize={BatchSize}",
_options.Interval, _options.BatchSize);
@@ -79,9 +86,12 @@ private async Task SweepAsync(CancellationToken ct)
"Expiry lag exceeded the 5s budget. MaxTicketLagMs={LagMs} Tickets={Tickets}",
(long)result.MaxTicketLag.TotalMilliseconds, result.TicketsExpired);
}
+
+ _readiness.MarkHealthy(RequiredWorkers.LobbyExpiry);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
+ _readiness.MarkUnhealthy(RequiredWorkers.LobbyExpiry);
// Idempotent by construction: every transition it drives is a TryExpire/TryTimeOut that returns false
// rather than transitioning twice, so a failed sweep leaves nothing half-done and the next tick simply
// sees the same overdue rows.
diff --git a/src/SimPle.Infrastructure/Matchmaking/MatchmakingWorker.cs b/src/SimPle.Infrastructure/Matchmaking/MatchmakingWorker.cs
index aeaa08b..6bb57d2 100644
--- a/src/SimPle.Infrastructure/Matchmaking/MatchmakingWorker.cs
+++ b/src/SimPle.Infrastructure/Matchmaking/MatchmakingWorker.cs
@@ -4,6 +4,7 @@
using Microsoft.Extensions.Options;
using SimPle.Application.Common.Options;
using SimPle.Application.Matchmaking.Services;
+using SimPle.Infrastructure.Health;
namespace SimPle.Infrastructure.Matchmaking;
@@ -28,16 +29,19 @@ public sealed class MatchmakingWorker : BackgroundService
private readonly IServiceScopeFactory _scopeFactory;
private readonly MatchmakingOptions _options;
private readonly ILogger _logger;
+ private readonly IWorkerReadinessRegistry _readiness;
private readonly string _workerId;
public MatchmakingWorker(
IServiceScopeFactory scopeFactory,
IOptions options,
- ILogger logger)
+ ILogger logger,
+ IWorkerReadinessRegistry readiness)
{
_scopeFactory = scopeFactory;
_options = options.Value;
_logger = logger;
+ _readiness = readiness;
// Machine name plus a random suffix: two instances on one host must not share an id, or their claims become
// indistinguishable in exactly the situation the id exists to disambiguate. Budgeted to fit
@@ -54,9 +58,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
// The rollback plan calls for disabling the workers while preserving every lobby and ticket record.
// Not hosting the loop is how that is done; nothing else changes.
_logger.LogInformation("Matchmaking worker is disabled by configuration; not starting.");
+ _readiness.MarkUnhealthy(RequiredWorkers.Matchmaking);
return;
}
+ _readiness.MarkStarted(RequiredWorkers.Matchmaking);
+
_logger.LogInformation(
"Matchmaking worker started. WorkerId={WorkerId} Interval={Interval} BatchSize={BatchSize}",
_workerId, _options.Interval, _options.BatchSize);
@@ -94,9 +101,12 @@ private async Task RunCycleAsync(CancellationToken ct)
_workerId, result.TicketsClaimed, result.ProposalsFormed, result.TicketsMatched,
(long?)result.OldestQueuedAge?.TotalMilliseconds);
}
+
+ _readiness.MarkHealthy(RequiredWorkers.Matchmaking);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
+ _readiness.MarkUnhealthy(RequiredWorkers.Matchmaking);
// A failed cycle is survivable by construction: the transaction rolled back, which released the
// FOR UPDATE SKIP LOCKED row locks, which returned every claimed ticket to Queued. There is nothing to
// compensate and nothing to clean up — the next cycle simply sees them again.
diff --git a/src/SimPle.Infrastructure/Migrations/20260717001316_AddChatAndRealtimeActivation.Designer.cs b/src/SimPle.Infrastructure/Migrations/20260717001316_AddChatAndRealtimeActivation.Designer.cs
new file mode 100644
index 0000000..f6730a5
--- /dev/null
+++ b/src/SimPle.Infrastructure/Migrations/20260717001316_AddChatAndRealtimeActivation.Designer.cs
@@ -0,0 +1,2045 @@
+//
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using SimPle.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace SimPle.Infrastructure.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260717001316_AddChatAndRealtimeActivation")]
+ partial class AddChatAndRealtimeActivation
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("SimPle.Domain.Capabilities.CapabilitySeedHistory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AppliedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Checksum")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ManifestVersion")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ManifestVersion")
+ .IsUnique();
+
+ b.ToTable("capability_seed_history", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Capabilities.GameCapabilityProfile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AiFillEligible")
+ .HasColumnType("boolean");
+
+ b.Property>("AllowedModes")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("CapabilityVersion")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GameSlug")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("ManifestVersion")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("MaxPlayers")
+ .HasColumnType("integer");
+
+ b.Property("MinPlayers")
+ .HasColumnType("integer");
+
+ b.Property("RatedEligible")
+ .HasColumnType("boolean");
+
+ b.Property>("SpectatorPolicies")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property>("TieBreakRules")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property>("TimeControls")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GameSlug")
+ .IsUnique()
+ .HasDatabaseName("ux_game_capability_profiles_one_active_per_game")
+ .HasFilter("\"IsActive\" = true");
+
+ b.HasIndex("GameSlug", "CapabilityVersion")
+ .IsUnique()
+ .HasDatabaseName("ux_game_capability_profiles_pin");
+
+ b.ToTable("game_capability_profiles", null, t =>
+ {
+ t.HasCheckConstraint("ck_game_capability_profiles_modes_nonempty", "cardinality(\"AllowedModes\") > 0");
+
+ t.HasCheckConstraint("ck_game_capability_profiles_players", "\"MinPlayers\" >= 2 AND \"MinPlayers\" <= \"MaxPlayers\" AND \"MaxPlayers\" <= 8");
+
+ t.HasCheckConstraint("ck_game_capability_profiles_spectators_nonempty", "cardinality(\"SpectatorPolicies\") > 0");
+
+ t.HasCheckConstraint("ck_game_capability_profiles_tie_breaks_nonempty", "cardinality(\"TieBreakRules\") > 0");
+
+ t.HasCheckConstraint("ck_game_capability_profiles_time_controls_nonempty", "cardinality(\"TimeControls\") > 0");
+
+ t.HasCheckConstraint("ck_game_capability_profiles_version", "\"CapabilityVersion\" >= 1");
+ });
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Chat.ChatMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ClientCommandId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedByUserId")
+ .HasColumnType("uuid");
+
+ b.Property("RetainUntilUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SchemaVersion")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(1);
+
+ b.Property("Scope")
+ .HasColumnType("integer");
+
+ b.Property("ScopeId")
+ .HasColumnType("uuid");
+
+ b.Property("SenderId")
+ .HasColumnType("uuid");
+
+ b.Property