From dace64848a3201c23592fcd5b707649cd9e11211 Mon Sep 17 00:00:00 2001 From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:28:08 +0300 Subject: [PATCH 1/4] fix(module-06-lobby-matchmaking): normalize game manifest checksum to LF Git's core.autocrlf rewrote line endings on Windows, making a semantically identical checked-in manifest hash to a different seed revision. Hash the manifest with canonical LF endings so only real content changes move the checksum, and cover it with a checksum test. --- .../Games/GameCatalogSeeder.cs | 16 +++++++++++++++- .../Games/GameCatalogSeedChecksumTests.cs | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 tests/SimPle.UnitTests/Games/GameCatalogSeedChecksumTests.cs 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/tests/SimPle.UnitTests/Games/GameCatalogSeedChecksumTests.cs b/tests/SimPle.UnitTests/Games/GameCatalogSeedChecksumTests.cs new file mode 100644 index 0000000..3b54f07 --- /dev/null +++ b/tests/SimPle.UnitTests/Games/GameCatalogSeedChecksumTests.cs @@ -0,0 +1,19 @@ +using System.Text; +using SimPle.Infrastructure.Games; + +namespace SimPle.UnitTests.Games; + +public sealed class GameCatalogSeedChecksumTests +{ + [Fact] + public void ComputeManifestChecksum_IgnoresGitLineEndingNormalization() + { + const string lf = "{\n \"manifestVersion\": \"2026.1\"\n}\n"; + var crlf = lf.Replace("\n", "\r\n", StringComparison.Ordinal); + + var lfChecksum = GameCatalogSeeder.ComputeManifestChecksum(Encoding.UTF8.GetBytes(lf)); + var crlfChecksum = GameCatalogSeeder.ComputeManifestChecksum(Encoding.UTF8.GetBytes(crlf)); + + Assert.Equal(lfChecksum, crlfChecksum); + } +} From 6f481f51c8cdeffa4417e8fc81ab2c17cd8aa48d Mon Sep 17 00:00:00 2001 From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:39:12 +0300 Subject: [PATCH 2/4] feat: containerize backend and add production health/observability infra Adds Dockerfile, CI/CD hardening (SBOM generation, provenance attestation, NuGet vulnerability gate, container smoke test), CodeQL and dependabot config, liveness/readiness health checks with a worker-readiness registry, correlation-id middleware, and structured JSON logging for containers. Co-Authored-By: Claude Sonnet 5 --- .dockerignore | 17 +++ .github/dependabot.yml | 12 ++ .github/workflows/ci.yml | 130 ++++++++++++++++-- .github/workflows/codeql.yml | 29 ++++ .github/workflows/publish-image.yml | 53 +++++++ Dockerfile | 30 ++++ .../Health/DatabaseReadinessHealthCheck.cs | 49 +++++++ .../Health/StorageConfigurationHealthCheck.cs | 44 ++++++ .../Health/WorkerReadinessHealthCheck.cs | 19 +++ .../Middleware/CorrelationIdMiddleware.cs | 68 +++++++++ .../Observability/RequestTelemetry.cs | 30 ++++ src/SimPle.Api/Program.cs | 76 +++++++++- .../Auth/TokenCleanupService.cs | 11 +- .../DependencyInjection.cs | 5 + .../Health/WorkerReadinessRegistry.cs | 73 ++++++++++ .../Matchmaking/LobbyExpiryWorker.cs | 12 +- .../Matchmaking/MatchmakingWorker.cs | 12 +- .../Outbox/OutboxDispatcherWorker.cs | 81 +++++++---- .../DismissedSuggestionCleanupService.cs | 11 +- .../Auth/HealthEndpointsTests.cs | 57 ++++++++ .../Auth/TestWebApplicationFactory.cs | 7 + .../SimPle.IntegrationTests.csproj | 3 + .../SimPle.UnitTests/SimPle.UnitTests.csproj | 3 + 23 files changed, 786 insertions(+), 46 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/publish-image.yml create mode 100644 Dockerfile create mode 100644 src/SimPle.Api/Health/DatabaseReadinessHealthCheck.cs create mode 100644 src/SimPle.Api/Health/StorageConfigurationHealthCheck.cs create mode 100644 src/SimPle.Api/Health/WorkerReadinessHealthCheck.cs create mode 100644 src/SimPle.Api/Middleware/CorrelationIdMiddleware.cs create mode 100644 src/SimPle.Api/Observability/RequestTelemetry.cs create mode 100644 src/SimPle.Infrastructure/Health/WorkerReadinessRegistry.cs create mode 100644 tests/SimPle.IntegrationTests/Auth/HealthEndpointsTests.cs 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/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/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..443d537 100644 --- a/src/SimPle.Api/Program.cs +++ b/src/SimPle.Api/Program.cs @@ -6,13 +6,16 @@ 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.Middleware; +using SimPle.Api.Health; using SimPle.Api.Models; using SimPle.Api.OpenApi; using SimPle.Application; @@ -31,6 +34,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"); @@ -76,6 +90,10 @@ builder.Services.AddValidatorsFromAssemblyContaining(); builder.Services.AddApplicationServices(); builder.Services.AddInfrastructureServices(builder.Configuration); +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 @@ -255,7 +273,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,6 +422,7 @@ 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(); @@ -427,8 +445,31 @@ await context.HttpContext.Response.WriteAsJsonAsync(new ApiErrorResponse( app.UseAuthorization(); app.MapControllers(); +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 +500,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.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/DependencyInjection.cs b/src/SimPle.Infrastructure/DependencyInjection.cs index 4b8229b..2485e8c 100644 --- a/src/SimPle.Infrastructure/DependencyInjection.cs +++ b/src/SimPle.Infrastructure/DependencyInjection.cs @@ -7,6 +7,7 @@ using SimPle.Application.Lobbies.Services; using SimPle.Infrastructure.Auth; using SimPle.Infrastructure.Email; +using SimPle.Infrastructure.Health; using SimPle.Infrastructure.Lobbies; using SimPle.Infrastructure.Matchmaking; using SimPle.Infrastructure.Outbox; @@ -76,6 +77,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(); diff --git a/src/SimPle.Infrastructure/Health/WorkerReadinessRegistry.cs b/src/SimPle.Infrastructure/Health/WorkerReadinessRegistry.cs new file mode 100644 index 0000000..5d6286a --- /dev/null +++ b/src/SimPle.Infrastructure/Health/WorkerReadinessRegistry.cs @@ -0,0 +1,73 @@ +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"; + + public static readonly IReadOnlyCollection All = + [ + TokenCleanup, + DismissedSuggestionCleanup, + Matchmaking, + LobbyExpiry, + OutboxDispatcher, + ]; +} + +/// +/// 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/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/Outbox/OutboxDispatcherWorker.cs b/src/SimPle.Infrastructure/Outbox/OutboxDispatcherWorker.cs index 92fe2ff..8765e2a 100644 --- a/src/SimPle.Infrastructure/Outbox/OutboxDispatcherWorker.cs +++ b/src/SimPle.Infrastructure/Outbox/OutboxDispatcherWorker.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using SimPle.Application.Common.Options; using SimPle.Application.Outbox; +using SimPle.Infrastructure.Health; namespace SimPle.Infrastructure.Outbox; @@ -28,15 +29,18 @@ public sealed class OutboxDispatcherWorker : BackgroundService private readonly IServiceScopeFactory _scopeFactory; private readonly OutboxOptions _options; private readonly ILogger _logger; + private readonly IWorkerReadinessRegistry _readiness; public OutboxDispatcherWorker( 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) @@ -44,9 +48,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) if (!_options.WorkerEnabled) { _logger.LogInformation("Outbox dispatcher is disabled by configuration; not starting."); + _readiness.MarkUnhealthy(RequiredWorkers.OutboxDispatcher); return; } + _readiness.MarkStarted(RequiredWorkers.OutboxDispatcher); + _logger.LogInformation( "Outbox dispatcher started. Interval={Interval} BatchSize={BatchSize} MaxAttempts={MaxAttempts}", _options.Interval, _options.BatchSize, _options.MaxAttempts); @@ -68,43 +75,57 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) private async Task DispatchAllAsync(CancellationToken ct) { - await using var scope = _scopeFactory.CreateAsyncScope(); - - var handlers = scope.ServiceProvider.GetServices().ToList(); - if (handlers.Count == 0) return; - - foreach (var handler in handlers) + try { - if (ct.IsCancellationRequested) break; + await using var scope = _scopeFactory.CreateAsyncScope(); - try + var handlers = scope.ServiceProvider.GetServices().ToList(); + var allHandlersSucceeded = true; + + foreach (var handler in handlers) { - // A fresh scope per handler: the processor and the handler share a scoped AppDbContext, and one - // handler's failed save must not leave a dirty change tracker for the next one to trip over. - await using var handlerScope = _scopeFactory.CreateAsyncScope(); - var processor = handlerScope.ServiceProvider.GetRequiredService(); - var scopedHandler = handlerScope.ServiceProvider - .GetServices() - .First(h => h.HandlerName == handler.HandlerName); + if (ct.IsCancellationRequested) break; + + try + { + // A fresh scope per handler: the processor and the handler share a scoped AppDbContext, and one + // handler's failed save must not leave a dirty change tracker for the next one to trip over. + await using var handlerScope = _scopeFactory.CreateAsyncScope(); + var processor = handlerScope.ServiceProvider.GetRequiredService(); + var scopedHandler = handlerScope.ServiceProvider + .GetServices() + .First(h => h.HandlerName == handler.HandlerName); - var result = await processor.DispatchAsync(scopedHandler, ct); + var result = await processor.DispatchAsync(scopedHandler, ct); - if (result.Processed > 0 || result.Failed > 0) + if (result.Processed > 0 || result.Failed > 0) + { + _logger.LogInformation( + "Outbox pass. Handler={Handler} Leased={Leased} Processed={Processed} Failed={Failed} DeadLettered={DeadLettered} OldestPendingMs={OldestMs}", + handler.HandlerName, result.Leased, result.Processed, result.Failed, result.DeadLettered, + (long?)result.OldestPendingAge?.TotalMilliseconds); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) { - _logger.LogInformation( - "Outbox pass. Handler={Handler} Leased={Leased} Processed={Processed} Failed={Failed} DeadLettered={DeadLettered} OldestPendingMs={OldestMs}", - handler.HandlerName, result.Leased, result.Processed, result.Failed, result.DeadLettered, - (long?)result.OldestPendingAge?.TotalMilliseconds); + allHandlersSucceeded = false; + // Nothing is lost: any delivery this pass leased keeps its lease only until it lapses, after which + // another pass reclaims it. That is precisely why the lease is a timestamp and not a boolean. + _logger.LogError( + ex, "Outbox dispatch pass failed. Handler={Handler}. Leases will lapse and be retried.", + handler.HandlerName); } } - catch (Exception ex) when (ex is not OperationCanceledException) - { - // Nothing is lost: any delivery this pass leased keeps its lease only until it lapses, after which - // another pass reclaims it. That is precisely why the lease is a timestamp and not a boolean. - _logger.LogError( - ex, "Outbox dispatch pass failed. Handler={Handler}. Leases will lapse and be retried.", - handler.HandlerName); - } + + if (allHandlersSucceeded) + _readiness.MarkHealthy(RequiredWorkers.OutboxDispatcher); + else + _readiness.MarkUnhealthy(RequiredWorkers.OutboxDispatcher); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _readiness.MarkUnhealthy(RequiredWorkers.OutboxDispatcher); + _logger.LogError(ex, "Outbox dispatcher health check failed. Will retry in {Interval}.", _options.Interval); } } } diff --git a/src/SimPle.Infrastructure/Persistence/DismissedSuggestionCleanupService.cs b/src/SimPle.Infrastructure/Persistence/DismissedSuggestionCleanupService.cs index 2ccdf21..9e2d10c 100644 --- a/src/SimPle.Infrastructure/Persistence/DismissedSuggestionCleanupService.cs +++ b/src/SimPle.Infrastructure/Persistence/DismissedSuggestionCleanupService.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.Persistence; @@ -16,19 +17,24 @@ public sealed class DismissedSuggestionCleanupService : BackgroundService private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private readonly DismissedSuggestionCleanupOptions _options; + private readonly IWorkerReadinessRegistry _readiness; public DismissedSuggestionCleanupService( 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.DismissedSuggestionCleanup); + // Stagger the first run so it doesn't run immediately on startup. await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); @@ -62,9 +68,12 @@ private async Task RunCleanupAsync(CancellationToken ct) _logger.LogInformation( "Dismissed-suggestion cleanup: deleted {Count} expired rows (cutoff: {Cutoff:u})", total, now); + + _readiness.MarkHealthy(RequiredWorkers.DismissedSuggestionCleanup); } catch (Exception ex) when (ex is not OperationCanceledException) { + _readiness.MarkUnhealthy(RequiredWorkers.DismissedSuggestionCleanup); _logger.LogError(ex, "Dismissed-suggestion cleanup failed. Will retry in {Interval}.", _options.Interval); } } diff --git a/tests/SimPle.IntegrationTests/Auth/HealthEndpointsTests.cs b/tests/SimPle.IntegrationTests/Auth/HealthEndpointsTests.cs new file mode 100644 index 0000000..afb0d53 --- /dev/null +++ b/tests/SimPle.IntegrationTests/Auth/HealthEndpointsTests.cs @@ -0,0 +1,57 @@ +using System.Net; +using FluentAssertions; +using SimPle.Api.Middleware; + +namespace SimPle.IntegrationTests.Auth; + +public sealed class HealthEndpointsTests : IDisposable +{ + private readonly TestWebApplicationFactory _factory = new(); + + [Fact] + public async Task LiveProbe_ReturnsHealthy_WhenTheProcessCanServeHttp() + { + using var client = _factory.CreateClient(); + + var response = await client.GetAsync("/health/live"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync()).Should().Be("{\"status\":\"healthy\"}"); + } + + [Fact] + public async Task ReadyProbe_ReturnsGenericHealthyStatus_WithoutDependencyDetails() + { + using var client = _factory.CreateClient(); + + var response = await client.GetAsync("/health/ready"); + var body = await response.Content.ReadAsStringAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + body.Should().Be("{\"status\":\"healthy\"}") + .And.NotContain("database") + .And.NotContain("storage") + .And.NotContain("worker"); + } + + [Fact] + public async Task CorrelationId_EchoesSafeCallerValue_AndReplacesUnsafeInput() + { + using var client = _factory.CreateClient(); + using var safeRequest = new HttpRequestMessage(HttpMethod.Get, "/health/live"); + safeRequest.Headers.Add(CorrelationIdMiddleware.HeaderName, "release-42.trace_1"); + + var safeResponse = await client.SendAsync(safeRequest); + safeResponse.Headers.GetValues(CorrelationIdMiddleware.HeaderName).Should().ContainSingle() + .Which.Should().Be("release-42.trace_1"); + + using var unsafeRequest = new HttpRequestMessage(HttpMethod.Get, "/health/live"); + unsafeRequest.Headers.Add(CorrelationIdMiddleware.HeaderName, "bad _factory.Dispose(); +} diff --git a/tests/SimPle.IntegrationTests/Auth/TestWebApplicationFactory.cs b/tests/SimPle.IntegrationTests/Auth/TestWebApplicationFactory.cs index 667caa4..224fd4b 100644 --- a/tests/SimPle.IntegrationTests/Auth/TestWebApplicationFactory.cs +++ b/tests/SimPle.IntegrationTests/Auth/TestWebApplicationFactory.cs @@ -54,6 +54,13 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ["Email:Password"] = "test-password", ["Email:AppUrl"] = "http://localhost:3000", ["Google:ClientId"] = "integration-tests-google-client-id", + ["Storage:Provider"] = "S3Compatible", + ["Storage:BucketName"] = "integration-tests-profile-assets", + ["Storage:Region"] = "us-east-1", + ["Storage:ServiceUrl"] = "http://storage.invalid", + ["Storage:AccessKey"] = "integration-tests-storage-access-key", + ["Storage:SecretKey"] = "integration-tests-storage-secret-key", + ["Storage:ProfilePrefix"] = "profile-assets", ["ConnectionStrings:DefaultConnection"] = "unused-for-in-memory-tests" }); }); diff --git a/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj b/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj index 306040b..bf598fa 100644 --- a/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj +++ b/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj @@ -17,6 +17,9 @@ + + + diff --git a/tests/SimPle.UnitTests/SimPle.UnitTests.csproj b/tests/SimPle.UnitTests/SimPle.UnitTests.csproj index bd44798..eada308 100644 --- a/tests/SimPle.UnitTests/SimPle.UnitTests.csproj +++ b/tests/SimPle.UnitTests/SimPle.UnitTests.csproj @@ -18,6 +18,9 @@ + + + From e2e76526ac84d2c5783c8ec43eeabeea8b5335c1 Mon Sep 17 00:00:00 2001 From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:25:54 +0300 Subject: [PATCH 3/4] feat(module-07-realtime-presence-chat): add SignalR presence hub and lobby chat backend Adds an authenticated /hubs/realtime SignalR hub with origin validation, connection tracking/rate limiting, and per-method scope/suspension/block rechecks; an in-memory presence registry; and persistent lobby chat (retention sweeper, idempotency, block-aware delivery) exposed over both the hub and REST history endpoints. --- src/SimPle.Api/Controllers/ChatController.cs | 127 + src/SimPle.Api/Hubs/RealtimeHub.cs | 219 ++ src/SimPle.Api/Program.cs | 75 +- .../Realtime/RealtimeConnectionCloser.cs | 28 + .../Realtime/RealtimeConnectionTracker.cs | 43 + src/SimPle.Api/Realtime/RealtimeNotifier.cs | 83 + src/SimPle.Api/Realtime/RealtimeOptions.cs | 14 + .../RealtimeOriginValidationMiddleware.cs | 48 + .../Realtime/SubjectUserIdProvider.cs | 17 + .../appsettings.Development.example.json | 3 + src/SimPle.Api/appsettings.json | 3 + .../Auth/Services/AuthService.cs | 17 + .../Chat/ChatBodyNormalizer.cs | 54 + src/SimPle.Application/Chat/ChatErrors.cs | 33 + .../Chat/ChatHistoryDirection.cs | 13 + .../Chat/ChatProfanityFilter.cs | 25 + .../Chat/ChatRetentionOptions.cs | 18 + src/SimPle.Application/Chat/ChatService.cs | 296 +++ .../Chat/IChatProfanityFilter.cs | 11 + .../Chat/IChatRepository.cs | 61 + src/SimPle.Application/Chat/IChatService.cs | 31 + .../Chat/ProfanityOptions.cs | 16 + .../Interfaces/IRealtimeConnectionCloser.cs | 25 + src/SimPle.Application/DependencyInjection.cs | 15 + .../Lobbies/Outbox/LobbyOutbox.cs | 8 + .../Outbox/IOutboxActivationStore.cs | 26 + .../Authorization/IRealtimeScopeAuthorizer.cs | 16 + .../Authorization/LobbyScopeAuthorizer.cs | 67 + .../Authorization/NullMatchScopeAuthorizer.cs | 19 + .../Realtime/Authorization/RealtimeAction.cs | 14 + .../RealtimeScopeAuthorizationResult.cs | 24 + .../Realtime/Contracts/ChatMessageDto.cs | 19 + .../Realtime/Contracts/IRealtimeClient.cs | 39 + .../Realtime/Contracts/IRealtimeNotifier.cs | 38 + .../Realtime/Contracts/RealtimeEnvelope.cs | 26 + .../Realtime/Contracts/RealtimeGroups.cs | 11 + .../Contracts/SendLobbyMessageResultDto.cs | 5 + .../Contracts/SubscribeLobbyResultDto.cs | 8 + .../Realtime/IRealtimeRateLimiter.cs | 22 + .../Realtime/Outbox/LobbyRealtimeHandler.cs | 224 ++ .../Realtime/Presence/IPresenceRegistry.cs | 40 + .../Presence/IPresenceViewerResolver.cs | 17 + .../Realtime/Presence/PresenceRegistry.cs | 192 ++ .../Realtime/Presence/PresenceStatus.cs | 16 + .../Presence/PresenceViewerResolver.cs | 44 + src/SimPle.Domain/Chat/ChatMessage.cs | 102 +- src/SimPle.Domain/Chat/ChatMessageHold.cs | 61 + .../Outbox/OutboxHandlerActivation.cs | 39 + .../Chat/ChatRepository.cs | 221 ++ .../Chat/ChatRetentionSweeper.cs | 76 + .../DependencyInjection.cs | 38 +- .../Health/WorkerReadinessRegistry.cs | 5 + .../Lobbies/DependencyProbes.cs | 11 +- ...6_AddChatAndRealtimeActivation.Designer.cs | 2045 +++++++++++++++++ ...0717001316_AddChatAndRealtimeActivation.cs | 116 + .../Migrations/AppDbContextModelSnapshot.cs | 133 ++ .../Outbox/OutboxActivationStore.cs | 62 + .../Outbox/OutboxDispatcherWorker.cs | 36 + .../Persistence/AppDbContext.cs | 6 + .../ChatMessageConfiguration.cs | 42 + .../ChatMessageHoldConfiguration.cs | 29 + .../OutboxHandlerActivationConfiguration.cs | 20 + .../Realtime/IHubContext.cs | 34 - .../Realtime/NullRealtimeConnectionCloser.cs | 15 + .../Realtime/RealtimeRateLimiter.cs | 96 + .../SimPle.Infrastructure.csproj | 1 + .../Chat/ChatEndpointsTests.cs | 364 +++ .../Chat/ChatRetentionHoldRaceTests.cs | 244 ++ .../Realtime/RealtimeHubTests.cs | 473 ++++ .../SimPle.IntegrationTests.csproj | 1 + .../Auth/AccountSecurityTests.cs | 2 + .../SimPle.UnitTests/Auth/AuthServiceTests.cs | 26 + .../Chat/ChatBodyNormalizerTests.cs | 159 ++ .../Chat/ChatProfanityFilterTests.cs | 64 + .../SimPle.UnitTests/Chat/ChatServiceTests.cs | 467 ++++ .../Realtime/LobbyScopeAuthorizerTests.cs | 202 ++ .../Realtime/NullMatchScopeAuthorizerTests.cs | 32 + .../Outbox/LobbyRealtimeHandlerTests.cs | 265 +++ .../Realtime/PresenceRegistryTests.cs | 186 ++ .../RealtimeDeadCodeRegressionTests.cs | 60 + 80 files changed, 7824 insertions(+), 59 deletions(-) create mode 100644 src/SimPle.Api/Controllers/ChatController.cs create mode 100644 src/SimPle.Api/Hubs/RealtimeHub.cs create mode 100644 src/SimPle.Api/Realtime/RealtimeConnectionCloser.cs create mode 100644 src/SimPle.Api/Realtime/RealtimeConnectionTracker.cs create mode 100644 src/SimPle.Api/Realtime/RealtimeNotifier.cs create mode 100644 src/SimPle.Api/Realtime/RealtimeOptions.cs create mode 100644 src/SimPle.Api/Realtime/RealtimeOriginValidationMiddleware.cs create mode 100644 src/SimPle.Api/Realtime/SubjectUserIdProvider.cs create mode 100644 src/SimPle.Application/Chat/ChatBodyNormalizer.cs create mode 100644 src/SimPle.Application/Chat/ChatErrors.cs create mode 100644 src/SimPle.Application/Chat/ChatHistoryDirection.cs create mode 100644 src/SimPle.Application/Chat/ChatProfanityFilter.cs create mode 100644 src/SimPle.Application/Chat/ChatRetentionOptions.cs create mode 100644 src/SimPle.Application/Chat/ChatService.cs create mode 100644 src/SimPle.Application/Chat/IChatProfanityFilter.cs create mode 100644 src/SimPle.Application/Chat/IChatRepository.cs create mode 100644 src/SimPle.Application/Chat/IChatService.cs create mode 100644 src/SimPle.Application/Chat/ProfanityOptions.cs create mode 100644 src/SimPle.Application/Common/Interfaces/IRealtimeConnectionCloser.cs create mode 100644 src/SimPle.Application/Outbox/IOutboxActivationStore.cs create mode 100644 src/SimPle.Application/Realtime/Authorization/IRealtimeScopeAuthorizer.cs create mode 100644 src/SimPle.Application/Realtime/Authorization/LobbyScopeAuthorizer.cs create mode 100644 src/SimPle.Application/Realtime/Authorization/NullMatchScopeAuthorizer.cs create mode 100644 src/SimPle.Application/Realtime/Authorization/RealtimeAction.cs create mode 100644 src/SimPle.Application/Realtime/Authorization/RealtimeScopeAuthorizationResult.cs create mode 100644 src/SimPle.Application/Realtime/Contracts/ChatMessageDto.cs create mode 100644 src/SimPle.Application/Realtime/Contracts/IRealtimeClient.cs create mode 100644 src/SimPle.Application/Realtime/Contracts/IRealtimeNotifier.cs create mode 100644 src/SimPle.Application/Realtime/Contracts/RealtimeEnvelope.cs create mode 100644 src/SimPle.Application/Realtime/Contracts/RealtimeGroups.cs create mode 100644 src/SimPle.Application/Realtime/Contracts/SendLobbyMessageResultDto.cs create mode 100644 src/SimPle.Application/Realtime/Contracts/SubscribeLobbyResultDto.cs create mode 100644 src/SimPle.Application/Realtime/IRealtimeRateLimiter.cs create mode 100644 src/SimPle.Application/Realtime/Outbox/LobbyRealtimeHandler.cs create mode 100644 src/SimPle.Application/Realtime/Presence/IPresenceRegistry.cs create mode 100644 src/SimPle.Application/Realtime/Presence/IPresenceViewerResolver.cs create mode 100644 src/SimPle.Application/Realtime/Presence/PresenceRegistry.cs create mode 100644 src/SimPle.Application/Realtime/Presence/PresenceStatus.cs create mode 100644 src/SimPle.Application/Realtime/Presence/PresenceViewerResolver.cs create mode 100644 src/SimPle.Domain/Chat/ChatMessageHold.cs create mode 100644 src/SimPle.Domain/Outbox/OutboxHandlerActivation.cs create mode 100644 src/SimPle.Infrastructure/Chat/ChatRepository.cs create mode 100644 src/SimPle.Infrastructure/Chat/ChatRetentionSweeper.cs create mode 100644 src/SimPle.Infrastructure/Migrations/20260717001316_AddChatAndRealtimeActivation.Designer.cs create mode 100644 src/SimPle.Infrastructure/Migrations/20260717001316_AddChatAndRealtimeActivation.cs create mode 100644 src/SimPle.Infrastructure/Outbox/OutboxActivationStore.cs create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/ChatMessageHoldConfiguration.cs create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/OutboxHandlerActivationConfiguration.cs delete mode 100644 src/SimPle.Infrastructure/Realtime/IHubContext.cs create mode 100644 src/SimPle.Infrastructure/Realtime/NullRealtimeConnectionCloser.cs create mode 100644 src/SimPle.Infrastructure/Realtime/RealtimeRateLimiter.cs create mode 100644 tests/SimPle.IntegrationTests/Chat/ChatEndpointsTests.cs create mode 100644 tests/SimPle.IntegrationTests/Chat/ChatRetentionHoldRaceTests.cs create mode 100644 tests/SimPle.IntegrationTests/Realtime/RealtimeHubTests.cs create mode 100644 tests/SimPle.UnitTests/Chat/ChatBodyNormalizerTests.cs create mode 100644 tests/SimPle.UnitTests/Chat/ChatProfanityFilterTests.cs create mode 100644 tests/SimPle.UnitTests/Chat/ChatServiceTests.cs create mode 100644 tests/SimPle.UnitTests/Realtime/LobbyScopeAuthorizerTests.cs create mode 100644 tests/SimPle.UnitTests/Realtime/NullMatchScopeAuthorizerTests.cs create mode 100644 tests/SimPle.UnitTests/Realtime/Outbox/LobbyRealtimeHandlerTests.cs create mode 100644 tests/SimPle.UnitTests/Realtime/PresenceRegistryTests.cs create mode 100644 tests/SimPle.UnitTests/Realtime/RealtimeDeadCodeRegressionTests.cs 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/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/Program.cs b/src/SimPle.Api/Program.cs index 443d537..0efd57e 100644 --- a/src/SimPle.Api/Program.cs +++ b/src/SimPle.Api/Program.cs @@ -14,15 +14,18 @@ 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; @@ -90,6 +93,37 @@ builder.Services.AddValidatorsFromAssemblyContaining(); 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"]) @@ -189,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)) @@ -425,6 +473,7 @@ await context.HttpContext.Response.WriteAsJsonAsync(new ApiErrorResponse( app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); +app.UseMiddleware(); if (app.Environment.IsDevelopment()) { @@ -445,6 +494,22 @@ 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 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/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/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/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 2485e8c..aeeeba5 100644 --- a/src/SimPle.Infrastructure/DependencyInjection.cs +++ b/src/SimPle.Infrastructure/DependencyInjection.cs @@ -2,10 +2,15 @@ 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; @@ -13,6 +18,7 @@ using SimPle.Infrastructure.Outbox; using SimPle.Infrastructure.Persistence; using SimPle.Infrastructure.Persistence.Repositories; +using SimPle.Infrastructure.Realtime; using SimPle.Infrastructure.Storage; namespace SimPle.Infrastructure; @@ -65,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(); @@ -108,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/Health/WorkerReadinessRegistry.cs b/src/SimPle.Infrastructure/Health/WorkerReadinessRegistry.cs index 5d6286a..e04aea0 100644 --- a/src/SimPle.Infrastructure/Health/WorkerReadinessRegistry.cs +++ b/src/SimPle.Infrastructure/Health/WorkerReadinessRegistry.cs @@ -14,6 +14,10 @@ public static class RequiredWorkers 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, @@ -21,6 +25,7 @@ public static class RequiredWorkers Matchmaking, LobbyExpiry, OutboxDispatcher, + ChatRetention, ]; } 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/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("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("RetainUntilUtc") + .HasDatabaseName("ix_chat_messages_retain"); + + b.HasIndex("SenderId", "ClientCommandId") + .IsUnique() + .HasDatabaseName("ux_chat_messages_sender_command"); + + b.HasIndex("Scope", "ScopeId", "CreatedAt", "Id") + .HasDatabaseName("ix_chat_messages_scope_created_id"); + + b.ToTable("chat_messages", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Chat.ChatMessageHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcknowledgedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("PlacedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ReleasedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MessageId") + .HasDatabaseName("ix_chat_message_holds_active") + .HasFilter("\"ReleasedAtUtc\" IS NULL"); + + b.ToTable("chat_message_holds", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.Block", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlockedId") + .HasColumnType("uuid"); + + b.Property("BlockerId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("BlockedId"); + + b.HasIndex("BlockerId"); + + b.HasIndex("BlockerId", "BlockedId") + .IsUnique(); + + b.ToTable("blocks", null, t => + { + t.HasCheckConstraint("ck_no_self_block", "\"BlockerId\" != \"BlockedId\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.DismissedFriendSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DismissedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SuggestedUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_dismissed_suggestions_expiresat"); + + b.HasIndex("SuggestedUserId"); + + b.HasIndex("UserId", "SuggestedUserId") + .IsUnique() + .HasDatabaseName("ix_dismissed_suggestions_user_suggested"); + + b.ToTable("dismissed_friend_suggestions", null, t => + { + t.HasCheckConstraint("ck_no_self_dismissal", "\"UserId\" != \"SuggestedUserId\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainVersion") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("EndReason") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSenderId") + .HasColumnType("uuid"); + + b.Property("NextRequestAllowedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestCycleId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("SendCountInWindow") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("SendWindowStartUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TransitionActorId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId", "Status", "SentAt", "Id") + .IsDescending(false, false, true, true) + .HasDatabaseName("ix_friendships_addressee_status_sentat_id"); + + b.HasIndex("RequesterId", "Status", "SentAt", "Id") + .IsDescending(false, false, true, true) + .HasDatabaseName("ix_friendships_requester_status_sentat_id"); + + b.ToTable("friendships", null, t => + { + t.HasCheckConstraint("ck_no_self_friendship", "\"RequesterId\" != \"AddresseeId\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.UserFriendSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FriendRequestPrivacy") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("FriendsListVisibility") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("PrivacyPolicyVersion") + .HasColumnType("bigint"); + + b.Property("SearchVisibility") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_friend_settings", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.CatalogSeedHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Checksum") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ManifestVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ManifestVersion") + .IsUnique(); + + b.ToTable("catalog_seed_history", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArtAltText") + .IsRequired() + .HasColumnType("text"); + + b.Property("ArtColorA") + .IsRequired() + .HasColumnType("text"); + + b.Property("ArtColorB") + .IsRequired() + .HasColumnType("text"); + + b.Property("ArtToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Difficulty") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("EstimatedDurationMaxMinutes") + .HasColumnType("integer"); + + b.Property("EstimatedDurationMinMinutes") + .HasColumnType("integer"); + + b.Property("FeaturedRank") + .HasColumnType("integer"); + + b.Property("Lifecycle") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LifecycleVersion") + .HasColumnType("integer"); + + b.Property("ManifestVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("MinPlayers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RulesSummary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Difficulty", "Slug") + .HasDatabaseName("ix_games_difficulty_slug"); + + b.HasIndex("EstimatedDurationMinMinutes", "Slug") + .HasDatabaseName("ix_games_duration_slug"); + + b.HasIndex("Name", "Slug") + .HasDatabaseName("ix_games_name_slug"); + + b.HasIndex("FeaturedRank", "SortOrder", "Slug") + .HasDatabaseName("ix_games_default_order"); + + b.ToTable("games", null, t => + { + t.HasCheckConstraint("ck_games_draft_retired_not_featured", "(\"Lifecycle\" <> 'Draft' AND \"Lifecycle\" <> 'Retired') OR \"FeaturedRank\" IS NULL"); + + t.HasCheckConstraint("ck_games_duration_bounds", "\"EstimatedDurationMinMinutes\" <= \"EstimatedDurationMaxMinutes\""); + + t.HasCheckConstraint("ck_games_min_players", "\"MinPlayers\" >= 1 AND \"MinPlayers\" <= \"MaxPlayers\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameModeCapability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GameId", "Mode") + .IsUnique(); + + b.ToTable("game_mode_capabilities", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameId", "Value") + .IsUnique(); + + b.ToTable("game_tags", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.UserFavoriteGame", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CycleId") + .HasColumnType("integer"); + + b.Property("GameId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("UserId", "GameId") + .IsUnique(); + + b.ToTable("user_favorite_games", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.Lobby", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiFillRequested") + .HasColumnType("boolean"); + + b.Property("CapabilityVersion") + .HasColumnType("integer"); + + b.Property("ClosedReason") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GameSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HostUserId") + .HasColumnType("uuid"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("Privacy") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Rated") + .HasColumnType("boolean"); + + b.Property("ResolvedRegion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Revision") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("SpectatorPolicy") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TieBreakRuleId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TimeControlId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc") + .HasDatabaseName("ix_lobbies_expiry_sweep") + .HasFilter("\"State\" IN ('Open', 'Starting')"); + + b.HasIndex("HostUserId") + .HasDatabaseName("ix_lobbies_host"); + + b.HasIndex("CreatedAt", "Id") + .HasDatabaseName("ix_lobbies_public_discovery") + .HasFilter("\"State\" = 'Open' AND \"Privacy\" = 'Public'"); + + b.ToTable("lobbies", null, t => + { + t.HasCheckConstraint("ck_lobbies_capability_version", "\"CapabilityVersion\" >= 1"); + + t.HasCheckConstraint("ck_lobbies_closed_reason_iff_terminal", "(\"State\" IN ('Closed', 'Expired')) = (\"ClosedReason\" IS NOT NULL)"); + + t.HasCheckConstraint("ck_lobbies_max_players", "\"MaxPlayers\" >= 2 AND \"MaxPlayers\" <= 8"); + + t.HasCheckConstraint("ck_lobbies_revision", "\"Revision\" >= 1"); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InviteeUserId") + .HasColumnType("uuid"); + + b.Property("InviterUserId") + .HasColumnType("uuid"); + + b.Property("LobbyId") + .HasColumnType("uuid"); + + b.Property("RespondedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc") + .HasDatabaseName("ix_lobby_invites_expiry_sweep") + .HasFilter("\"State\" = 'Pending'"); + + b.HasIndex("InviterUserId"); + + b.HasIndex("LobbyId", "InviteeUserId") + .IsUnique() + .HasDatabaseName("ux_lobby_invites_one_pending_per_invitee") + .HasFilter("\"State\" = 'Pending'"); + + b.HasIndex("InviteeUserId", "CreatedAt", "Id") + .HasDatabaseName("ix_lobby_invites_invitee_pending") + .HasFilter("\"State\" = 'Pending'"); + + b.ToTable("lobby_invites", null, t => + { + t.HasCheckConstraint("ck_lobby_invites_no_self_invite", "\"InviterUserId\" <> \"InviteeUserId\""); + + t.HasCheckConstraint("ck_lobby_invites_responded_iff_terminal", "(\"State\" <> 'Pending') = (\"RespondedAtUtc\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyJoinCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CodeDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Generation") + .HasColumnType("integer"); + + b.Property("LinkTokenDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LobbyId") + .HasColumnType("uuid"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SupersededAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CodeDigest") + .IsUnique() + .HasDatabaseName("ux_lobby_join_credentials_active_code") + .HasFilter("\"State\" = 'Active'"); + + b.HasIndex("LinkTokenDigest") + .IsUnique() + .HasDatabaseName("ux_lobby_join_credentials_active_link_token") + .HasFilter("\"State\" = 'Active'"); + + b.HasIndex("LobbyId") + .IsUnique() + .HasDatabaseName("ux_lobby_join_credentials_one_active_per_lobby") + .HasFilter("\"State\" = 'Active'"); + + b.ToTable("lobby_join_credentials", null, t => + { + t.HasCheckConstraint("ck_lobby_join_credentials_generation", "\"Generation\" >= 1"); + + t.HasCheckConstraint("ck_lobby_join_credentials_superseded_iff_terminal", "(\"State\" <> 'Active') = (\"SupersededAtUtc\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyMember", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReady") + .HasColumnType("boolean"); + + b.Property("JoinedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LeftAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LobbyId") + .HasColumnType("uuid"); + + b.Property("RemovedByUserId") + .HasColumnType("uuid"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ux_lobby_members_one_joined_per_user") + .HasFilter("\"State\" = 'Joined'"); + + b.HasIndex("LobbyId", "JoinedAtUtc", "UserId") + .HasDatabaseName("ix_lobby_members_lobby_tenure"); + + b.ToTable("lobby_members", null, t => + { + t.HasCheckConstraint("ck_lobby_members_left_at_iff_terminal", "(\"State\" IN ('Left', 'Kicked')) = (\"LeftAtUtc\" IS NOT NULL)"); + + t.HasCheckConstraint("ck_lobby_members_removed_by_only_on_kick", "\"RemovedByUserId\" IS NULL OR \"State\" = 'Kicked'"); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyStartRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FailureReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LobbyId") + .HasColumnType("uuid"); + + b.Property("LobbyRevision") + .HasColumnType("integer"); + + b.Property("MatchRequestId") + .HasColumnType("uuid"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MatchRequestId") + .IsUnique() + .HasDatabaseName("ux_lobby_start_requests_match_request"); + + b.HasIndex("LobbyId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("ux_lobby_start_requests_idempotency"); + + b.HasIndex("LobbyId", "LobbyRevision") + .IsUnique() + .HasDatabaseName("ux_lobby_start_requests_one_open_per_revision") + .HasFilter("\"State\" = 'Open'"); + + b.ToTable("lobby_start_requests", null, t => + { + t.HasCheckConstraint("ck_lobby_start_requests_failure_reason_only_on_failed", "\"FailureReason\" IS NULL OR \"State\" = 'Failed'"); + + t.HasCheckConstraint("ck_lobby_start_requests_resolved_iff_terminal", "(\"State\" <> 'Open') = (\"ResolvedAtUtc\" IS NOT NULL)"); + + t.HasCheckConstraint("ck_lobby_start_requests_revision", "\"LobbyRevision\" >= 1"); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Matchmaking.MatchmakingAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("MatchRequestId") + .HasColumnType("uuid"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .HasDatabaseName("ix_matchmaking_assignments_group"); + + b.HasIndex("MatchRequestId") + .HasDatabaseName("ix_matchmaking_assignments_match_request"); + + b.HasIndex("TicketId") + .IsUnique() + .HasDatabaseName("ux_matchmaking_assignments_one_active_per_ticket") + .HasFilter("\"State\" = 'Active'"); + + b.ToTable("matchmaking_assignments", null, t => + { + t.HasCheckConstraint("ck_matchmaking_assignments_resolved_iff_terminal", "(\"State\" <> 'Active') = (\"ResolvedAtUtc\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Matchmaking.MatchmakingTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapabilityVersion") + .HasColumnType("integer"); + + b.Property("ClaimedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ClaimedByWorker") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeadlineAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EnqueuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GameSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PlayerCount") + .HasColumnType("integer"); + + b.Property("Rated") + .HasColumnType("boolean"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("RatingSourceVersion") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedRegion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RetryBudget") + .HasColumnType("integer"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TimeControlId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("DeadlineAtUtc") + .HasDatabaseName("ix_matchmaking_tickets_expiry_sweep") + .HasFilter("\"State\" IN ('Queued', 'Claimed', 'Requeued')"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ux_matchmaking_tickets_one_nonterminal_per_user") + .HasFilter("\"State\" IN ('Queued', 'Claimed', 'Requeued')"); + + b.HasIndex("GameSlug", "CapabilityVersion", "Mode", "PlayerCount", "TimeControlId", "Rated", "ResolvedRegion", "EnqueuedAtUtc", "Id") + .HasDatabaseName("ix_matchmaking_tickets_candidate_pool") + .HasFilter("\"State\" = 'Queued'"); + + b.ToTable("matchmaking_tickets", null, t => + { + t.HasCheckConstraint("ck_matchmaking_tickets_capability_version", "\"CapabilityVersion\" >= 1"); + + t.HasCheckConstraint("ck_matchmaking_tickets_deadline_after_enqueue", "\"DeadlineAtUtc\" > \"EnqueuedAtUtc\""); + + t.HasCheckConstraint("ck_matchmaking_tickets_no_worker_while_queued", "\"State\" <> 'Queued' OR \"ClaimedByWorker\" IS NULL"); + + t.HasCheckConstraint("ck_matchmaking_tickets_player_count", "\"PlayerCount\" >= 2 AND \"PlayerCount\" <= 8"); + + t.HasCheckConstraint("ck_matchmaking_tickets_retry_budget", "\"RetryBudget\" >= 0"); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("DeadLettered") + .HasColumnType("boolean"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("HandlerName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastError") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Lease") + .HasColumnType("timestamp with time zone"); + + b.Property("Processed") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EventId", "HandlerName") + .IsUnique() + .HasDatabaseName("ix_outbox_deliveries_event_handler"); + + b.HasIndex("HandlerName", "Processed", "DeadLettered") + .HasDatabaseName("ix_outbox_deliveries_handler_processed_dead"); + + b.ToTable("outbox_deliveries", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxHandlerActivation", b => + { + b.Property("HandlerName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ActivatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WatermarkEventId") + .HasColumnType("uuid"); + + b.Property("WatermarkOccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("HandlerName"); + + b.ToTable("outbox_handler_activations", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AggregateDomainVersion") + .HasColumnType("bigint"); + + b.Property("AggregateId") + .HasColumnType("uuid"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EventVersion") + .HasColumnType("integer"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RequestCycleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAtUtc") + .HasDatabaseName("ix_outbox_messages_occurredat"); + + b.HasIndex("AggregateId", "EventType", "AggregateDomainVersion") + .IsUnique() + .HasDatabaseName("ix_outbox_messages_aggregate_event_version"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileExternalLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayLabel") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("profile_external_links", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileInterestTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "NormalizedName") + .IsUnique(); + + b.ToTable("profile_interest_tags", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.RetiredUsername", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PriorOwnerUserId") + .HasColumnType("uuid"); + + b.Property("RetiredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("PriorOwnerUserId"); + + b.ToTable("retired_usernames", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.UsernameChangeRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedRequestedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RejectionReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RequestMonth") + .HasColumnType("integer"); + + b.Property("RequestYear") + .HasColumnType("integer"); + + b.Property("RequestedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedBy") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Status"); + + b.HasIndex("UserId", "RequestYear", "RequestMonth"); + + b.ToTable("username_change_requests", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.EmailVerificationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PendingEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("email_verification_tokens", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("password_reset_tokens", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarObjectKey") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BannerFallbackColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("BannerObjectKey") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("BannerUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Elo") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("GoogleId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Initials") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("character varying(4)"); + + b.Property("IsEmailVerified") + .HasColumnType("boolean"); + + b.Property("IsSuspended") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsernameAdminRequestMonth") + .HasColumnType("integer"); + + b.Property("LastUsernameAdminRequestYear") + .HasColumnType("integer"); + + b.Property("LastUsernameImmediateChangeMonth") + .HasColumnType("integer"); + + b.Property("LastUsernameImmediateChangeYear") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProfileType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Player"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubscriptionTier") + .IsRequired() + .HasColumnType("text"); + + b.Property("SuspendedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Visibility") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Public"); + + b.Property("Xp") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GoogleId") + .IsUnique() + .HasFilter("\"GoogleId\" IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .IsUnique(); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Capabilities.GameCapabilityProfile", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany() + .HasForeignKey("GameSlug") + .HasPrincipalKey("Slug") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Chat.ChatMessage", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Chat.ChatMessageHold", b => + { + b.HasOne("SimPle.Domain.Chat.ChatMessage", null) + .WithMany() + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.Block", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("BlockedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("BlockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.DismissedFriendSuggestion", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("SuggestedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.Friendship", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.UserFriendSettings", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameModeCapability", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany("Capabilities") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameTag", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany("Tags") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.UserFavoriteGame", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.Lobby", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("HostUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyInvite", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("InviteeUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("InviterUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Lobbies.Lobby", null) + .WithMany() + .HasForeignKey("LobbyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyJoinCredential", b => + { + b.HasOne("SimPle.Domain.Lobbies.Lobby", null) + .WithMany() + .HasForeignKey("LobbyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyMember", b => + { + b.HasOne("SimPle.Domain.Lobbies.Lobby", null) + .WithMany("Members") + .HasForeignKey("LobbyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyStartRequest", b => + { + b.HasOne("SimPle.Domain.Lobbies.Lobby", null) + .WithMany() + .HasForeignKey("LobbyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Matchmaking.MatchmakingAssignment", b => + { + b.HasOne("SimPle.Domain.Matchmaking.MatchmakingTicket", null) + .WithMany() + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Matchmaking.MatchmakingTicket", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxDelivery", b => + { + b.HasOne("SimPle.Domain.Outbox.OutboxMessage", null) + .WithMany() + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileExternalLink", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileInterestTag", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.UsernameChangeRequest", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Users.EmailVerificationToken", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Users.PasswordResetToken", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Users.RefreshToken", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.Game", b => + { + b.Navigation("Capabilities"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.Lobby", b => + { + b.Navigation("Members"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/SimPle.Infrastructure/Migrations/20260717001316_AddChatAndRealtimeActivation.cs b/src/SimPle.Infrastructure/Migrations/20260717001316_AddChatAndRealtimeActivation.cs new file mode 100644 index 0000000..7fef3b4 --- /dev/null +++ b/src/SimPle.Infrastructure/Migrations/20260717001316_AddChatAndRealtimeActivation.cs @@ -0,0 +1,116 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SimPle.Infrastructure.Migrations +{ + /// + public partial class AddChatAndRealtimeActivation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "chat_messages", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Scope = table.Column(type: "integer", nullable: false), + ScopeId = table.Column(type: "uuid", nullable: false), + SenderId = table.Column(type: "uuid", nullable: false), + Body = table.Column(type: "text", nullable: false), + SchemaVersion = table.Column(type: "integer", nullable: false, defaultValue: 1), + ClientCommandId = table.Column(type: "uuid", nullable: false), + DeletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + DeletedByUserId = table.Column(type: "uuid", nullable: true), + RetainUntilUtc = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_chat_messages", x => x.Id); + table.ForeignKey( + name: "FK_chat_messages_users_SenderId", + column: x => x.SenderId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "outbox_handler_activations", + columns: table => new + { + HandlerName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + ActivatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + WatermarkOccurredAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + WatermarkEventId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_outbox_handler_activations", x => x.HandlerName); + }); + + migrationBuilder.CreateTable( + name: "chat_message_holds", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MessageId = table.Column(type: "uuid", nullable: false), + ReasonCode = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + PlacedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ReleasedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + AcknowledgedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_chat_message_holds", x => x.Id); + table.ForeignKey( + name: "FK_chat_message_holds_chat_messages_MessageId", + column: x => x.MessageId, + principalTable: "chat_messages", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "ix_chat_message_holds_active", + table: "chat_message_holds", + column: "MessageId", + filter: "\"ReleasedAtUtc\" IS NULL"); + + migrationBuilder.CreateIndex( + name: "ix_chat_messages_retain", + table: "chat_messages", + column: "RetainUntilUtc"); + + migrationBuilder.CreateIndex( + name: "ix_chat_messages_scope_created_id", + table: "chat_messages", + columns: new[] { "Scope", "ScopeId", "CreatedAt", "Id" }); + + migrationBuilder.CreateIndex( + name: "ux_chat_messages_sender_command", + table: "chat_messages", + columns: new[] { "SenderId", "ClientCommandId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "chat_message_holds"); + + migrationBuilder.DropTable( + name: "outbox_handler_activations"); + + migrationBuilder.DropTable( + name: "chat_messages"); + } + } +} diff --git a/src/SimPle.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/SimPle.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 1b14a24..71877e0 100644 --- a/src/SimPle.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/SimPle.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -139,6 +139,101 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + 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("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("RetainUntilUtc") + .HasDatabaseName("ix_chat_messages_retain"); + + b.HasIndex("SenderId", "ClientCommandId") + .IsUnique() + .HasDatabaseName("ux_chat_messages_sender_command"); + + b.HasIndex("Scope", "ScopeId", "CreatedAt", "Id") + .HasDatabaseName("ix_chat_messages_scope_created_id"); + + b.ToTable("chat_messages", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Chat.ChatMessageHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcknowledgedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("PlacedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ReleasedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MessageId") + .HasDatabaseName("ix_chat_message_holds_active") + .HasFilter("\"ReleasedAtUtc\" IS NULL"); + + b.ToTable("chat_message_holds", (string)null); + }); + modelBuilder.Entity("SimPle.Domain.Friends.Block", b => { b.Property("Id") @@ -1130,6 +1225,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("outbox_deliveries", (string)null); }); + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxHandlerActivation", b => + { + b.Property("HandlerName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ActivatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WatermarkEventId") + .HasColumnType("uuid"); + + b.Property("WatermarkOccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("HandlerName"); + + b.ToTable("outbox_handler_activations", (string)null); + }); + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxMessage", b => { b.Property("Id") @@ -1661,6 +1776,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("SimPle.Domain.Chat.ChatMessage", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Chat.ChatMessageHold", b => + { + b.HasOne("SimPle.Domain.Chat.ChatMessage", null) + .WithMany() + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("SimPle.Domain.Friends.Block", b => { b.HasOne("SimPle.Domain.Users.User", null) diff --git a/src/SimPle.Infrastructure/Outbox/OutboxActivationStore.cs b/src/SimPle.Infrastructure/Outbox/OutboxActivationStore.cs new file mode 100644 index 0000000..fdd5ea1 --- /dev/null +++ b/src/SimPle.Infrastructure/Outbox/OutboxActivationStore.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using SimPle.Application.Outbox; +using SimPle.Domain.Outbox; +using SimPle.Infrastructure.Persistence; + +namespace SimPle.Infrastructure.Outbox; + +/// See . Idempotent try-insert/catch-23505/re-read, matching this +/// codebase's established idempotent-insert idiom (e.g. lobby join-credential code generation). +public sealed class OutboxActivationStore : IOutboxActivationStore +{ + private readonly AppDbContext _db; + + public OutboxActivationStore(AppDbContext db) => _db = db; + + public async Task GetOrActivateAsync( + string handlerName, + IReadOnlyList eventTypes, + DateTime nowUtc, + CancellationToken ct = default) + { + var existing = await _db.OutboxHandlerActivations + .AsNoTracking() + .FirstOrDefaultAsync(a => a.HandlerName == handlerName, ct); + if (existing is not null) return existing; + + // MAX(OccurredAtUtc, Id) over this handler's own event types, taken at the activation instant. The outbox + // has no global sequence, so (OccurredAtUtc, Id) is the only ordering key available — the same one the + // handler itself compares every later message against. + var latest = await _db.OutboxMessages + .AsNoTracking() + .Where(m => eventTypes.Contains(m.EventType)) + .OrderByDescending(m => m.OccurredAtUtc) + .ThenByDescending(m => m.Id) + .Select(m => new { m.OccurredAtUtc, m.Id }) + .FirstOrDefaultAsync(ct); + + var watermarkOccurredAtUtc = latest?.OccurredAtUtc ?? DateTime.MinValue; + var watermarkEventId = latest?.Id; + + var activation = OutboxHandlerActivation.Activate(handlerName, nowUtc, watermarkOccurredAtUtc, watermarkEventId); + + _db.OutboxHandlerActivations.Add(activation); + + try + { + await _db.SaveChangesAsync(ct); + return activation; + } + catch (DbUpdateException ex) when (PostgresContention.IsContention(ex)) + { + // Lost the race for this HandlerName's primary key: another instance activated first. Detach the + // failed insert and re-read the winner's row rather than retrying our own watermark computation — + // the whole point of the watermark is that it is captured exactly once, at first activation. + _db.Entry(activation).State = EntityState.Detached; + + return await _db.OutboxHandlerActivations + .AsNoTracking() + .FirstAsync(a => a.HandlerName == handlerName, ct); + } + } +} diff --git a/src/SimPle.Infrastructure/Outbox/OutboxDispatcherWorker.cs b/src/SimPle.Infrastructure/Outbox/OutboxDispatcherWorker.cs index 8765e2a..2b77c0b 100644 --- a/src/SimPle.Infrastructure/Outbox/OutboxDispatcherWorker.cs +++ b/src/SimPle.Infrastructure/Outbox/OutboxDispatcherWorker.cs @@ -28,17 +28,20 @@ public sealed class OutboxDispatcherWorker : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly OutboxOptions _options; + private readonly TimeProvider _clock; private readonly ILogger _logger; private readonly IWorkerReadinessRegistry _readiness; public OutboxDispatcherWorker( IServiceScopeFactory scopeFactory, IOptions options, + TimeProvider clock, ILogger logger, IWorkerReadinessRegistry readiness) { _scopeFactory = scopeFactory; _options = options.Value; + _clock = clock; _logger = logger; _readiness = readiness; } @@ -52,6 +55,16 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) return; } + // Every handler's activation watermark (docs/specs/module-07-realtime-presence-chat-spec.md, "Activation + // watermark") must be captured here, before the loop below ever leases a message — not lazily, inside + // HandleAsync, on whichever pass first happens to find something leasable. GetOrActivateAsync's watermark + // is MAX(OccurredAtUtc, Id) over the outbox *at the moment its query runs*; if that query is deferred until + // a live event has already landed, the query sees its own event as the table's current maximum and + // classifies it as pre-existing history, silently dropping it rather than fanning it out. Activating here, + // before this process can have leased anything, keeps the watermark anchored to "before this process + // existed" rather than to an arbitrary later instant that a fast-moving live event can race into. + await ActivateAllHandlersAsync(stoppingToken); + _readiness.MarkStarted(RequiredWorkers.OutboxDispatcher); _logger.LogInformation( @@ -73,6 +86,29 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } } + private async Task ActivateAllHandlersAsync(CancellationToken ct) + { + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var activation = scope.ServiceProvider.GetRequiredService(); + var handlers = scope.ServiceProvider.GetServices(); + var nowUtc = _clock.GetUtcNow().UtcDateTime; + + foreach (var handler in handlers) + { + if (ct.IsCancellationRequested) break; + await activation.GetOrActivateAsync(handler.HandlerName, handler.EventTypes, nowUtc, ct); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Not fatal: an un-activated handler simply activates lazily on its first HandleAsync call instead, + // which is the pre-existing (racy) behavior this method exists to avoid — not a new failure mode. + _logger.LogError(ex, "Eager outbox handler activation failed; handlers will activate lazily instead."); + } + } + private async Task DispatchAllAsync(CancellationToken ct) { try diff --git a/src/SimPle.Infrastructure/Persistence/AppDbContext.cs b/src/SimPle.Infrastructure/Persistence/AppDbContext.cs index 94b0df2..390a1af 100644 --- a/src/SimPle.Infrastructure/Persistence/AppDbContext.cs +++ b/src/SimPle.Infrastructure/Persistence/AppDbContext.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using SimPle.Domain.Capabilities; +using SimPle.Domain.Chat; using SimPle.Domain.Friends; using SimPle.Domain.Games; using SimPle.Domain.Lobbies; @@ -56,6 +57,11 @@ public AppDbContext(DbContextOptions options) : base(options) { } public DbSet GameCapabilityProfiles => Set(); public DbSet CapabilitySeedHistory => Set(); + // Module 7 — chat persistence & outbox handler activation watermarks + public DbSet ChatMessages => Set(); + public DbSet ChatMessageHolds => Set(); + public DbSet OutboxHandlerActivations => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); diff --git a/src/SimPle.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs b/src/SimPle.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs new file mode 100644 index 0000000..40a3095 --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimPle.Domain.Chat; +using SimPle.Domain.Users; + +namespace SimPle.Infrastructure.Persistence.Configurations; + +public sealed class ChatMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("chat_messages"); + + builder.HasKey(m => m.Id); + + builder.Property(m => m.Scope).IsRequired(); + builder.Property(m => m.ScopeId).IsRequired(); + builder.Property(m => m.SenderId).IsRequired(); + builder.Property(m => m.Body).HasColumnType("text").IsRequired(); + builder.Property(m => m.SchemaVersion).HasDefaultValue(1).IsRequired(); + builder.Property(m => m.ClientCommandId).IsRequired(); + builder.Property(m => m.DeletedAtUtc); + builder.Property(m => m.DeletedByUserId); + builder.Property(m => m.RetainUntilUtc).IsRequired(); + + // A duplicate send (client retry) catches 23505 here and the caller re-reads the original message. + builder.HasIndex(m => new { m.SenderId, m.ClientCommandId }) + .IsUnique() + .HasDatabaseName("ux_chat_messages_sender_command"); + + // History cursor. Scope-prefixed so Module 8 (Match scope) reuses this same index shape. + builder.HasIndex(m => new { m.Scope, m.ScopeId, m.CreatedAt, m.Id }) + .HasDatabaseName("ix_chat_messages_scope_created_id"); + + // Retention cleanup scan. + builder.HasIndex(m => m.RetainUntilUtc).HasDatabaseName("ix_chat_messages_retain"); + + // RESTRICT is the backstop: even if a future cleanup bug forgets the NOT EXISTS active-hold probe, + // PostgreSQL refuses with 23503 rather than destroying moderation evidence tied to this sender. + builder.HasOne().WithMany().HasForeignKey(m => m.SenderId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/SimPle.Infrastructure/Persistence/Configurations/ChatMessageHoldConfiguration.cs b/src/SimPle.Infrastructure/Persistence/Configurations/ChatMessageHoldConfiguration.cs new file mode 100644 index 0000000..ea3ecbb --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Configurations/ChatMessageHoldConfiguration.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimPle.Domain.Chat; + +namespace SimPle.Infrastructure.Persistence.Configurations; + +public sealed class ChatMessageHoldConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("chat_message_holds"); + + builder.HasKey(h => h.Id); + + builder.Property(h => h.MessageId).IsRequired(); + builder.Property(h => h.ReasonCode).HasMaxLength(64).IsRequired(); + builder.Property(h => h.PlacedAtUtc).IsRequired(); + builder.Property(h => h.ReleasedAtUtc); + builder.Property(h => h.AcknowledgedAtUtc); + + // Partial index: the retention cleanup sweep's NOT EXISTS(active hold) probe. + builder.HasIndex(h => h.MessageId) + .HasFilter("\"ReleasedAtUtc\" IS NULL") + .HasDatabaseName("ix_chat_message_holds_active"); + + // RESTRICT: a held message can never be swept away by the retention cleanup while evidence exists. + builder.HasOne().WithMany().HasForeignKey(h => h.MessageId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/SimPle.Infrastructure/Persistence/Configurations/OutboxHandlerActivationConfiguration.cs b/src/SimPle.Infrastructure/Persistence/Configurations/OutboxHandlerActivationConfiguration.cs new file mode 100644 index 0000000..a0d298c --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Configurations/OutboxHandlerActivationConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimPle.Domain.Outbox; + +namespace SimPle.Infrastructure.Persistence.Configurations; + +public sealed class OutboxHandlerActivationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("outbox_handler_activations"); + + builder.HasKey(a => a.HandlerName); + builder.Property(a => a.HandlerName).HasMaxLength(128).IsRequired(); + + builder.Property(a => a.ActivatedAtUtc).IsRequired(); + builder.Property(a => a.WatermarkOccurredAtUtc).IsRequired(); + builder.Property(a => a.WatermarkEventId); + } +} diff --git a/src/SimPle.Infrastructure/Realtime/IHubContext.cs b/src/SimPle.Infrastructure/Realtime/IHubContext.cs deleted file mode 100644 index 30bb5c4..0000000 --- a/src/SimPle.Infrastructure/Realtime/IHubContext.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace SimPle.Infrastructure.Realtime; - -// Placeholder interfaces for Module 7 (Real-Time Presence, Lobby, Chat). -// The Application layer depends on these abstractions; the concrete SignalR implementations -// will live in Infrastructure once Microsoft.AspNetCore.SignalR is wired in. - -public interface IPresenceNotifier -{ - Task UserOnlineAsync(Guid userId, CancellationToken ct = default); - Task UserOfflineAsync(Guid userId, CancellationToken ct = default); - Task FriendStatusChangedAsync(Guid friendId, string status, string activity, CancellationToken ct = default); -} - -public interface ILobbyNotifier -{ - Task LobbyUpdatedAsync(string lobbyCode, object lobbyState, CancellationToken ct = default); - Task PlayerJoinedAsync(string lobbyCode, object slot, CancellationToken ct = default); - Task PlayerLeftAsync(string lobbyCode, Guid userId, CancellationToken ct = default); - Task GameStartingAsync(string lobbyCode, Guid sessionId, CancellationToken ct = default); -} - -public interface IGameNotifier -{ - Task MatchStateUpdatedAsync(Guid sessionId, object state, CancellationToken ct = default); - Task MoveAcceptedAsync(Guid sessionId, object move, CancellationToken ct = default); - Task MoveRejectedAsync(Guid sessionId, Guid playerId, string reason, CancellationToken ct = default); - Task MatchEndedAsync(Guid sessionId, object result, CancellationToken ct = default); -} - -public interface IHardwareNotifier -{ - Task DeviceConnectedAsync(Guid deviceId, CancellationToken ct = default); - Task DeviceInputReceivedAsync(Guid deviceId, string eventType, string payload, CancellationToken ct = default); -} diff --git a/src/SimPle.Infrastructure/Realtime/NullRealtimeConnectionCloser.cs b/src/SimPle.Infrastructure/Realtime/NullRealtimeConnectionCloser.cs new file mode 100644 index 0000000..f48b7e4 --- /dev/null +++ b/src/SimPle.Infrastructure/Realtime/NullRealtimeConnectionCloser.cs @@ -0,0 +1,15 @@ +using SimPle.Application.Common.Interfaces; + +namespace SimPle.Infrastructure.Realtime; + +/// +/// No-op default DI registration for , so Auth flows (logout, logout-all, +/// revoke-session, delete-account) never throw when the realtime hub is disabled — e.g. as a rollback. The API +/// layer overrides this registration with a real SignalR-backed implementation when the hub is mapped (it needs +/// the concrete hub type, which Infrastructure cannot reference — see docs/specs/module-07-realtime-presence-chat-spec.md). +/// +public sealed class NullRealtimeConnectionCloser : IRealtimeConnectionCloser +{ + public Task CloseUserConnectionsAsync(Guid userId, string reason, CancellationToken ct = default) => + Task.CompletedTask; +} diff --git a/src/SimPle.Infrastructure/Realtime/RealtimeRateLimiter.cs b/src/SimPle.Infrastructure/Realtime/RealtimeRateLimiter.cs new file mode 100644 index 0000000..7fdc9e4 --- /dev/null +++ b/src/SimPle.Infrastructure/Realtime/RealtimeRateLimiter.cs @@ -0,0 +1,96 @@ +using System.Threading.RateLimiting; +using SimPle.Application.Realtime; + +namespace SimPle.Infrastructure.Realtime; + +/// +/// Hand-rolled hub-invocation rate limiter (docs/specs/module-07-realtime-presence-chat-spec.md): 5 connections +/// per user, 5 messages/5s burst chained with 20 messages/60s sustained (both must pass). Not SignalR-specific — +/// pure System.Threading.RateLimiting — so it lives in Infrastructure rather than the API layer. +/// +public sealed class RealtimeRateLimiter : IRealtimeRateLimiter, IDisposable +{ + private const int MaxConnectionsPerUser = 5; + private const int BurstPermits = 5; + private static readonly TimeSpan BurstWindow = TimeSpan.FromSeconds(5); + private const int SustainedPermits = 20; + private static readonly TimeSpan SustainedWindow = TimeSpan.FromMinutes(1); + + private readonly PartitionedRateLimiter _connectionLimiter; + private readonly PartitionedRateLimiter _burstLimiter; + private readonly PartitionedRateLimiter _sustainedLimiter; + + // One lease per acquired connection, per user — a ConcurrencyLimiter permit is only released by disposing the + // exact lease that acquired it, so a single "latest lease" field per user would leak permits from earlier + // concurrent connections. Push on acquire, pop on release (release order need not match acquire order; any + // held lease releases one permit). + private readonly System.Collections.Concurrent.ConcurrentDictionary> _connectionLeases = new(); + + public RealtimeRateLimiter() + { + _connectionLimiter = PartitionedRateLimiter.Create(userId => + RateLimitPartition.GetConcurrencyLimiter(userId, _ => new ConcurrencyLimiterOptions + { + PermitLimit = MaxConnectionsPerUser, + QueueLimit = 0, + })); + + _burstLimiter = PartitionedRateLimiter.Create(userId => + RateLimitPartition.GetFixedWindowLimiter(userId, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = BurstPermits, + Window = BurstWindow, + QueueLimit = 0, + })); + + _sustainedLimiter = PartitionedRateLimiter.Create(userId => + RateLimitPartition.GetFixedWindowLimiter(userId, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = SustainedPermits, + Window = SustainedWindow, + QueueLimit = 0, + })); + } + + public bool TryAcquireConnection(Guid userId) + { + var lease = _connectionLimiter.AttemptAcquire(userId); + if (!lease.IsAcquired) + { + lease.Dispose(); + return false; + } + + // Belt-and-suspenders redundant cap alongside the presence registry's own 5-connection enforcement + // (deliberate; see final report). + var stack = _connectionLeases.GetOrAdd(userId, _ => new System.Collections.Concurrent.ConcurrentStack()); + stack.Push(lease); + return true; + } + + public void ReleaseConnection(Guid userId) + { + if (_connectionLeases.TryGetValue(userId, out var stack) && stack.TryPop(out var lease)) + lease.Dispose(); + } + + public bool TryAcquireMessage(Guid userId) + { + using var burstLease = _burstLimiter.AttemptAcquire(userId); + if (!burstLease.IsAcquired) + return false; + + using var sustainedLease = _sustainedLimiter.AttemptAcquire(userId); + return sustainedLease.IsAcquired; + } + + public void Dispose() + { + _connectionLimiter.Dispose(); + _burstLimiter.Dispose(); + _sustainedLimiter.Dispose(); + foreach (var stack in _connectionLeases.Values) + while (stack.TryPop(out var lease)) + lease.Dispose(); + } +} diff --git a/src/SimPle.Infrastructure/SimPle.Infrastructure.csproj b/src/SimPle.Infrastructure/SimPle.Infrastructure.csproj index c7ac31f..41d7574 100644 --- a/src/SimPle.Infrastructure/SimPle.Infrastructure.csproj +++ b/src/SimPle.Infrastructure/SimPle.Infrastructure.csproj @@ -27,6 +27,7 @@ + diff --git a/tests/SimPle.IntegrationTests/Chat/ChatEndpointsTests.cs b/tests/SimPle.IntegrationTests/Chat/ChatEndpointsTests.cs new file mode 100644 index 0000000..7dc59c9 --- /dev/null +++ b/tests/SimPle.IntegrationTests/Chat/ChatEndpointsTests.cs @@ -0,0 +1,364 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SimPle.Domain.Capabilities; +using SimPle.Domain.Chat; +using SimPle.Domain.Games; +using SimPle.Infrastructure.Persistence; +using SimPle.IntegrationTests.Auth; + +namespace SimPle.IntegrationTests.Chat; + +/// +/// REST surface tests for Module 7 backend session B (M07-B2): docs/specs/module-07-realtime-presence-chat-spec.md +/// Test Matrix -- history cursor paging, delete/tombstone, and Swagger documentation. Sending a message has no +/// REST route ('s class doc: hub-only), so the hub happy path +/// (SendLobbyMessage -> ChatMessageCreated) lives in RealtimeHubTests.cs instead. Messages here are seeded +/// directly via rather than sent, matching this file's REST-surface-only remit. +/// +public sealed class ChatEndpointsTests : IDisposable +{ + private const string TestPassword = "ValidPassword1"; + private readonly TestWebApplicationFactory _factory = new(); + + // ── History: default limit + ordering ─────────────────────────────────────── + + [Fact] + public async Task GetHistory_DefaultLimit_ReturnsThirtyMostRecentInAscendingOrder() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + var lobbyId = await CreatePublicLobbyAsync(client); + var senderId = await GetMyUserIdAsync(client); + var bodies = await SeedMessagesAsync(lobbyId, senderId, count: 35); + + var response = await client.GetAsync($"/api/chat/lobbies/{lobbyId}/messages"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await response.Content.ReadFromJsonAsync(); + var items = page.GetProperty("items").EnumerateArray().ToList(); + + items.Should().HaveCount(30); + items.Select(i => i.GetProperty("body").GetString()) + .Should().Equal(bodies.TakeLast(30)); + page.GetProperty("nextCursor").GetString().Should().NotBeNullOrEmpty(); + } + + // ── History: scrollback paging covers everything, no dupes/gaps ───────────── + + [Fact] + public async Task GetHistory_PagingBackward_CoversEveryMessageWithNoDuplicatesOrGaps() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + var lobbyId = await CreatePublicLobbyAsync(client); + var senderId = await GetMyUserIdAsync(client); + var bodies = await SeedMessagesAsync(lobbyId, senderId, count: 35); + + var firstPage = await GetHistoryPageAsync(client, lobbyId, direction: "before", cursor: null, limit: null); + var firstItems = firstPage.GetProperty("items").EnumerateArray() + .Select(i => i.GetProperty("body").GetString()).ToList(); + var firstCursor = firstPage.GetProperty("nextCursor").GetString(); + firstCursor.Should().NotBeNullOrEmpty(); + + var secondPage = await GetHistoryPageAsync(client, lobbyId, direction: "before", cursor: firstCursor, limit: null); + var secondItems = secondPage.GetProperty("items").EnumerateArray() + .Select(i => i.GetProperty("body").GetString()).ToList(); + secondPage.GetProperty("nextCursor").ValueKind.Should().Be(JsonValueKind.Null); + + var combined = secondItems.Concat(firstItems).ToList(); + combined.Should().Equal(bodies, "walking every scrollback page in chronological order must reconstruct " + + "the full seeded history exactly once each, with no duplicates and no gaps"); + } + + // ── History: reconnect repair (direction=after) ────────────────────────────── + + [Fact] + public async Task GetHistory_DirectionAfter_RepairsFromTheStartThenFromTheCursor() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + var lobbyId = await CreatePublicLobbyAsync(client); + var senderId = await GetMyUserIdAsync(client); + var bodies = await SeedMessagesAsync(lobbyId, senderId, count: 35); + + var firstPage = await GetHistoryPageAsync(client, lobbyId, direction: "after", cursor: null, limit: null); + var firstItems = firstPage.GetProperty("items").EnumerateArray() + .Select(i => i.GetProperty("body").GetString()).ToList(); + firstItems.Should().Equal(bodies.Take(30), "no cursor + after means from the start of history, ascending"); + var firstCursor = firstPage.GetProperty("nextCursor").GetString(); + firstCursor.Should().NotBeNullOrEmpty(); + + var secondPage = await GetHistoryPageAsync(client, lobbyId, direction: "after", cursor: firstCursor, limit: null); + var secondItems = secondPage.GetProperty("items").EnumerateArray() + .Select(i => i.GetProperty("body").GetString()).ToList(); + secondItems.Should().Equal(bodies.Skip(30), "a cursor + after means the page immediately newer than it"); + secondPage.GetProperty("nextCursor").ValueKind.Should().Be(JsonValueKind.Null); + } + + // ── History: limit cap + rejection ─────────────────────────────────────────── + + [Fact] + public async Task GetHistory_LimitFifty_ReturnsAtMostTheCap() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + var lobbyId = await CreatePublicLobbyAsync(client); + var senderId = await GetMyUserIdAsync(client); + await SeedMessagesAsync(lobbyId, senderId, count: 56); + + var page = await GetHistoryPageAsync(client, lobbyId, direction: "before", cursor: null, limit: 50); + + page.GetProperty("items").GetArrayLength().Should().Be(50); + } + + [Fact] + public async Task GetHistory_LimitAboveCap_Returns400ValidationFailed() + { + // Validation runs before authorization/lookup (ChatService.GetHistoryAsync), so an arbitrary lobby id is + // sufficient to prove the cap is enforced -- no lobby needs to exist for this to reject. + using var client = CreateClient(); + await SignInAsync(client); + + var response = await client.GetAsync($"/api/chat/lobbies/{Guid.NewGuid()}/messages?limit=51"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("Validation.Failed"); + } + + // ── Delete / tombstone ──────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteMessage_ByAuthor_TombstonesTheMessageAndPersistsAcrossHistory() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + var lobbyId = await CreatePublicLobbyAsync(client); + var senderId = await GetMyUserIdAsync(client); + var messageId = await SeedMessageAsync(lobbyId, senderId, "delete me"); + + var delete = await client.DeleteAsync($"/api/chat/messages/{messageId}"); + delete.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var page = await GetHistoryPageAsync(client, lobbyId, direction: "before", cursor: null, limit: null); + var item = page.GetProperty("items").EnumerateArray().Single(); + item.GetProperty("id").GetGuid().Should().Be(messageId, "the id is never reused by a delete"); + item.GetProperty("body").ValueKind.Should().Be(JsonValueKind.Null); + item.GetProperty("deleted").GetBoolean().Should().BeTrue(); + + // Idempotent retry: a second delete on an already-deleted message still succeeds. + var retriedDelete = await client.DeleteAsync($"/api/chat/messages/{messageId}"); + retriedDelete.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task DeleteMessage_ByNonAuthorMember_Returns403Forbidden() + { + using var author = CreateClient(); + await SignInAsync(author); + await SeedCatalogAsync(); + var lobbyId = await CreatePublicLobbyAsync(author); + var authorId = await GetMyUserIdAsync(author); + var messageId = await SeedMessageAsync(lobbyId, authorId, "not yours to delete"); + + using var otherMember = CreateClient(); + await SignInAsync(otherMember); + var join = await otherMember.PostAsJsonAsync("/api/lobbies/join", new { LobbyId = lobbyId }); + join.EnsureSuccessStatusCode(); + + var delete = await otherMember.DeleteAsync($"/api/chat/messages/{messageId}"); + + delete.StatusCode.Should().Be(HttpStatusCode.Forbidden); + var body = await delete.Content.ReadAsStringAsync(); + body.Should().Contain("Chat.Forbidden"); + } + + [Fact] + public async Task DeleteMessage_MissingCsrfHeader_Returns400() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + var lobbyId = await CreatePublicLobbyAsync(client); + var senderId = await GetMyUserIdAsync(client); + var messageId = await SeedMessageAsync(lobbyId, senderId, "csrf guard"); + client.DefaultRequestHeaders.Remove("X-Requested-With"); + + var delete = await client.DeleteAsync($"/api/chat/messages/{messageId}"); + + delete.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var body = await delete.Content.ReadAsStringAsync(); + body.Should().Contain("Auth.CsrfHeaderRequired"); + } + + // ── Swagger ─────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Swagger_DescribesChatHistoryAndDeleteRoutes() + { + using var client = CreateClient(); + + var document = await client.GetStringAsync("/swagger/v1/swagger.json"); + + document.Should().Contain("\"/api/chat/lobbies/{lobbyId}/messages\"") + .And.Contain("\"/api/chat/messages/{messageId}\"") + .And.Contain("Chat_GetHistory") + .And.Contain("Chat_DeleteMessage"); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private HttpClient CreateClient() + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + HandleCookies = true + }); + client.DefaultRequestHeaders.Add("X-Requested-With", "XMLHttpRequest"); + return client; + } + + private static async Task SignInAsync(HttpClient client) + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + var email = $"chat-{suffix}@example.com"; + var username = $"chat{suffix}"; + + var register = await client.PostAsJsonAsync("/api/auth/register", new + { + Username = username, + Email = email, + Password = TestPassword, + ConfirmPassword = TestPassword, + CaptchaToken = "test-captcha-token" + }); + register.EnsureSuccessStatusCode(); + + var login = await client.PostAsJsonAsync("/api/auth/login", new + { + EmailOrUsername = email, + Password = TestPassword, + CaptchaToken = "test-captcha-token" + }); + login.EnsureSuccessStatusCode(); + } + + private static async Task GetMyUserIdAsync(HttpClient client) + { + var response = await client.GetAsync("/api/profile/me"); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("userId").GetGuid(); + } + + private static async Task CreatePublicLobbyAsync(HttpClient host) + { + var response = await host.PostAsJsonAsync("/api/lobbies", new + { + GameSlug = "chess-lite", + CapabilityVersion = 1, + Privacy = "Public", + MaxPlayers = 8, + TimeControlId = "blitz-3-2", + Rated = false, + Region = "eu-west", + SpectatorPolicy = "Anyone", + TieBreakRuleId = "none", + AiFillRequested = false + }); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + } + + private async Task SeedCatalogAsync() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (!await db.Games.AnyAsync(g => g.Slug == "chess-lite")) + { + db.Games.Add(Game.Create( + slug: "chess-lite", name: "Chess Lite", summary: "A streamlined chess experience.", + rulesSummary: "Chess Lite wins by checkmate.", difficulty: GameDifficulty.Medium, + estimatedDurationMinMinutes: 10, estimatedDurationMaxMinutes: 20, + minPlayers: 2, maxPlayers: 8, initialLifecycle: GameLifecycle.Available, + featuredRank: null, sortOrder: 1, artToken: "chess-lite", + artColorA: "#9B51E0", artColorB: "#2D9CDB", artAltText: "Chess Lite abstract game artwork", + manifestVersion: "2026.1", category: "strategy", + tags: new[] { "classic" }, + modes: new[] { "multiplayer", "cooperative" })); + await db.SaveChangesAsync(); + } + + if (!await db.GameCapabilityProfiles.AnyAsync(p => p.GameSlug == "chess-lite" && p.CapabilityVersion == 1)) + { + db.GameCapabilityProfiles.Add(GameCapabilityProfile.Create( + "chess-lite", 1, minPlayers: 2, maxPlayers: 8, + allowedModes: new[] { "multiplayer", "cooperative" }, + timeControls: new[] { "blitz-3-2", "rapid-10-0", "untimed" }, + tieBreakRules: new[] { "none", "sudden-death" }, + spectatorPolicies: new[] { "Anyone", "FriendsOnly", "Disabled" }, + ratedEligible: false, aiFillEligible: false, + manifestVersion: "2026.1")); + await db.SaveChangesAsync(); + } + } + + /// Seeds messages directly, one second apart starting from a fixed instant, + /// so (CreatedAt, Id) ordering is deterministic across the whole batch. Returns each message's body in + /// creation (chronological) order. + private async Task> SeedMessagesAsync(Guid lobbyId, Guid senderId, int count) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var baseTime = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc); + var bodies = new List(count); + for (var i = 0; i < count; i++) + { + var body = $"message-{i:D3}"; + bodies.Add(body); + db.ChatMessages.Add(ChatMessage.Create( + ChatScope.Lobby, lobbyId, senderId, body, Guid.NewGuid(), baseTime.AddSeconds(i))); + } + await db.SaveChangesAsync(); + return bodies; + } + + private async Task SeedMessageAsync(Guid lobbyId, Guid senderId, string body) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var message = ChatMessage.Create(ChatScope.Lobby, lobbyId, senderId, body, Guid.NewGuid(), DateTime.UtcNow); + db.ChatMessages.Add(message); + await db.SaveChangesAsync(); + return message.Id; + } + + private static async Task GetHistoryPageAsync( + HttpClient client, Guid lobbyId, string direction, string? cursor, int? limit) + { + var query = $"?direction={direction}"; + if (cursor is not null) query += $"&cursor={Uri.EscapeDataString(cursor)}"; + if (limit is not null) query += $"&limit={limit}"; + + var response = await client.GetAsync($"/api/chat/lobbies/{lobbyId}/messages{query}"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + return await response.Content.ReadFromJsonAsync(); + } + + public void Dispose() => _factory.Dispose(); +} diff --git a/tests/SimPle.IntegrationTests/Chat/ChatRetentionHoldRaceTests.cs b/tests/SimPle.IntegrationTests/Chat/ChatRetentionHoldRaceTests.cs new file mode 100644 index 0000000..894ffae --- /dev/null +++ b/tests/SimPle.IntegrationTests/Chat/ChatRetentionHoldRaceTests.cs @@ -0,0 +1,244 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using SimPle.Application.Chat; +using SimPle.Domain.Chat; +using SimPle.Domain.Users; +using SimPle.Infrastructure.Chat; +using SimPle.Infrastructure.Persistence; +using Xunit; + +namespace SimPle.IntegrationTests.Chat; + +/// +/// Real-PostgreSQL tests for the moderation-hold-vs-retention race (docs/specs/module-07-realtime-presence-chat- +/// spec.md, Risk #5, "mandatory real-PostgreSQL test"). +/// +/// +/// InMemory cannot prove any of this: it has no row locks and no FOR UPDATE ... SKIP LOCKED, so a race +/// asserted against it would pass regardless of whether and +/// actually serialize on the message row. +/// +/// +/// Skipped unless MIGRATION_TEST_CONNECTION_STRING points at a running PostgreSQL instance. +/// +public sealed class ChatRetentionHoldRaceTests : IAsyncLifetime +{ + private readonly string? _masterConn = Environment.GetEnvironmentVariable("MIGRATION_TEST_CONNECTION_STRING"); + private readonly string _dbName = $"simple_m7b_{Guid.NewGuid():N}"; + private string? _testConn; + + private static readonly DateTime T0 = new(2026, 7, 11, 12, 0, 0, DateTimeKind.Utc); + + public async Task InitializeAsync() + { + if (_masterConn is null) return; + + _testConn = new NpgsqlConnectionStringBuilder(_masterConn) { Database = _dbName }.ToString(); + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + await using var createCmd = masterConn.CreateCommand(); + createCmd.CommandText = $"CREATE DATABASE \"{_dbName}\""; + await createCmd.ExecuteNonQueryAsync(); + + await using var db = CreateDb(); + await db.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + if (_masterConn is null || _testConn is null) return; + + NpgsqlConnection.ClearAllPools(); + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + + await using var terminateCmd = masterConn.CreateCommand(); + terminateCmd.CommandText = $@" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{_dbName}' + AND pid <> pg_backend_pid() + -- Only this role's own backends; see LobbiesPostgresConcurrencyTests for why the unfiltered form + -- fails intermittently under a least-privilege test role. + AND usename = current_user;"; + await terminateCmd.ExecuteNonQueryAsync(); + + await using var dropCmd = masterConn.CreateCommand(); + dropCmd.CommandText = $"DROP DATABASE IF EXISTS \"{_dbName}\""; + await dropCmd.ExecuteNonQueryAsync(); + } + + // ── The Risk #5 races ──────────────────────────────────────────────────── + + [SkippableFact] + public async Task AnExpiredMessageWithAnActiveHold_SurvivesTheSweep() + { + SkipIfNoPg(); + + var sender = await SeedUserAsync(); + var messageId = await SeedExpiredMessageAsync(sender.Id); + + await using (var holdDb = CreateDb()) + { + var holdResult = await new ChatRepository(holdDb) + .PlaceHoldAsync(messageId, "m12-evidence", T0, CancellationToken.None); + holdResult.IsSuccess.Should().BeTrue(); + } + + await using var sweepDb = CreateDb(); + var deleted = await new ChatRepository(sweepDb).DeleteExpiredAsync(T0, 100, CancellationToken.None); + + deleted.Should().Be(0, "an active hold must exclude the row from the sweep's candidate set entirely"); + + await using var verify = CreateDb(); + (await verify.ChatMessages.AnyAsync(m => m.Id == messageId)).Should().BeTrue(); + } + + [SkippableFact] + public async Task AnExpiredMessageWithNoHold_IsDeletedBySweep() + { + SkipIfNoPg(); + + var sender = await SeedUserAsync(); + var messageId = await SeedExpiredMessageAsync(sender.Id); + + await using var sweepDb = CreateDb(); + var deleted = await new ChatRepository(sweepDb).DeleteExpiredAsync(T0, 100, CancellationToken.None); + + deleted.Should().Be(1); + + await using var verify = CreateDb(); + (await verify.ChatMessages.AnyAsync(m => m.Id == messageId)).Should().BeFalse(); + } + + [SkippableFact] + public async Task AnExpiredMessageWhoseHoldWasReleased_IsDeletedBySweep() + { + SkipIfNoPg(); + + var sender = await SeedUserAsync(); + var messageId = await SeedExpiredMessageAsync(sender.Id); + + await using (var holdDb = CreateDb()) + { + var holdResult = await new ChatRepository(holdDb) + .PlaceHoldAsync(messageId, "m12-evidence", T0, CancellationToken.None); + holdResult.IsSuccess.Should().BeTrue(); + + var hold = await holdDb.ChatMessageHolds.SingleAsync(h => h.MessageId == messageId); + hold.Release(T0); + await holdDb.SaveChangesAsync(); + } + + await using var sweepDb = CreateDb(); + var deleted = await new ChatRepository(sweepDb).DeleteExpiredAsync(T0, 100, CancellationToken.None); + + deleted.Should().Be(1, "a released hold no longer satisfies the partial index's ReleasedAtUtc IS NULL filter"); + } + + /// + /// The mandatory race itself. The sweep takes its row lock and deletes the row within one uncommitted + /// statement/transaction; a concurrent on the same row must + /// block rather than race past it — proving the two transactions serialize on the message + /// row (spec: "Row-lock serialization"). Once the sweep commits, the blocked hold unblocks and receives a + /// truthful , never a silently "successful" hold on a row that no + /// longer exists. + /// + [SkippableFact] + public async Task SweepWinningTheRace_UnblocksAPendingHold_WithATypedMessageExpired_NeverSilentEvidenceLoss() + { + SkipIfNoPg(); + + var sender = await SeedUserAsync(); + var messageId = await SeedExpiredMessageAsync(sender.Id); + + await using var sweepDb = CreateDb(); + await using var sweepTx = await sweepDb.Database.BeginTransactionAsync(); + + // Runs the sweep's single CTE+DELETE statement inside the still-open sweepTx — the row is deleted but not + // yet committed, so its lock is still held. + var deleted = await new ChatRepository(sweepDb).DeleteExpiredAsync(T0, 100, CancellationToken.None); + deleted.Should().Be(1); + + await using var holdDb = CreateDb(); + var holdTask = new ChatRepository(holdDb) + .PlaceHoldAsync(messageId, "m12-evidence", T0, CancellationToken.None); + + // The hold must still be blocked a short while later — proving serialization, not a lost race. + var finishedEarly = await Task.WhenAny(holdTask, Task.Delay(TimeSpan.FromMilliseconds(500))); + finishedEarly.Should().NotBe(holdTask, "PlaceHoldAsync must block on the sweep's uncommitted row lock rather than race past it"); + + await sweepTx.CommitAsync(); + + var holdResult = await holdTask.WaitAsync(TimeSpan.FromSeconds(10)); + + holdResult.IsSuccess.Should().BeFalse("the sweep already committed the delete by the time the hold's lock was granted"); + holdResult.Error!.Code.Should().Be(ChatErrors.MessageExpired); + + await using var verify = CreateDb(); + (await verify.ChatMessages.AnyAsync(m => m.Id == messageId)).Should().BeFalse(); + (await verify.ChatMessageHolds.AnyAsync(h => h.MessageId == messageId)).Should().BeFalse( + "a hold must never be persisted for a message the sweep already deleted"); + } + + /// Reverse interleaving of the same race: the hold commits first, so the message survives and the + /// sweep's own NOT EXISTS probe (not SKIP LOCKED) is what excludes it. + [SkippableFact] + public async Task HoldWinningTheRace_LeavesTheMessageSurviving_AndTheSweepReportsItSkipped() + { + SkipIfNoPg(); + + var sender = await SeedUserAsync(); + var messageId = await SeedExpiredMessageAsync(sender.Id); + + await using (var holdDb = CreateDb()) + { + var holdResult = await new ChatRepository(holdDb) + .PlaceHoldAsync(messageId, "m12-evidence", T0, CancellationToken.None); + holdResult.IsSuccess.Should().BeTrue(); + } + + await using var sweepDb = CreateDb(); + var deleted = await new ChatRepository(sweepDb).DeleteExpiredAsync(T0, 100, CancellationToken.None); + + deleted.Should().Be(0, "the held message must never even enter the sweep's candidate set"); + + await using var verify = CreateDb(); + (await verify.ChatMessages.AnyAsync(m => m.Id == messageId)).Should().BeTrue(); + (await verify.ChatMessageHolds.AnyAsync(h => h.MessageId == messageId && h.ReleasedAtUtc == null)).Should().BeTrue(); + } + + // ── Fixtures ───────────────────────────────────────────────────────────── + + private void SkipIfNoPg() => Skip.If(_masterConn is null, + "Set MIGRATION_TEST_CONNECTION_STRING to a PostgreSQL connection string to run Module 7B retention race tests."); + + private AppDbContext CreateDb() => + new(new DbContextOptionsBuilder().UseNpgsql(_testConn).Options); + + private async Task SeedUserAsync() + { + await using var db = CreateDb(); + var g = Guid.NewGuid(); + var user = User.Create($"m7b{g:N}"[..20], $"m7b{g:N}@test.io", "hash", "M7B Race User"); + db.Users.Add(user); + await db.SaveChangesAsync(); + return user; + } + + /// A message whose RetainUntilUtc has already passed relative to — created 31 days + /// before T0, so its 30-day RetainUntilUtc is one day in T0's past. + private async Task SeedExpiredMessageAsync(Guid senderId) + { + await using var db = CreateDb(); + var createdAtUtc = T0 - ChatMessage.RetentionPeriod - TimeSpan.FromDays(1); + var message = ChatMessage.Create( + ChatScope.Lobby, Guid.NewGuid(), senderId, "an expired chat message", Guid.NewGuid(), createdAtUtc); + db.ChatMessages.Add(message); + await db.SaveChangesAsync(); + return message.Id; + } +} diff --git a/tests/SimPle.IntegrationTests/Realtime/RealtimeHubTests.cs b/tests/SimPle.IntegrationTests/Realtime/RealtimeHubTests.cs new file mode 100644 index 0000000..0f31954 --- /dev/null +++ b/tests/SimPle.IntegrationTests/Realtime/RealtimeHubTests.cs @@ -0,0 +1,473 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Http.Connections; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SimPle.Domain.Capabilities; +using SimPle.Domain.Games; +using SimPle.Infrastructure.Persistence; +using SimPle.IntegrationTests.Auth; + +namespace SimPle.IntegrationTests.Realtime; + +/// +/// Hub-level (not just unit-level) verification for Module 7 backend session A (M07-B1): +/// docs/specs/module-07-realtime-presence-chat-spec.md. Connects real s against the +/// in-memory host over LongPolling (a real HTTP transport the TestServer's +/// fake handler supports end to end, unlike raw WebSockets) so that authentication, the origin allowlist +/// middleware, the connection cap, per-method scope authorization, proactive close, and the message-size cap are +/// all exercised through the actual wire path rather than by calling internal classes directly. +/// +/// NOT covered here: a live "match" scope request. The B1 hub surface never exposes a method that routes to the +/// "match" scope kind (no SubscribeMatch exists — see RealtimeHub.cs) so realtime.scope_not_available is +/// only reachable at the unit level today (NullMatchScopeAuthorizerTests). Calling that out rather than inventing +/// a hub method to force the path, which would be a production-code change outside this file's remit. +/// +public sealed class RealtimeHubTests : IDisposable +{ + private const string TestPassword = "ValidPassword1"; + private const string HubPath = "/hubs/realtime"; + private const string AllowedOrigin = "http://localhost:3000"; + + private readonly TestWebApplicationFactory _factory = new(); + + // ── 1. Authenticated cookie connect + origin mismatch ─────────────────────── + + [Fact] + public async Task Connect_WithValidCookieAndAllowedOrigin_Succeeds() + { + using var client = CreateClient(); + var cookies = await SignInAsync(client); + + await using var connection = CreateHubConnection(cookies, AllowedOrigin); + + await connection.StartAsync(); + + connection.State.Should().Be(HubConnectionState.Connected); + } + + [Fact] + public async Task Connect_WithMismatchedOrigin_IsRefused() + { + using var client = CreateClient(); + var cookies = await SignInAsync(client); + + await using var connection = CreateHubConnection(cookies, "http://evil.example.com"); + + var act = () => connection.StartAsync(); + + await act.Should().ThrowAsync(); + connection.State.Should().Be(HubConnectionState.Disconnected); + } + + /// M07-003 regression: a request to the hub with no Origin header at all must be rejected the same + /// as a present-but-unlisted one — a missing header is not an implicit pass. Uses a raw + /// negotiate POST rather than , since the SignalR client always sets Origin itself + /// and offers no way to omit it. + [Fact] + public async Task Connect_WithMissingOriginHeader_IsRefusedWith403() + { + using var client = CreateClient(); + await SignInAsync(client); + + var response = await client.PostAsync(HubPath + "/negotiate?negotiateVersion=1", new StringContent("")); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + // ── 2. Connection cap ──────────────────────────────────────────────────────── + + [Fact] + public async Task SixthConcurrentConnection_ForSameUser_IsRejectedWithConnectionLimit() + { + using var client = CreateClient(); + var cookies = await SignInAsync(client); + + var connections = new List(); + try + { + for (var i = 0; i < 5; i++) + { + var connection = CreateHubConnection(cookies, AllowedOrigin); + await connection.StartAsync(); + connections.Add(connection); + } + + await using var sixth = CreateHubConnection(cookies, AllowedOrigin); + var closedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + sixth.Closed += ex => + { + closedTcs.TrySetResult(ex); + return Task.CompletedTask; + }; + + // A HubException thrown from OnConnectedAsync does not necessarily fault StartAsync itself: the + // SignalR protocol handshake response can already have been sent to the client before the hub's + // OnConnectedAsync override runs server-side (confirmed via server-side logs: "Realtime connection + // rejected: connection limit reached" followed by "HubException: Realtime.ConnectionLimit" from + // OnConnectedAsync, then the connection is torn down). Both a synchronous throw here and a + // just-connected-then-immediately-closed connection are accepted proof of rejection. + try + { + await sixth.StartAsync(); + } + catch + { + // Rejected synchronously during the handshake (e.g. via the origin/auth path) -- also acceptable. + } + + // KNOWN FLAKE under full-solution runs (documented, not a production defect): this project has no + // [CollectionDefinition(DisableParallelization = true)], so xUnit runs every integration test + // collection concurrently (default max degree of parallelism == CPU core count). A full `dotnet test + // SimPle.sln` run competes dozens of WebApplicationFactory/Postgres-backed hosts plus 898 unit tests + // for the same cores, which can starve the thread pool badly enough that a LongPolling client fails + // to observe a server-initiated abort within even a generous window — this is an observation-latency + // artifact of full-suite parallel contention, not evidence the connection limit stopped being + // enforced (the rate limiter and presence registry are per-host singletons, unaffected by other + // hosts' load; see RealtimeRateLimiter/PresenceRegistry). Reproduced failures at 10s, 25s, and 60s + // under full-suite load; passes reliably in isolation and under capped parallelism + // (`dotnet test -- xUnit.MaxParallelThreads=4`). Not fixed here because the real fix (capping + // parallelism suite-wide) is a shared test-infrastructure change outside M07-B1's scope — see the + // M07-B1 checkpoint report for the accepted tradeoff. + if (sixth.State == HubConnectionState.Connected) + await Task.WhenAny(closedTcs.Task, Task.Delay(TimeSpan.FromSeconds(60))); + + sixth.State.Should().Be(HubConnectionState.Disconnected, + "the 6th concurrent connection for the same user must be rejected (Realtime.ConnectionLimit), " + + "surfacing here as the connection closing shortly after connecting rather than staying live"); + } + finally + { + foreach (var connection in connections) + await connection.DisposeAsync(); + } + } + + // ── 3. Privacy-safe not-found via SubscribeLobby ──────────────────────────── + + [Fact] + public async Task SubscribeLobby_ToANonexistentLobby_ReturnsPrivacySafeNotFound() + { + using var client = CreateClient(); + var cookies = await SignInAsync(client); + + await using var connection = CreateHubConnection(cookies, AllowedOrigin); + await connection.StartAsync(); + + var act = () => connection.InvokeAsync("SubscribeLobby", Guid.NewGuid()); + + var exception = await act.Should().ThrowAsync(); + exception.And.Message.Should().Contain("Lobbies.NotFound"); + } + + [Fact] + public async Task SubscribeLobby_ToAPrivateLobbyBelongingToAnotherUser_ReturnsTheSamePrivacySafeNotFound() + { + using var host = CreateClient(); + await SignInAsync(host); + await SeedCatalogAsync(); + var lobbyId = await CreatePrivateLobbyAsync(host); + + using var outsider = CreateClient(); + var outsiderCookies = await SignInAsync(outsider); + + await using var connection = CreateHubConnection(outsiderCookies, AllowedOrigin); + await connection.StartAsync(); + + var act = () => connection.InvokeAsync("SubscribeLobby", lobbyId); + + var exception = await act.Should().ThrowAsync(); + exception.And.Message.Should().Contain("Lobbies.NotFound"); + } + + // ── 4. Proactive close on logout ──────────────────────────────────────────── + + [Fact] + public async Task Logout_ProactivelyClosesTheLiveHubConnection() + { + using var client = CreateClient(); + var cookies = await SignInAsync(client); + + await using var connection = CreateHubConnection(cookies, AllowedOrigin); + var closedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + connection.Closed += ex => + { + closedTcs.TrySetResult(ex); + return Task.CompletedTask; + }; + await connection.StartAsync(); + connection.State.Should().Be(HubConnectionState.Connected); + + var logout = await client.PostAsync("/api/auth/logout", null); + logout.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var completed = await Task.WhenAny(closedTcs.Task, Task.Delay(TimeSpan.FromSeconds(10))); + + completed.Should().Be(closedTcs.Task, "logout must proactively abort the live realtime connection " + + "rather than waiting for token expiry or the next per-method recheck"); + } + + // ── 5. Oversized message rejected without disabling the buffer limit ─────── + + [Fact] + public async Task OversizedMessage_IsRejected_AndTheCapIsNeverGloballyDisabled() + { + using var client = CreateClient(); + var cookies = await SignInAsync(client); + + await using var connection = CreateHubConnection(cookies, AllowedOrigin); + var closedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + connection.Closed += ex => + { + closedTcs.TrySetResult(ex); + return Task.CompletedTask; + }; + await connection.StartAsync(); + + // 20 KiB of payload in a single argument is well over the 16 KiB cap (MaximumReceiveMessageSize / + // ApplicationMaxBufferSize, both set in Program.cs) — sent against the existing ReportActivity method + // (which normally takes no arguments) purely to force an oversized wire frame; the cap is enforced by + // the hub protocol parser before argument binding, so the extra argument never needs to be consumed. + var oversizedPayload = new string('a', 20 * 1024); + + // SendAsync is fire-and-forget (no completion is expected for an over-cap frame the server can't even + // parse), so the assertion is on the connection being torn down, not on an exception from this call. + try + { + await connection.SendAsync("ReportActivity", oversizedPayload); + } + catch + { + // Some transports surface the rejection synchronously as a thrown exception instead of only via + // Closed — either outcome proves the cap is enforced, so this is deliberately swallowed here. + } + + var completed = await Task.WhenAny(closedTcs.Task, Task.Delay(TimeSpan.FromSeconds(10))); + + completed.Should().Be(closedTcs.Task, + "an over-cap message must cause the connection to be torn down, proving MaximumReceiveMessageSize " + + "is enforced rather than disabled"); + + // Proof the cap was never globally disabled elsewhere: a fresh, well-formed connection against the same + // running host still connects and calls a real method successfully right after the oversized rejection. + using var otherClient = CreateClient(); + var otherCookies = await SignInAsync(otherClient); + await using var otherConnection = CreateHubConnection(otherCookies, AllowedOrigin); + await otherConnection.StartAsync(); + await otherConnection.InvokeAsync("ReportActivity"); + otherConnection.State.Should().Be(HubConnectionState.Connected); + } + + // ── 6. Chat hub happy path (M07-B2) ───────────────────────────────────────── + + /// docs/specs/module-07-realtime-presence-chat-spec.md Test Matrix: "Hub happy path: connect with + /// cookie → SubscribeLobby → receive LobbyChanged → SendLobbyMessage → ChatMessageCreated." The lobby-create + /// itself is what fires the LobbyChanged the spec asks for (see LobbyRealtimeHandler); SendLobbyMessage is + /// invoked only after that first hint has actually been observed, so the ordering in the assertion mirrors the + /// spec's own phrasing rather than just proving the two events arrive at some point. + [Fact] + public async Task SendLobbyMessage_FansOutChatMessageCreated_ToSubscribedConnections() + { + using var host = CreateClient(); + await SignInAsync(host); + await SeedCatalogAsync(); + var lobbyId = await CreatePublicLobbyAsync(host); + + using var listenerClient = CreateClient(); + var listenerCookies = await SignInAsync(listenerClient); + + await using var connection = CreateHubConnection(listenerCookies, AllowedOrigin); + + var lobbyChangedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + connection.On("LobbyChanged", (_, revision, _) => lobbyChangedTcs.TrySetResult(revision)); + + var chatMessageTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + connection.On("ChatMessageCreated", (_, message) => chatMessageTcs.TrySetResult(message)); + + await connection.StartAsync(); + await connection.InvokeAsync("SubscribeLobby", lobbyId); + + // The listener joining the lobby is what fires the LobbyChanged hint this connection must observe before + // SendLobbyMessage is invoked, proving SubscribeLobby actually took effect first (Lobbies_Join: a + // Public+Open lobby can be joined by naming its id directly, no code/link token needed). + var join = await listenerClient.PostAsJsonAsync("/api/lobbies/join", new { LobbyId = lobbyId }); + join.EnsureSuccessStatusCode(); + + // The outbox dispatcher (D3) polls on a 5s default interval (OutboxOptions.Interval) rather than pushing + // synchronously from the join request, so this window must comfortably clear a full cycle plus dispatch. + var lobbyChangedCompleted = await Task.WhenAny(lobbyChangedTcs.Task, Task.Delay(TimeSpan.FromSeconds(15))); + lobbyChangedCompleted.Should().Be(lobbyChangedTcs.Task, + "SubscribeLobby must have taken effect before SendLobbyMessage is invoked"); + + var clientCommandId = Guid.NewGuid(); + var sendResult = await connection.InvokeAsync( + "SendLobbyMessage", lobbyId, "hello from the hub happy path", clientCommandId); + + var sentMessage = sendResult.GetProperty("message"); + sentMessage.GetProperty("body").GetString().Should().Be("hello from the hub happy path"); + sentMessage.GetProperty("deleted").GetBoolean().Should().BeFalse(); + var sentMessageId = sentMessage.GetProperty("id").GetGuid(); + + var chatCompleted = await Task.WhenAny(chatMessageTcs.Task, Task.Delay(TimeSpan.FromSeconds(10))); + chatCompleted.Should().Be(chatMessageTcs.Task, "the subscribed connection must receive ChatMessageCreated"); + + var broadcastMessage = await chatMessageTcs.Task; + broadcastMessage.GetProperty("id").GetGuid().Should().Be(sentMessageId); + broadcastMessage.GetProperty("lobbyId").GetGuid().Should().Be(lobbyId); + broadcastMessage.GetProperty("body").GetString().Should().Be("hello from the hub happy path"); + broadcastMessage.GetProperty("deleted").GetBoolean().Should().BeFalse(); + broadcastMessage.GetProperty("sender").GetProperty("userId").GetGuid().Should().NotBeEmpty(); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private HttpClient CreateClient() + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + HandleCookies = true + }); + client.DefaultRequestHeaders.Add("X-Requested-With", "XMLHttpRequest"); + return client; + } + + /// + /// Registers + logs in a fresh account on the given client and returns a raw Cookie request-header + /// value (e.g. "access_token=...; refresh_token=...") built directly from the login response's + /// Set-Cookie headers, for use as ["Cookie"] on the hub connection. + /// + /// Deliberately NOT : SignalR's client only applies that + /// to the internal it constructs and passes + /// *into* — a factory that (as ours does here, + /// to route onto ) returns a different handler entirely + /// discards that CookieContainer silently (confirmed by a first run of this file: every connect attempt got a + /// 401 on negotiate despite a populated CookieContainer). Setting the header directly is honored by every + /// outgoing request the transport makes (negotiate, poll, send) — the same mechanism already proven to work + /// for the Origin header below. Cookie values are JWTs (base64url), so no escaping is needed. + /// + private static async Task SignInAsync(HttpClient client) + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + var email = $"rt-{suffix}@example.com"; + var username = $"rt{suffix}"; + + var register = await client.PostAsJsonAsync("/api/auth/register", new + { + Username = username, + Email = email, + Password = TestPassword, + ConfirmPassword = TestPassword, + CaptchaToken = "test-captcha-token" + }); + register.EnsureSuccessStatusCode(); + + var login = await client.PostAsJsonAsync("/api/auth/login", new + { + EmailOrUsername = email, + Password = TestPassword, + CaptchaToken = "test-captcha-token" + }); + login.EnsureSuccessStatusCode(); + + var pairs = login.Headers.GetValues("Set-Cookie") + .Select(setCookie => setCookie.Split(';')[0]) + .Where(nameValue => nameValue.IndexOf('=') > 0); + return string.Join("; ", pairs); + } + + private static async Task CreatePrivateLobbyAsync(HttpClient host) + { + var response = await host.PostAsJsonAsync("/api/lobbies", new + { + GameSlug = "chess-lite", + CapabilityVersion = 1, + Privacy = "Private", + MaxPlayers = 4, + TimeControlId = "blitz-3-2", + Rated = false, + Region = "eu-west", + SpectatorPolicy = "Anyone", + TieBreakRuleId = "none", + AiFillRequested = false + }); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + } + + /// Unlike , produces a lobby a non-member can actually join via + /// the REST join endpoint (LobbiesService only allows joining a lobby that is + /// Privacy == Public && State == Open) — needed so a second connection can legitimately become a + /// member and observe its own LobbyChanged hint before SendLobbyMessage. + private static async Task CreatePublicLobbyAsync(HttpClient host) + { + var response = await host.PostAsJsonAsync("/api/lobbies", new + { + GameSlug = "chess-lite", + CapabilityVersion = 1, + Privacy = "Public", + MaxPlayers = 4, + TimeControlId = "blitz-3-2", + Rated = false, + Region = "eu-west", + SpectatorPolicy = "Anyone", + TieBreakRuleId = "none", + AiFillRequested = false + }); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + } + + private async Task SeedCatalogAsync() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (!await db.Games.AnyAsync(g => g.Slug == "chess-lite")) + { + db.Games.Add(Game.Create( + slug: "chess-lite", name: "Chess Lite", summary: "A streamlined chess experience.", + rulesSummary: "Chess Lite wins by checkmate.", difficulty: GameDifficulty.Medium, + estimatedDurationMinMinutes: 10, estimatedDurationMaxMinutes: 20, + minPlayers: 2, maxPlayers: 4, initialLifecycle: GameLifecycle.Available, + featuredRank: null, sortOrder: 1, artToken: "chess-lite", + artColorA: "#9B51E0", artColorB: "#2D9CDB", artAltText: "Chess Lite abstract game artwork", + manifestVersion: "2026.1", category: "strategy", + tags: new[] { "classic" }, + modes: new[] { "multiplayer", "cooperative" })); + await db.SaveChangesAsync(); + } + + if (!await db.GameCapabilityProfiles.AnyAsync(p => p.GameSlug == "chess-lite" && p.CapabilityVersion == 1)) + { + db.GameCapabilityProfiles.Add(GameCapabilityProfile.Create( + "chess-lite", 1, minPlayers: 2, maxPlayers: 4, + allowedModes: new[] { "multiplayer", "cooperative" }, + timeControls: new[] { "blitz-3-2", "rapid-10-0", "untimed" }, + tieBreakRules: new[] { "none", "sudden-death" }, + spectatorPolicies: new[] { "Anyone", "FriendsOnly", "Disabled" }, + ratedEligible: false, aiFillEligible: false, + manifestVersion: "2026.1")); + await db.SaveChangesAsync(); + } + } + + private HubConnection CreateHubConnection(string cookieHeader, string origin) => + new HubConnectionBuilder() + .WithUrl(new Uri("https://localhost" + HubPath), options => + { + options.HttpMessageHandlerFactory = _ => _factory.Server.CreateHandler(); + options.Transports = HttpTransportType.LongPolling; + options.Headers["Origin"] = origin; + options.Headers["Cookie"] = cookieHeader; + }) + .Build(); + + public void Dispose() => _factory.Dispose(); +} diff --git a/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj b/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj index bf598fa..9c80c33 100644 --- a/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj +++ b/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj @@ -13,6 +13,7 @@ + diff --git a/tests/SimPle.UnitTests/Auth/AccountSecurityTests.cs b/tests/SimPle.UnitTests/Auth/AccountSecurityTests.cs index b14f086..87ccf87 100644 --- a/tests/SimPle.UnitTests/Auth/AccountSecurityTests.cs +++ b/tests/SimPle.UnitTests/Auth/AccountSecurityTests.cs @@ -22,6 +22,7 @@ public sealed class AccountSecurityTests private readonly IEmailService _emailService = Substitute.For(); private readonly IGoogleTokenValidationService _googleValidator = Substitute.For(); private readonly IRevokedJtiStore _revokedJtis = Substitute.For(); + private readonly IRealtimeConnectionCloser _realtimeCloser = Substitute.For(); private readonly AuthService _service; public AccountSecurityTests() @@ -35,6 +36,7 @@ public AccountSecurityTests() _users, _tokens, _verificationTokens, _resetTokens, _hasher, _tokenService, _emailService, _googleValidator, _revokedJtis, + _realtimeCloser, Options.Create(new AuthOptions { RefreshTokenExpiryDays = 7, MaxFailedLoginAttempts = 10, LockoutDurationMinutes = 15 }), Options.Create(new EmailOptions { AppUrl = "http://localhost:3000", SmtpHost = "smtp.test", Password = "x" }), NullLogger.Instance); diff --git a/tests/SimPle.UnitTests/Auth/AuthServiceTests.cs b/tests/SimPle.UnitTests/Auth/AuthServiceTests.cs index 69c31e4..254bdf6 100644 --- a/tests/SimPle.UnitTests/Auth/AuthServiceTests.cs +++ b/tests/SimPle.UnitTests/Auth/AuthServiceTests.cs @@ -23,6 +23,7 @@ public sealed class AuthServiceTests private readonly IEmailService _emailService = Substitute.For(); private readonly IGoogleTokenValidationService _googleValidator = Substitute.For(); private readonly IRevokedJtiStore _revokedJtis = Substitute.For(); + private readonly IRealtimeConnectionCloser _realtimeCloser = Substitute.For(); private readonly AuthService _service; public AuthServiceTests() @@ -44,6 +45,7 @@ public AuthServiceTests() _emailService, _googleValidator, _revokedJtis, + _realtimeCloser, Options.Create(new AuthOptions { RefreshTokenExpiryDays = 7, @@ -295,6 +297,30 @@ await _tokens.Received(1).RevokeAllByUserIdAsync( user.Id, "Logout all", Arg.Any()); } + /// + /// Regression test for the Module 7 fix (docs/specs/module-07-realtime-presence-chat-spec.md): before this + /// fix, LogoutAsync revoked only the refresh-token row, never the session family — so a session logged out via + /// this path could still authenticate with a not-yet-expired access token cookie until it expired naturally + /// (LogoutAllAsync/RevokeSessionAsync already revoked the family; LogoutAsync did not). This also closes any + /// live realtime connections proactively, since a realtime hub connection would otherwise survive "logout" + /// indefinitely (see "the load-bearing rule" in the spec). + /// + [Fact] + public async Task LogoutAsync_RevokesSessionFamilyAndClosesRealtimeConnections() + { + var user = ExistingUser(); + var token = RefreshToken.Create(user.Id, "hash-old-token", Guid.NewGuid(), + DateTime.UtcNow.AddDays(1), "", null); + _tokens.GetByHashAsync("hash-old-token", Arg.Any()).Returns(token); + + await _service.LogoutAsync("old-token"); + + token.IsRevoked.Should().BeTrue(); + _revokedJtis.Received(1).Revoke(token.FamilyId.ToString(), Arg.Any()); + await _realtimeCloser.Received(1).CloseUserConnectionsAsync( + user.Id, "auth.session_revoked", Arg.Any()); + } + // ── GetCurrentUser ──────────────────────────────────────────────────────── [Fact] diff --git a/tests/SimPle.UnitTests/Chat/ChatBodyNormalizerTests.cs b/tests/SimPle.UnitTests/Chat/ChatBodyNormalizerTests.cs new file mode 100644 index 0000000..6a6ef33 --- /dev/null +++ b/tests/SimPle.UnitTests/Chat/ChatBodyNormalizerTests.cs @@ -0,0 +1,159 @@ +using FluentAssertions; +using SimPle.Application.Chat; +using Xunit; + +namespace SimPle.UnitTests.Chat; + +/// Test matrix source: 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." +public sealed class ChatBodyNormalizerTests +{ + [Fact] + public void Normalize_CollapsesCrLfToLf() + { + var result = ChatBodyNormalizer.Normalize("line one\r\nline two"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("line one\nline two"); + } + + [Fact] + public void Normalize_CollapsesLoneCrToLf() + { + var result = ChatBodyNormalizer.Normalize("line one\rline two"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("line one\nline two"); + } + + [Fact] + public void Normalize_KeepsInternalLf() + { + var result = ChatBodyNormalizer.Normalize("line one\nline two"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("line one\nline two"); + } + + [Fact] + public void Normalize_TrimsOuterUnicodeWhitespaceOnly() + { + // U+00A0 (NBSP) and U+2003 (EM SPACE) are Unicode whitespace but not ASCII space. + var result = ChatBodyNormalizer.Normalize("   hello world \t\n"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("hello world"); + } + + [Fact] + public void Normalize_AppliesNfcNormalization() + { + // "e" + combining acute accent (U+0065 U+0301) normalizes to precomposed U+00E9 ("é"). + var decomposed = "é"; + var result = ChatBodyNormalizer.Normalize(decomposed); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("é"); + } + + [Fact] + public void Normalize_RejectsNull() + { + var result = ChatBodyNormalizer.Normalize(null); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.InvalidBody); + } + + [Fact] + public void Normalize_RejectsZeroScalarsAfterTrim() + { + var result = ChatBodyNormalizer.Normalize(" \t "); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.InvalidBody); + } + + [Fact] + public void Normalize_AcceptsSingleScalar() + { + var result = ChatBodyNormalizer.Normalize("a"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("a"); + } + + [Fact] + public void Normalize_Accepts1000Scalars() + { + var body = new string('a', 1000); + var result = ChatBodyNormalizer.Normalize(body); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveLength(1000); + } + + [Fact] + public void Normalize_Rejects1001Scalars() + { + var body = new string('a', 1001); + var result = ChatBodyNormalizer.Normalize(body); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.InvalidBody); + } + + [Fact] + public void Normalize_CountsAstralPlaneScalarsCorrectly_NotUtf16Units() + { + // U+1F600 (grinning face emoji) is one Unicode scalar but two UTF-16 code units (a surrogate pair). + // 999 'a' chars (999 scalars) + one astral emoji (1 scalar) = 1000 scalars, which must be accepted even + // though the raw string.Length (UTF-16 code unit count) is 1001. + var body = new string('a', 999) + "\U0001F600"; + body.Length.Should().Be(1001); // sanity: confirms the surrogate pair inflates UTF-16 length + + var result = ChatBodyNormalizer.Normalize(body); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(body); + } + + [Fact] + public void Normalize_RejectsAstralPlaneScalarOverflow() + { + // 1000 'a' chars (1000 scalars) + one astral emoji (1 more scalar) = 1001 scalars: must reject. + var body = new string('a', 1000) + "\U0001F600"; + + var result = ChatBodyNormalizer.Normalize(body); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.InvalidBody); + } + + [Theory] + [InlineData((char)0x00)] // NUL, C0 + [InlineData((char)0x01)] // C0 + [InlineData((char)0x1F)] // C0, last before LF's own 0x0A + [InlineData((char)0x7F)] // DEL + [InlineData((char)0x80)] // C1 + [InlineData((char)0x9F)] // C1, last C1 control + public void Normalize_RejectsC0AndC1Controls(char control) + { + var body = $"hello{control}world"; + + var result = ChatBodyNormalizer.Normalize(body); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.InvalidBody); + } + + [Fact] + public void Normalize_AllowsInternalLfControlCharacter() + { + var result = ChatBodyNormalizer.Normalize("hello\nworld"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("hello\nworld"); + } +} diff --git a/tests/SimPle.UnitTests/Chat/ChatProfanityFilterTests.cs b/tests/SimPle.UnitTests/Chat/ChatProfanityFilterTests.cs new file mode 100644 index 0000000..64075a5 --- /dev/null +++ b/tests/SimPle.UnitTests/Chat/ChatProfanityFilterTests.cs @@ -0,0 +1,64 @@ +using FluentAssertions; +using Microsoft.Extensions.Options; +using SimPle.Application.Chat; +using Xunit; + +namespace SimPle.UnitTests.Chat; + +/// Test matrix source: docs/specs/module-07-realtime-presence-chat-spec.md, "Test Matrix" — "Profanity +/// filter: deny-list match rejects and does not persist; versioned config is read server-side only." +public sealed class ChatProfanityFilterTests +{ + private static ChatProfanityFilter CreateFilter(params string[] terms) => + new(Options.Create(new ProfanityOptions { Terms = terms })); + + [Fact] + public void IsProfane_MatchesConfiguredTerm_CaseInsensitive() + { + var filter = CreateFilter("badword"); + + filter.IsProfane("this is a BadWord in a sentence").Should().BeTrue(); + } + + [Fact] + public void IsProfane_DoesNotMatchSubstringOfAnotherWord() + { + var filter = CreateFilter("ass"); + + // "class" contains "ass" as a substring but not as a whole word. + filter.IsProfane("this is my class assignment").Should().BeFalse(); + } + + [Fact] + public void IsProfane_MatchesWholeWordWithPunctuationBoundary() + { + var filter = CreateFilter("badword"); + + filter.IsProfane("badword!").Should().BeTrue(); + } + + [Fact] + public void IsProfane_ReturnsFalseWhenNoTermsConfigured() + { + var filter = CreateFilter(); + + filter.IsProfane("anything at all").Should().BeFalse(); + } + + [Fact] + public void IsProfane_ReturnsFalseForCleanMessage() + { + var filter = CreateFilter("badword", "worseword"); + + filter.IsProfane("hello, how are you today?").Should().BeFalse(); + } + + [Fact] + public void IsProfane_IgnoresBlankConfiguredTerms() + { + var filter = CreateFilter("", " ", "badword"); + + filter.IsProfane("this has a badword in it").Should().BeTrue(); + filter.IsProfane("this is clean").Should().BeFalse(); + } +} diff --git a/tests/SimPle.UnitTests/Chat/ChatServiceTests.cs b/tests/SimPle.UnitTests/Chat/ChatServiceTests.cs new file mode 100644 index 0000000..7f9edea --- /dev/null +++ b/tests/SimPle.UnitTests/Chat/ChatServiceTests.cs @@ -0,0 +1,467 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using SimPle.Application.Chat; +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.Common; +using SimPle.Domain.Users; +using SimPle.UnitTests.Lobbies; +using Xunit; + +namespace SimPle.UnitTests.Chat; + +/// +/// Command/query-layer tests for : idempotent send, author-only tombstone delete, and +/// keyset history pagination (docs/specs/module-07-realtime-presence-chat-spec.md). +/// +public sealed class ChatServiceTests +{ + private static readonly DateTime T0 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private readonly IChatRepository _chat = Substitute.For(); + private readonly IRealtimeScopeAuthorizer _authorizer = Substitute.For(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly IRealtimeRateLimiter _rateLimiter = Substitute.For(); + private readonly IChatProfanityFilter _profanity = Substitute.For(); + private readonly IFileStorageService _storage = Substitute.For(); + private readonly ILobbyRepository _lobbies = Substitute.For(); + private readonly IFriendRepository _friends = Substitute.For(); + private readonly FakeTimeProvider _clock = new(T0); + + private readonly Guid _lobbyId = Guid.NewGuid(); + private readonly Guid _actorId = Guid.NewGuid(); + private readonly Guid _clientCommandId = Guid.NewGuid(); + + private readonly ChatService _sut; + + public ChatServiceTests() + { + _authorizer.ScopeKind.Returns(RealtimeEnvelope.LobbyScope); + _authorizer.AuthorizeAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(RealtimeScopeAuthorizationResult.Allow()); + + _rateLimiter.TryAcquireMessage(Arg.Any()).Returns(true); + _profanity.IsProfane(Arg.Any()).Returns(false); + + _chat.FindByClientCommandIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ChatMessage?)null); + _chat.AddAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + _chat.GetSendersAsync(Arg.Any>(), Arg.Any()) + .Returns(call => Roster(call.Arg>())); + + // The actor is the lobby's host (and only joined member) by default, so every recipient-filtering call + // resolves to just the sender unless a test overrides this to add other members/blocks. + _lobbies.GetByIdAsync(_lobbyId, Arg.Any()) + .Returns(LobbyTestFactory.Open(_actorId, T0)); + + _sut = new ChatService( + _chat, new[] { _authorizer }, _notifier, _rateLimiter, _profanity, _storage, + Options.Create(new StorageOptions()), _lobbies, _friends, _clock, + NullLogger.Instance); + } + + // ── Send ───────────────────────────────────────────────────────────────── + + [Fact] + public async Task Send_Authorized_PersistsAndNotifies() + { + var result = await _sut.SendAsync(_actorId, _lobbyId, "hello world", _clientCommandId); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Body.Should().Be("hello world"); + result.Value.Deleted.Should().BeFalse(); + result.Value.LobbyId.Should().Be(_lobbyId); + result.Value.Sender.UserId.Should().Be(_actorId); + + await _chat.Received(1).AddAsync( + Arg.Is(m => m.SenderId == _actorId && m.ScopeId == _lobbyId && m.Body == "hello world"), + Arg.Any()); + await _notifier.Received(1).NotifyChatMessageCreatedAsync( + _lobbyId, Arg.Is>(r => r.Contains(_actorId)), + Arg.Is(dto => dto.Body == "hello world"), Arg.Any()); + } + + [Fact] + public async Task Send_NotAuthorized_ReturnsChatNotFoundAndNeverChecksRateLimitOrProfanity() + { + _authorizer.AuthorizeAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(RealtimeScopeAuthorizationResult.Deny("Lobbies.NotFound")); + + var result = await _sut.SendAsync(_actorId, _lobbyId, "hello", _clientCommandId); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.NotFound); + _rateLimiter.DidNotReceive().TryAcquireMessage(Arg.Any()); + _profanity.DidNotReceive().IsProfane(Arg.Any()); + await _chat.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Send_DuplicateClientCommandId_ReturnsOriginalWithoutRateLimitOrProfanityCheck() + { + var original = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "already sent", _clientCommandId, T0); + _chat.FindByClientCommandIdAsync(_actorId, _clientCommandId, Arg.Any()) + .Returns(original); + + var result = await _sut.SendAsync(_actorId, _lobbyId, "a different body this time", _clientCommandId); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Body.Should().Be("already sent"); + result.Value.Id.Should().Be(original.Id); + + _rateLimiter.DidNotReceive().TryAcquireMessage(Arg.Any()); + _profanity.DidNotReceive().IsProfane(Arg.Any()); + await _chat.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + await _notifier.DidNotReceive().NotifyChatMessageCreatedAsync( + Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Send_RateLimited_ReturnsRateLimitExceededWithRetryAfter() + { + _rateLimiter.TryAcquireMessage(_actorId).Returns(false); + + var result = await _sut.SendAsync(_actorId, _lobbyId, "hello", _clientCommandId); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.RateLimitExceeded); + result.Error.RetryAfterUtc.Should().Be(T0.AddSeconds(5)); + await _chat.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Send_InvalidBody_ReturnsInvalidBodyAndNeverChecksProfanity() + { + var result = await _sut.SendAsync(_actorId, _lobbyId, " ", _clientCommandId); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.InvalidBody); + _profanity.DidNotReceive().IsProfane(Arg.Any()); + await _chat.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Send_ProfaneBody_ReturnsProfanityRejected() + { + _profanity.IsProfane(Arg.Any()).Returns(true); + + var result = await _sut.SendAsync(_actorId, _lobbyId, "bad word", _clientCommandId); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.ProfanityRejected); + await _chat.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Send_SenderMissingFromLookup_RendersTombstone() + { + _chat.GetSendersAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary()); + + var result = await _sut.SendAsync(_actorId, _lobbyId, "hello", _clientCommandId); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Sender.UserId.Should().Be(Guid.Empty); + result.Value.Sender.DisplayName.Should().Be("Chat participant"); + result.Value.Sender.AvatarUrl.Should().BeNull(); + } + + /// M07-001 regression: a co-member the sender has blocked (or who has blocked the sender) must + /// never appear in the delivery list, even though they are still a joined lobby member — a plain group + /// broadcast would otherwise leak the sender's chat activity to them. + [Fact] + public async Task Send_LobbyHasBlockedCoMember_ExcludesBlockedMemberFromRecipients() + { + var blockedMemberId = Guid.NewGuid(); + var unrelatedMemberId = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(_actorId, T0); + lobby.Join(blockedMemberId, T0); + lobby.Join(unrelatedMemberId, T0); + _lobbies.GetByIdAsync(_lobbyId, Arg.Any()).Returns(lobby); + + _friends.IsBlockedInEitherDirectionAsync(_actorId, blockedMemberId, Arg.Any()) + .Returns(true); + _friends.IsBlockedInEitherDirectionAsync(_actorId, unrelatedMemberId, Arg.Any()) + .Returns(false); + + var result = await _sut.SendAsync(_actorId, _lobbyId, "hello world", _clientCommandId); + + result.IsSuccess.Should().BeTrue(); + await _notifier.Received(1).NotifyChatMessageCreatedAsync( + _lobbyId, + Arg.Is>(r => + r.Contains(_actorId) && r.Contains(unrelatedMemberId) && !r.Contains(blockedMemberId)), + Arg.Any(), Arg.Any()); + } + + // ── Delete ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Delete_AuthorDeletesOwnMessage_TombstonesAndNotifies() + { + var message = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "goodbye", Guid.NewGuid(), T0); + _chat.GetByIdAsync(message.Id, Arg.Any()).Returns(message); + + _clock.Advance(TimeSpan.FromMinutes(1)); + var result = await _sut.DeleteAsync(_actorId, message.Id); + + result.IsSuccess.Should().BeTrue(); + message.IsDeleted.Should().BeTrue(); + await _chat.Received(1).SaveAsync(Arg.Any()); + await _notifier.Received(1).NotifyChatMessageDeletedAsync( + _lobbyId, Arg.Is>(r => r.Contains(_actorId)), + message.Id, message.DeletedAtUtc!.Value, Arg.Any()); + } + + [Fact] + public async Task Delete_NonAuthor_ReturnsForbiddenAndNeverSavesOrNotifies() + { + var author = Guid.NewGuid(); + var message = ChatMessage.Create(ChatScope.Lobby, _lobbyId, author, "not yours", Guid.NewGuid(), T0); + _chat.GetByIdAsync(message.Id, Arg.Any()).Returns(message); + + var result = await _sut.DeleteAsync(_actorId, message.Id); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.Forbidden); + message.IsDeleted.Should().BeFalse(); + await _chat.DidNotReceive().SaveAsync(Arg.Any()); + await _notifier.DidNotReceive().NotifyChatMessageDeletedAsync( + Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Delete_MessageNotFound_ReturnsChatNotFound() + { + _chat.GetByIdAsync(Arg.Any(), Arg.Any()).Returns((ChatMessage?)null); + + var result = await _sut.DeleteAsync(_actorId, Guid.NewGuid()); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.NotFound); + } + + [Fact] + public async Task Delete_NotAuthorizedForScope_ReturnsChatNotFoundEvenForTheAuthor() + { + var message = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "hi", Guid.NewGuid(), T0); + _chat.GetByIdAsync(message.Id, Arg.Any()).Returns(message); + _authorizer.AuthorizeAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(RealtimeScopeAuthorizationResult.Deny("Lobbies.NotFound")); + + var result = await _sut.DeleteAsync(_actorId, message.Id); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.NotFound); + await _chat.DidNotReceive().SaveAsync(Arg.Any()); + } + + [Fact] + public async Task Delete_RetriedDeleteOnAlreadyDeletedMessage_IsIdempotentAndReplaysOriginalTimestamp() + { + var message = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "bye", Guid.NewGuid(), T0); + message.Delete(_actorId, T0.AddMinutes(1)); + var originalDeletedAt = message.DeletedAtUtc!.Value; + _chat.GetByIdAsync(message.Id, Arg.Any()).Returns(message); + + _clock.Advance(TimeSpan.FromHours(1)); + var result = await _sut.DeleteAsync(_actorId, message.Id); + + result.IsSuccess.Should().BeTrue(); + message.DeletedAtUtc.Should().Be(originalDeletedAt); + await _notifier.Received(1).NotifyChatMessageDeletedAsync( + _lobbyId, Arg.Is>(r => r.Contains(_actorId)), + message.Id, originalDeletedAt, Arg.Any()); + } + + // ── History ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetHistory_DefaultLimitAppliedWhenNull() + { + _chat.GetHistoryPageAsync( + ChatScope.Lobby, _lobbyId, ChatHistoryDirection.Before, null, null, 30, Arg.Any()) + .Returns(new List()); + + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, null, null); + + result.IsSuccess.Should().BeTrue(); + await _chat.Received(1).GetHistoryPageAsync( + ChatScope.Lobby, _lobbyId, ChatHistoryDirection.Before, null, null, 30, Arg.Any()); + } + + [Theory] + [InlineData(0)] + [InlineData(51)] + public async Task GetHistory_LimitOutOfRange_ReturnsValidationFailedWithoutQueryingRepository(int limit) + { + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, null, limit); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.ValidationFailed); + await _chat.DidNotReceive().GetHistoryPageAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GetHistory_NotAuthorized_ReturnsChatNotFound() + { + _authorizer.AuthorizeAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(RealtimeScopeAuthorizationResult.Deny("Lobbies.NotFound")); + + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, null, 30); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.NotFound); + } + + [Fact] + public async Task GetHistory_InvalidCursor_ReturnsInvalidCursor() + { + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, "not-a-cursor", 30); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.InvalidCursor); + } + + [Fact] + public async Task GetHistory_BeforeDirection_FullPage_NextCursorDerivedFromOldestRow() + { + var rows = ThreeMessagesAscending(); + _chat.GetHistoryPageAsync( + ChatScope.Lobby, _lobbyId, ChatHistoryDirection.Before, null, null, 3, Arg.Any()) + .Returns(rows); + + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, null, 3); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Items.Should().HaveCount(3); + result.Value.NextCursor.Should().Be(Cursor.EncodeTimeId(rows[0].CreatedAt, rows[0].Id)); + } + + [Fact] + public async Task GetHistory_AfterDirection_FullPage_NextCursorDerivedFromNewestRow() + { + var rows = ThreeMessagesAscending(); + _chat.GetHistoryPageAsync( + ChatScope.Lobby, _lobbyId, ChatHistoryDirection.After, null, null, 3, Arg.Any()) + .Returns(rows); + + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.After, null, 3); + + result.IsSuccess.Should().BeTrue(); + result.Value!.NextCursor.Should().Be(Cursor.EncodeTimeId(rows[^1].CreatedAt, rows[^1].Id)); + } + + [Fact] + public async Task GetHistory_PartialPage_NoNextCursor() + { + var rows = ThreeMessagesAscending(); + _chat.GetHistoryPageAsync( + ChatScope.Lobby, _lobbyId, ChatHistoryDirection.Before, null, null, 30, Arg.Any()) + .Returns(rows); + + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, null, 30); + + result.IsSuccess.Should().BeTrue(); + result.Value!.NextCursor.Should().BeNull(); + } + + [Fact] + public async Task GetHistory_DeletedRow_ProjectsNullBodyAndDeletedTrue() + { + var deleted = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "secret", Guid.NewGuid(), T0); + deleted.Delete(_actorId, T0.AddMinutes(1)); + _chat.GetHistoryPageAsync( + ChatScope.Lobby, _lobbyId, ChatHistoryDirection.Before, null, null, 30, Arg.Any()) + .Returns(new List { deleted }); + + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, null, 30); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Items.Single().Body.Should().BeNull(); + result.Value.Items.Single().Deleted.Should().BeTrue(); + } + + [Fact] + public async Task GetHistory_SenderMissingFromLookup_RendersTombstone() + { + var row = ChatMessage.Create(ChatScope.Lobby, _lobbyId, Guid.NewGuid(), "hi", Guid.NewGuid(), T0); + _chat.GetHistoryPageAsync( + ChatScope.Lobby, _lobbyId, ChatHistoryDirection.Before, null, null, 30, Arg.Any()) + .Returns(new List { row }); + _chat.GetSendersAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary()); + + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, null, 30); + + var sender = result.Value!.Items.Single().Sender; + sender.UserId.Should().Be(Guid.Empty); + sender.DisplayName.Should().Be("Chat participant"); + } + + /// Post-frontend security review regression: GetHistoryAsync had no block filtering at all + /// (unlike SendAsync's GetDeliverableRecipientsAsync path fixed for M07-001), so a blocked + /// co-member's messages remained visible via REST history on every page load/resync. A message from a + /// blocked sender must be excluded; the actor's own message and an unrelated sender's message stay + /// visible. + [Fact] + public async Task GetHistory_RowFromBlockedSender_IsExcludedFromResults() + { + var blockedSenderId = Guid.NewGuid(); + var unrelatedSenderId = Guid.NewGuid(); + var ownMessage = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "mine", Guid.NewGuid(), T0); + var blockedMessage = ChatMessage.Create( + ChatScope.Lobby, _lobbyId, blockedSenderId, "from blocked user", Guid.NewGuid(), T0.AddSeconds(1)); + var unrelatedMessage = ChatMessage.Create( + ChatScope.Lobby, _lobbyId, unrelatedSenderId, "from unrelated user", Guid.NewGuid(), T0.AddSeconds(2)); + _chat.GetHistoryPageAsync( + ChatScope.Lobby, _lobbyId, ChatHistoryDirection.Before, null, null, 30, Arg.Any()) + .Returns(new List { ownMessage, blockedMessage, unrelatedMessage }); + + _friends.IsBlockedInEitherDirectionAsync(_actorId, blockedSenderId, Arg.Any()) + .Returns(true); + _friends.IsBlockedInEitherDirectionAsync(_actorId, unrelatedSenderId, Arg.Any()) + .Returns(false); + + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, null, 30); + + result.IsSuccess.Should().BeTrue(); + var senderIds = result.Value!.Items.Select(i => i.Sender.UserId).ToList(); + senderIds.Should().Contain(_actorId); + senderIds.Should().Contain(unrelatedSenderId); + senderIds.Should().NotContain(blockedSenderId); + result.Value.Items.Should().HaveCount(2); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private List ThreeMessagesAscending() + { + var m1 = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "one", Guid.NewGuid(), T0); + var m2 = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "two", Guid.NewGuid(), T0.AddSeconds(1)); + var m3 = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "three", Guid.NewGuid(), T0.AddSeconds(2)); + return new List { m1, m2, m3 }; + } + + private static User MakeUser(Guid id) + { + var user = User.Create($"user{id:N}"[..12], $"{id:N}@example.com", "hash", "Test Player"); + typeof(Entity).GetProperty(nameof(Entity.Id))!.SetValue(user, id); + return user; + } + + private static IReadOnlyDictionary Roster(IReadOnlyList ids) => + ids.ToDictionary(id => id, MakeUser); +} diff --git a/tests/SimPle.UnitTests/Realtime/LobbyScopeAuthorizerTests.cs b/tests/SimPle.UnitTests/Realtime/LobbyScopeAuthorizerTests.cs new file mode 100644 index 0000000..5e8c80b --- /dev/null +++ b/tests/SimPle.UnitTests/Realtime/LobbyScopeAuthorizerTests.cs @@ -0,0 +1,202 @@ +using FluentAssertions; +using NSubstitute; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Lobbies.Services; +using SimPle.Application.Realtime.Authorization; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Users; +using SimPle.UnitTests.Lobbies; + +namespace SimPle.UnitTests.Realtime; + +/// +/// Authorization matrix for (docs/specs/module-07-realtime-presence-chat- +/// spec.md, "Authorization / Privacy rules"). Every denial must collapse to the exact same +/// code the REST API already uses — existence is never disclosed, so a +/// distinct "forbidden" would itself be the leak. +/// +public sealed class LobbyScopeAuthorizerTests +{ + private readonly ILobbyRepository _lobbies = Substitute.For(); + private readonly IUserRepository _users = Substitute.For(); + private readonly IFriendRepository _friends = Substitute.For(); + private readonly LobbyScopeAuthorizer _authorizer; + + public LobbyScopeAuthorizerTests() + { + _authorizer = new LobbyScopeAuthorizer(_lobbies, _users, _friends); + } + + private static User ActiveUser() => User.Create("user1", "user1@example.com", "hash", "User One"); + + [Fact] + public async Task Subscribe_Member_Allowed() + { + var host = Guid.NewGuid(); + var member = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Private)); + lobby.Join(member, LobbyTestFactory.T0); + SetUpLobby(lobby); + SetUpActor(member); + + var result = await _authorizer.AuthorizeAsync(member, lobby.Id, RealtimeAction.Subscribe); + + result.IsAllowed.Should().BeTrue(); + } + + [Fact] + public async Task Subscribe_NonMemberOfPublicOpenLobby_Allowed() + { + var host = Guid.NewGuid(); + var outsider = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Public)); + SetUpLobby(lobby); + SetUpActor(outsider); + + var result = await _authorizer.AuthorizeAsync(outsider, lobby.Id, RealtimeAction.Subscribe); + + result.IsAllowed.Should().BeTrue(); + } + + [Fact] + public async Task Subscribe_NonMemberOfPrivateLobby_DeniedWithPrivacySafeNotFound() + { + var host = Guid.NewGuid(); + var outsider = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Private)); + SetUpLobby(lobby); + SetUpActor(outsider); + + var result = await _authorizer.AuthorizeAsync(outsider, lobby.Id, RealtimeAction.Subscribe); + + result.IsAllowed.Should().BeFalse(); + result.ErrorCode.Should().Be(LobbyErrors.NotFound); + } + + [Fact] + public async Task Subscribe_UnknownLobby_DeniedWithSamePrivacySafeNotFound() + { + var actor = Guid.NewGuid(); + _lobbies.GetByIdAsync(Arg.Any(), Arg.Any()).Returns((Lobby?)null); + SetUpActor(actor); + + var result = await _authorizer.AuthorizeAsync(actor, Guid.NewGuid(), RealtimeAction.Subscribe); + + result.IsAllowed.Should().BeFalse(); + result.ErrorCode.Should().Be(LobbyErrors.NotFound); + } + + [Fact] + public async Task Send_NonMemberOfPublicOpenLobby_Denied() + { + // Unlike Subscribe, Send/Delete always require membership regardless of public visibility — an + // anonymous observer of a public lobby is never allowed to act inside it. + var host = Guid.NewGuid(); + var outsider = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Public)); + SetUpLobby(lobby); + SetUpActor(outsider); + + var result = await _authorizer.AuthorizeAsync(outsider, lobby.Id, RealtimeAction.Send); + + result.IsAllowed.Should().BeFalse(); + result.ErrorCode.Should().Be(LobbyErrors.NotFound); + } + + [Fact] + public async Task Delete_Member_Allowed() + { + var host = Guid.NewGuid(); + var member = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Private)); + lobby.Join(member, LobbyTestFactory.T0); + SetUpLobby(lobby); + SetUpActor(member); + + var result = await _authorizer.AuthorizeAsync(member, lobby.Id, RealtimeAction.Delete); + + result.IsAllowed.Should().BeTrue(); + } + + [Fact] + public async Task Member_BlockedByHost_Denied() + { + var host = Guid.NewGuid(); + var member = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Private)); + lobby.Join(member, LobbyTestFactory.T0); + SetUpLobby(lobby); + SetUpActor(member); + _friends.IsBlockedInEitherDirectionAsync(member, host, Arg.Any()).Returns(true); + + var result = await _authorizer.AuthorizeAsync(member, lobby.Id, RealtimeAction.Subscribe); + + result.IsAllowed.Should().BeFalse(); + result.ErrorCode.Should().Be(LobbyErrors.NotFound); + } + + [Fact] + public async Task Host_NeverBlockChecksSelf_Allowed() + { + var host = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Private)); + SetUpLobby(lobby); + SetUpActor(host); + + var result = await _authorizer.AuthorizeAsync(host, lobby.Id, RealtimeAction.Subscribe); + + result.IsAllowed.Should().BeTrue(); + await _friends.DidNotReceive().IsBlockedInEitherDirectionAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task SuspendedActor_Denied() + { + var host = Guid.NewGuid(); + var member = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Private)); + lobby.Join(member, LobbyTestFactory.T0); + SetUpLobby(lobby); + + var suspendedUser = ActiveUser(); + suspendedUser.Suspend(null); + _users.GetByIdAsync(member, Arg.Any()).Returns(suspendedUser); + + var result = await _authorizer.AuthorizeAsync(member, lobby.Id, RealtimeAction.Subscribe); + + result.IsAllowed.Should().BeFalse(); + result.ErrorCode.Should().Be(LobbyErrors.NotFound); + } + + [Fact] + public async Task DeletedActor_Denied() + { + var host = Guid.NewGuid(); + var member = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Private)); + lobby.Join(member, LobbyTestFactory.T0); + SetUpLobby(lobby); + _users.GetByIdAsync(member, Arg.Any()).Returns((User?)null); + + var result = await _authorizer.AuthorizeAsync(member, lobby.Id, RealtimeAction.Subscribe); + + result.IsAllowed.Should().BeFalse(); + result.ErrorCode.Should().Be(LobbyErrors.NotFound); + } + + private void SetUpLobby(Lobby lobby) => + _lobbies.GetByIdAsync(lobby.Id, Arg.Any()).Returns(lobby); + + private void SetUpActor(Guid actorUserId) => + _users.GetByIdAsync(actorUserId, Arg.Any()).Returns(ActiveUser()); +} diff --git a/tests/SimPle.UnitTests/Realtime/NullMatchScopeAuthorizerTests.cs b/tests/SimPle.UnitTests/Realtime/NullMatchScopeAuthorizerTests.cs new file mode 100644 index 0000000..7280046 --- /dev/null +++ b/tests/SimPle.UnitTests/Realtime/NullMatchScopeAuthorizerTests.cs @@ -0,0 +1,32 @@ +using FluentAssertions; +using SimPle.Application.Realtime.Authorization; +using SimPle.Application.Realtime.Contracts; + +namespace SimPle.UnitTests.Realtime; + +/// +/// Match-scope realtime does not exist yet (no Module 8) — every action against it must fail closed with +/// realtime.scope_not_available rather than 500ing or silently succeeding. +/// +public sealed class NullMatchScopeAuthorizerTests +{ + private readonly NullMatchScopeAuthorizer _authorizer = new(); + + [Theory] + [InlineData(RealtimeAction.Subscribe)] + [InlineData(RealtimeAction.Send)] + [InlineData(RealtimeAction.Delete)] + public async Task AlwaysDeniesWithScopeNotAvailable(RealtimeAction action) + { + var result = await _authorizer.AuthorizeAsync(Guid.NewGuid(), Guid.NewGuid(), action); + + result.IsAllowed.Should().BeFalse(); + result.ErrorCode.Should().Be(NullMatchScopeAuthorizer.ScopeNotAvailableCode); + } + + [Fact] + public void ScopeKind_IsMatch() + { + _authorizer.ScopeKind.Should().Be(RealtimeEnvelope.MatchScope); + } +} diff --git a/tests/SimPle.UnitTests/Realtime/Outbox/LobbyRealtimeHandlerTests.cs b/tests/SimPle.UnitTests/Realtime/Outbox/LobbyRealtimeHandlerTests.cs new file mode 100644 index 0000000..9e96712 --- /dev/null +++ b/tests/SimPle.UnitTests/Realtime/Outbox/LobbyRealtimeHandlerTests.cs @@ -0,0 +1,265 @@ +using System.Reflection; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Lobbies.Outbox; +using SimPle.Application.Outbox; +using SimPle.Application.Realtime.Contracts; +using SimPle.Application.Realtime.Outbox; +using SimPle.Application.Realtime.Presence; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Outbox; +using SimPle.UnitTests.Lobbies; +using Xunit; + +namespace SimPle.UnitTests.Realtime.Outbox; + +/// +/// : activation-watermark backfill suppression, duplicate-delivery no-op, +/// same-revision sibling-event collapse, revision-gap detection, and the presence side effects (docs/specs/ +/// module-07-realtime-presence-chat-spec.md, "Activation watermark" / Test Matrix). +/// +public sealed class LobbyRealtimeHandlerTests +{ + private static readonly DateTime T0 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private readonly IOutboxActivationStore _activation = Substitute.For(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly IPresenceRegistry _presence = Substitute.For(); + private readonly IPresenceViewerResolver _viewerResolver = Substitute.For(); + private readonly ILobbyRepository _lobbies = Substitute.For(); + private readonly FakeTimeProvider _clock = new(T0); + + private readonly Guid _lobbyId = Guid.NewGuid(); + + private readonly LobbyRealtimeHandler _sut; + + public LobbyRealtimeHandlerTests() + { + // No activation row yet at T0 for any of these tests: watermark = T0, tie-break = null (an empty + // outbox at first activation, per OutboxHandlerActivation's doc comment). + _activation + .GetOrActivateAsync(LobbyRealtimeHandler.Name, Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(OutboxHandlerActivation.Activate(LobbyRealtimeHandler.Name, T0, T0, null)); + + _sut = new LobbyRealtimeHandler( + _activation, _notifier, _presence, _viewerResolver, _lobbies, _clock, NullLogger.Instance); + } + + // ── Watermark suppression ─────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_MessageBeforeWatermark_IsSuppressedWithNoFanOut() + { + var watermarkId = Guid.NewGuid(); + _activation + .GetOrActivateAsync(LobbyRealtimeHandler.Name, Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(OutboxHandlerActivation.Activate(LobbyRealtimeHandler.Name, T0, T0.AddMinutes(5), watermarkId)); + + var message = Message(LobbyOutbox.LobbyMemberJoined, revision: 3, occurredAtUtc: T0.AddMinutes(1)); + + await _sut.HandleAsync(message); + + await _notifier.DidNotReceiveWithAnyArgs().NotifyLobbyChangedAsync(default, default, default!); + await _notifier.DidNotReceiveWithAnyArgs().NotifyResyncRequiredAsync(default, default!, default); + } + + [Fact] + public async Task HandleAsync_MessageAtExactWatermarkInstantAndId_IsSuppressed() + { + var watermarkId = Guid.NewGuid(); + _activation + .GetOrActivateAsync(LobbyRealtimeHandler.Name, Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(OutboxHandlerActivation.Activate(LobbyRealtimeHandler.Name, T0, T0.AddMinutes(5), watermarkId)); + + // Same instant as the watermark and an Id that sorts at-or-before it (itself) must also suppress — + // "at or before", not strictly before. + var message = Message(LobbyOutbox.LobbyMemberJoined, revision: 3, occurredAtUtc: T0.AddMinutes(5), id: watermarkId); + + await _sut.HandleAsync(message); + + await _notifier.DidNotReceiveWithAnyArgs().NotifyLobbyChangedAsync(default, default, default!); + } + + [Fact] + public async Task HandleAsync_MessageAfterWatermark_FansOut() + { + _activation + .GetOrActivateAsync(LobbyRealtimeHandler.Name, Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(OutboxHandlerActivation.Activate(LobbyRealtimeHandler.Name, T0, T0, null)); + + var message = Message(LobbyOutbox.LobbyMemberJoined, revision: 1, occurredAtUtc: T0.AddMinutes(1)); + + await _sut.HandleAsync(message); + + await _notifier.Received(1).NotifyLobbyChangedAsync(_lobbyId, 1, LobbyOutbox.LobbyMemberJoined, Arg.Any()); + } + + // ── Duplicate delivery ────────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_SameMessageDeliveredTwice_SecondDeliveryIsNoOp() + { + var message = Message(LobbyOutbox.LobbyMemberJoined, revision: 4, occurredAtUtc: T0.AddMinutes(1)); + + await _sut.HandleAsync(message); + await _sut.HandleAsync(message); + + await _notifier.Received(1).NotifyLobbyChangedAsync(_lobbyId, 4, LobbyOutbox.LobbyMemberJoined, Arg.Any()); + } + + // ── Revision collapse ──────────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_ThreeEventsAtSameRevision_CollapseToExactlyOneLobbyChanged() + { + // Lobby.Leave legitimately stages MemberLeftV1 + HostTransferredV1 + LobbyClosedV1 at one Revision. + var memberLeft = Message(LobbyOutbox.LobbyMemberLeft, revision: 7, occurredAtUtc: T0.AddMinutes(1)); + var hostTransferred = Message(LobbyOutbox.LobbyHostTransferred, revision: 7, occurredAtUtc: T0.AddMinutes(1)); + var lobbyClosed = Message(LobbyOutbox.LobbyClosed, revision: 7, occurredAtUtc: T0.AddMinutes(1)); + + await _sut.HandleAsync(memberLeft); + await _sut.HandleAsync(hostTransferred); + await _sut.HandleAsync(lobbyClosed); + + await _notifier.ReceivedWithAnyArgs(1).NotifyLobbyChangedAsync(default, default, default!); + await _notifier.Received(1).NotifyLobbyChangedAsync(_lobbyId, 7, LobbyOutbox.LobbyMemberLeft, Arg.Any()); + } + + [Fact] + public async Task HandleAsync_NextRevisionAfterCollapsedBatch_StillFansOut() + { + var atRevision7 = Message(LobbyOutbox.LobbyMemberLeft, revision: 7, occurredAtUtc: T0.AddMinutes(1)); + var atRevision8 = Message(LobbyOutbox.LobbySettingsChanged, revision: 8, occurredAtUtc: T0.AddMinutes(2)); + + await _sut.HandleAsync(atRevision7); + await _sut.HandleAsync(atRevision8); + + await _notifier.Received(1).NotifyLobbyChangedAsync(_lobbyId, 8, LobbyOutbox.LobbySettingsChanged, Arg.Any()); + } + + // ── Gap detection ──────────────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_RevisionGapAfterBaseline_TriggersResyncRequiredNotLobbyChanged() + { + var baseline = Message(LobbyOutbox.LobbyMemberJoined, revision: 1, occurredAtUtc: T0.AddMinutes(1)); + var gapped = Message(LobbyOutbox.LobbyMemberJoined, revision: 3, occurredAtUtc: T0.AddMinutes(2)); + + await _sut.HandleAsync(baseline); + await _sut.HandleAsync(gapped); + + await _notifier.Received(1).NotifyResyncRequiredAsync(_lobbyId, "gap", 3, Arg.Any()); + await _notifier.Received(1).NotifyLobbyChangedAsync(_lobbyId, 1, Arg.Any(), Arg.Any()); + await _notifier.DidNotReceive().NotifyLobbyChangedAsync(_lobbyId, 3, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_FirstSightingOfLobbyAtNonOneRevision_IsNotTreatedAsGap() + { + // No prior baseline for this lobby (e.g. it was created before this process's activation watermark and + // this is simply its next post-watermark mutation) — nothing to gap-detect against. + var message = Message(LobbyOutbox.LobbySettingsChanged, revision: 9, occurredAtUtc: T0.AddMinutes(1)); + + await _sut.HandleAsync(message); + + await _notifier.DidNotReceiveWithAnyArgs().NotifyResyncRequiredAsync(default, default!, default); + await _notifier.Received(1).NotifyLobbyChangedAsync(_lobbyId, 9, LobbyOutbox.LobbySettingsChanged, Arg.Any()); + } + + // ── Presence side effects ──────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_LobbyCreated_SetsHostMembershipAndBroadcastsWhenChanged() + { + var hostUserId = Guid.NewGuid(); + var viewers = new[] { hostUserId }; + _presence.SetLobbyMembership(hostUserId, _lobbyId, true) + .Returns(new PresenceUpdateResult(Changed: true, PresenceStatus.InLobby, Guid.NewGuid(), UserVersion: 1)); + _viewerResolver.ResolveAsync(hostUserId, Arg.Any()).Returns(viewers); + + var payload = $$"""{"lobbyId":"{{_lobbyId}}","hostUserId":"{{hostUserId}}","gameSlug":"chess-lite"}"""; + var message = Message(LobbyOutbox.LobbyCreated, revision: 1, occurredAtUtc: T0.AddMinutes(1), payload: payload); + + await _sut.HandleAsync(message); + + _presence.Received(1).SetLobbyMembership(hostUserId, _lobbyId, true); + await _notifier.Received(1).NotifyPresenceChangedAsync( + hostUserId, Arg.Is>(v => v.SequenceEqual(viewers)), + "InLobby", Arg.Any(), 1, Arg.Any()); + } + + [Fact] + public async Task HandleAsync_LobbyCreated_DoesNotBroadcastWhenMembershipDidNotChange() + { + var hostUserId = Guid.NewGuid(); + // Already recorded as in this lobby (e.g. a redelivery) — SetLobbyMembership is a no-op. + _presence.SetLobbyMembership(hostUserId, _lobbyId, true) + .Returns(new PresenceUpdateResult(Changed: false, PresenceStatus.InLobby, Guid.NewGuid(), UserVersion: 1)); + + var payload = $$"""{"lobbyId":"{{_lobbyId}}","hostUserId":"{{hostUserId}}","gameSlug":"chess-lite"}"""; + var message = Message(LobbyOutbox.LobbyCreated, revision: 1, occurredAtUtc: T0.AddMinutes(1), payload: payload); + + await _sut.HandleAsync(message); + + await _notifier.DidNotReceiveWithAnyArgs().NotifyPresenceChangedAsync( + default, default!, default!, default, default, default); + } + + /// + /// Regression guard for the fix alongside Lobby.CloseInternal/TryExpire now releasing every seat: this handler + /// must clear presence membership from the full roster (Members), not JoinedMembers — by the time this handler + /// re-reads the lobby, JoinedMembers is already empty because closure released every seat. + /// + [Fact] + public async Task HandleAsync_LobbyClosed_ClearsMembershipForEveryMemberOnTheFullRoster() + { + var hostId = Guid.NewGuid(); + var otherId = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(hostId, T0); + lobby.Join(otherId, T0); + lobby.TryExpire(T0.AddHours(2)); // closure already released every seat, matching production behavior + + _lobbies.GetByIdAsync(lobby.Id, Arg.Any()).Returns(lobby); + _presence.SetLobbyMembership(Arg.Any(), lobby.Id, false) + .Returns(new PresenceUpdateResult(Changed: true, PresenceStatus.Online, Guid.NewGuid(), UserVersion: 1)); + _viewerResolver.ResolveAsync(Arg.Any(), Arg.Any()).Returns(Array.Empty()); + + var message = OutboxMessage.Create( + "Lobby", lobby.Id, LobbyOutbox.LobbyClosed, LobbyOutbox.EventVersion, + aggregateDomainVersion: 2, requestCycleId: 2, payload: $$"""{"lobbyId":"{{lobby.Id}}","reason":"Expired"}"""); + typeof(OutboxMessage).GetProperty(nameof(OutboxMessage.OccurredAtUtc))!.SetValue(message, T0.AddMinutes(1)); + + await _sut.HandleAsync(message); + + _presence.Received(1).SetLobbyMembership(hostId, lobby.Id, false); + _presence.Received(1).SetLobbyMembership(otherId, lobby.Id, false); + } + + // ── EventTypes scoping ────────────────────────────────────────────────── + + [Fact] + public void EventTypes_DoesNotIncludeInviteOrMatchRequestEvents() + { + _sut.EventTypes.Should().NotContain(LobbyOutbox.LobbyInviteCreated); + _sut.EventTypes.Should().NotContain(LobbyOutbox.LobbyInviteAccepted); + _sut.EventTypes.Should().NotContain(LobbyOutbox.LobbyInviteRevoked); + _sut.EventTypes.Should().NotContain(LobbyOutbox.MatchRequested); + _sut.EventTypes.Should().Contain(LobbyOutbox.LobbyReadinessChanged); + } + + private OutboxMessage Message(string eventType, long revision, DateTime occurredAtUtc, Guid? id = null, string payload = "{}") + { + var message = OutboxMessage.Create( + "Lobby", _lobbyId, eventType, LobbyOutbox.EventVersion, + aggregateDomainVersion: revision, requestCycleId: (int)revision, payload: payload); + + typeof(OutboxMessage).GetProperty(nameof(OutboxMessage.OccurredAtUtc))!.SetValue(message, occurredAtUtc); + if (id is Guid explicitId) + typeof(OutboxMessage).GetProperty(nameof(OutboxMessage.Id))!.SetValue(message, explicitId); + + return message; + } +} diff --git a/tests/SimPle.UnitTests/Realtime/PresenceRegistryTests.cs b/tests/SimPle.UnitTests/Realtime/PresenceRegistryTests.cs new file mode 100644 index 0000000..1c190f8 --- /dev/null +++ b/tests/SimPle.UnitTests/Realtime/PresenceRegistryTests.cs @@ -0,0 +1,186 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using SimPle.Application.Realtime.Presence; + +namespace SimPle.UnitTests.Realtime; + +/// +/// Fake-clock boundary tests for (docs/specs/module-07-realtime-presence-chat- +/// spec.md, "Domain Invariants: presence precedence"). Mirrors the FakeTimeProvider convention already used in +/// tests/SimPle.UnitTests/Matchmaking/ExpirySweeperTests.cs. +/// +public sealed class PresenceRegistryTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private readonly FakeTimeProvider _clock = new(T0); + private readonly PresenceRegistry _registry; + + public PresenceRegistryTests() + { + _registry = new PresenceRegistry(_clock); + } + + [Fact] + public void Connect_FirstConnection_ReportsOnlineAndBumpsVersion() + { + var userId = Guid.NewGuid(); + + var connected = _registry.TryConnect(userId, "conn-1"); + var status = _registry.GetStatus(userId); + + connected.Should().BeTrue(); + status.Status.Should().Be(PresenceStatus.Online); + status.UserVersion.Should().Be(1); + } + + [Fact] + public void Away_At4Minutes59Seconds_StillOnline() + { + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + + _clock.Advance(TimeSpan.FromMinutes(4) + TimeSpan.FromSeconds(59)); + var status = _registry.GetStatus(userId); + + status.Status.Should().Be(PresenceStatus.Online); + status.Changed.Should().BeFalse(); + } + + [Fact] + public void Away_At5Minutes1Second_BecomesAway() + { + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + + _clock.Advance(TimeSpan.FromMinutes(5) + TimeSpan.FromSeconds(1)); + var status = _registry.GetStatus(userId); + + status.Status.Should().Be(PresenceStatus.Away); + status.Changed.Should().BeTrue(); + } + + [Fact] + public void Offline_At9SecondsAfterAllDisconnected_NotYetOffline() + { + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + _registry.Disconnect(userId, "conn-1"); + + _clock.Advance(TimeSpan.FromSeconds(9)); + var status = _registry.GetStatus(userId); + + status.Status.Should().Be(PresenceStatus.Online); + } + + [Fact] + public void Offline_At11SecondsAfterAllDisconnected_BecomesOfflineAndEvictsBucket() + { + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + _registry.Disconnect(userId, "conn-1"); + + _clock.Advance(TimeSpan.FromSeconds(11)); + var status = _registry.GetStatus(userId); + + status.Status.Should().Be(PresenceStatus.Offline); + status.Changed.Should().BeTrue(); + + // Bucket evicted: a subsequent query for the same (still-disconnected) user starts fresh at version 0 + // rather than continuing to increment a retained entry. + var again = _registry.GetStatus(userId); + again.UserVersion.Should().Be(0); + again.Changed.Should().BeFalse(); + } + + [Fact] + public void ReportActivity_ThrottledWithinSixtySeconds_RejectedAndMutatesNothing() + { + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); // sets last-activity at T0 + _clock.Advance(TimeSpan.FromSeconds(30)); // within the 60s throttle window of that same stamp + + var accepted = _registry.TryReportActivity(userId, "conn-1"); + accepted.Should().BeFalse(); + + // Prove the rejected signal truly mutated nothing: the away-timer base is still T0, so at T0+4:59 total + // (30s already elapsed + 4:29 more) the connection is still within its original 5-minute Online window. + _clock.Advance(TimeSpan.FromMinutes(4) + TimeSpan.FromSeconds(29)); + _registry.GetStatus(userId).Status.Should().Be(PresenceStatus.Online); + } + + [Fact] + public void ReportActivity_AfterThrottleWindow_AcceptedAndResetsAwayTimer() + { + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + _clock.Advance(TimeSpan.FromSeconds(61)); // past the 60s throttle window + + var accepted = _registry.TryReportActivity(userId, "conn-1"); + accepted.Should().BeTrue(); + + // Away-timer reset by the accepted activity: 4 more minutes from here is still well within Online. + _clock.Advance(TimeSpan.FromMinutes(4)); + _registry.GetStatus(userId).Status.Should().Be(PresenceStatus.Online); + } + + [Fact] + public void MaxAggregation_OneConnectionActive_KeepsUserOnlineDespiteAnotherIdleConnection() + { + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + _clock.Advance(TimeSpan.FromMinutes(3)); + _registry.TryConnect(userId, "conn-2"); + _clock.Advance(TimeSpan.FromMinutes(2) + TimeSpan.FromSeconds(1)); // conn-1 now 5:01 idle, conn-2 only 2:01 + + _registry.GetStatus(userId).Status.Should().Be(PresenceStatus.Online); + } + + [Fact] + public void SixthConnection_ForSameUser_Rejected() + { + var userId = Guid.NewGuid(); + for (var i = 0; i < 5; i++) + _registry.TryConnect(userId, $"conn-{i}").Should().BeTrue(); + + _registry.TryConnect(userId, "conn-6").Should().BeFalse(); + } + + [Fact] + public void MemberOfLobby_WhileConnected_DrivesInLobbyStatus() + { + var userId = Guid.NewGuid(); + var lobbyId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + + var result = _registry.SetLobbyMembership(userId, lobbyId, isMember: true); + + result.Status.Should().Be(PresenceStatus.InLobby); + } + + [Fact] + public void SubscribedLobby_DoesNotDriveInLobbyStatus() + { + // SubscribedLobbyIds (fan-out) is a hub/group concern entirely separate from MemberOfLobbyIds (presence). + // Presence never learns about a subscription that isn't also reported as membership. + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + + var status = _registry.GetStatus(userId); + + status.Status.Should().Be(PresenceStatus.Online); + } + + [Fact] + public void ServerEpoch_IsStableAcrossCalls() + { + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + + var first = _registry.GetStatus(userId).ServerEpoch; + var second = _registry.GetStatus(userId).ServerEpoch; + + first.Should().Be(second); + first.Should().Be(_registry.ServerEpoch); + } +} diff --git a/tests/SimPle.UnitTests/Realtime/RealtimeDeadCodeRegressionTests.cs b/tests/SimPle.UnitTests/Realtime/RealtimeDeadCodeRegressionTests.cs new file mode 100644 index 0000000..0fb8da6 --- /dev/null +++ b/tests/SimPle.UnitTests/Realtime/RealtimeDeadCodeRegressionTests.cs @@ -0,0 +1,60 @@ +using System.Reflection; +using FluentAssertions; +using SimPle.Domain.Chat; + +namespace SimPle.UnitTests.Realtime; + +/// +/// Regression tests for the Module 7 dead-code removal (docs/specs/module-07-realtime-presence-chat-spec.md, +/// backend sessions A/B, M07-B1/M07-B2): the orphan ChatContext.DirectMessage-era stub (zero DbSet, zero +/// EF config, zero migration, zero repository, zero consumer) was replaced outright by in +/// M07-B2 — never migrated, never given a DirectMessage member — and the four unused placeholder notifier +/// interfaces that routed by a non-existent lobbyCode were deleted outright in M07-B1. These tests fail +/// loudly if any of it ever comes back. +/// +public sealed class RealtimeDeadCodeRegressionTests +{ + [Fact] + public void ChatScope_HasNoDirectMessageValue() + { + Enum.GetNames().Should().NotContain("DirectMessage"); + } + + [Fact] + public void ChatScope_OnlyHasLobbyAndMatch() + { + Enum.GetNames().Should().BeEquivalentTo("Lobby", "Match"); + } + + [Fact] + public void NoChatContextTypeSurvivesAnywhereInTheLoadedAssemblies() + { + // ChatContext was the orphan stub's enum name (pre-M07-B2). Replaced by ChatScope, not renamed in place — + // if a type literally named ChatContext ever reappears, something resurrected the old shape. + typeof(ChatMessage).Assembly.GetTypes().Select(t => t.Name).Should().NotContain("ChatContext"); + } + + [Fact] + public void NoLobbyCodeRoutedNotifierInterfaceSurvivesAnywhereInTheLoadedAssemblies() + { + // The dead placeholder file (src/SimPle.Infrastructure/Realtime/IHubContext.cs) declared + // IPresenceNotifier/ILobbyNotifier/IGameNotifier/IHardwareNotifier, none of which had a single + // implementation or caller. If any of these type names ever reappear anywhere in the solution's own + // assemblies, this test fails — the fix was to delete them, not resurrect them under the same name. + var deadTypeNames = new[] { "IPresenceNotifier", "ILobbyNotifier", "IGameNotifier", "IHardwareNotifier" }; + + var assemblies = new[] + { + typeof(SimPle.Application.Realtime.Contracts.IRealtimeClient).Assembly, + typeof(SimPle.Domain.Chat.ChatMessage).Assembly, + }; + + foreach (var assembly in assemblies) + { + var typeNames = assembly.GetTypes().Select(t => t.Name).ToList(); + var resurrected = typeNames.Intersect(deadTypeNames).ToList(); + resurrected.Should().BeEmpty( + $"assembly {assembly.GetName().Name} should not declare any resurrected dead placeholder type"); + } + } +} From b20cfb956c976bc482b0ce0ee746b4006a7a42f2 Mon Sep 17 00:00:00 2001 From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:26:10 +0300 Subject: [PATCH 4/4] fix(lobbies): release stranded members when a lobby closes or expires Lobby.Close/expiry only flipped the lobby's own state and never released individual LobbyMember rows still at Joined. The partial unique index on (UserId, Joined) then permanently blocked that user from joining or creating another lobby, even though the lobby itself was terminal. Found during module-07 E2E verification; also broadcasts readiness changes over the outbox so LobbyRealtimeHandler can consume them. --- .../Lobbies/Services/LobbiesService.cs | 2 +- src/SimPle.Domain/Lobbies/Lobby.cs | 25 +++++++++++--- .../Lobbies/LobbiesServiceTests.cs | 4 +-- .../Lobbies/LobbyLifecycleTests.cs | 34 +++++++++++++++++++ .../Matchmaking/LobbyBlockHandlerTests.cs | 2 +- 5 files changed, 58 insertions(+), 9 deletions(-) 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.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/tests/SimPle.UnitTests/Lobbies/LobbiesServiceTests.cs b/tests/SimPle.UnitTests/Lobbies/LobbiesServiceTests.cs index da2a7f6..691d950 100644 --- a/tests/SimPle.UnitTests/Lobbies/LobbiesServiceTests.cs +++ b/tests/SimPle.UnitTests/Lobbies/LobbiesServiceTests.cs @@ -280,7 +280,7 @@ public async Task Join_WrongExpiredRotatedAndClosed_AllReturnTheIdenticalError() // 3. Correct, live credential — but the lobby behind it has closed. var closedLobby = LobbyTestFactory.Open(_host, T0); - closedLobby.Close(LobbyClosedReason.HostClosed); + closedLobby.Close(LobbyClosedReason.HostClosed, T0); var closedCred = IssuedCredential(closedLobby.Id, "CLOSED"); _repo.FindActiveByCodeDigestAsync("hash:CLOSED", Arg.Any()).Returns(closedCred); _repo.GetForUpdateAsync(closedLobby.Id, Arg.Any()).Returns(closedLobby); @@ -425,7 +425,7 @@ public async Task JoinByLobbyId_ForAPrivateClosedOrUnknownLobby_IsTheIdenticalNo var private_ = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto(null, null, privateLobby.Id)); var closedLobby = LobbyTestFactory.Open(_host, T0, LobbyTestFactory.Settings(privacy: LobbyPrivacy.Public)); - closedLobby.Close(LobbyClosedReason.HostClosed); + closedLobby.Close(LobbyClosedReason.HostClosed, T0); _repo.GetForUpdateAsync(closedLobby.Id, Arg.Any()).Returns(closedLobby); var closed = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto(null, null, closedLobby.Id)); diff --git a/tests/SimPle.UnitTests/Lobbies/LobbyLifecycleTests.cs b/tests/SimPle.UnitTests/Lobbies/LobbyLifecycleTests.cs index b6b17e4..970bbdc 100644 --- a/tests/SimPle.UnitTests/Lobbies/LobbyLifecycleTests.cs +++ b/tests/SimPle.UnitTests/Lobbies/LobbyLifecycleTests.cs @@ -75,6 +75,40 @@ public void TheExpirySweepDoesNotTouchALobbyThatIsNotYetDue() lobby.State.Should().Be(LobbyState.Open); } + /// + /// Regression: expiry used to only flip Lobby.State, leaving every still-seated member's own + /// LobbyMemberState stuck at Joined. That row survives forever against the DB's partial unique index on + /// (UserId, Joined) — even though the lobby itself is terminal and invisible to GetActiveLobbyForUserAsync — + /// permanently blocking that user from ever joining or creating another lobby. + /// + [Fact] + public void ExpiryReleasesEveryRemainingSeatedMember() + { + var lobby = LobbyTestFactory.Open(_host, Now); + lobby.Join(_alice, Now).Should().Be(LobbyOutcome.Ok); + _clock.Advance(TimeSpan.FromHours(2)); + + lobby.TryExpire(Now).Should().BeTrue(); + + lobby.JoinedCount.Should().Be(0); + lobby.FindJoinedMember(_host).Should().BeNull(); + lobby.FindJoinedMember(_alice).Should().BeNull(); + } + + /// Same guarantee for an explicit Close, not just expiry. + [Fact] + public void ClosingALobbyReleasesEveryRemainingSeatedMember() + { + var lobby = LobbyTestFactory.Open(_host, Now); + lobby.Join(_alice, Now).Should().Be(LobbyOutcome.Ok); + + lobby.Close(LobbyClosedReason.HostClosed, Now).Should().Be(LobbyOutcome.Ok); + + lobby.JoinedCount.Should().Be(0); + lobby.FindJoinedMember(_host).Should().BeNull(); + lobby.FindJoinedMember(_alice).Should().BeNull(); + } + // ── Capacity ───────────────────────────────────────────────────────────── [Fact] diff --git a/tests/SimPle.UnitTests/Matchmaking/LobbyBlockHandlerTests.cs b/tests/SimPle.UnitTests/Matchmaking/LobbyBlockHandlerTests.cs index c397f06..491c7d1 100644 --- a/tests/SimPle.UnitTests/Matchmaking/LobbyBlockHandlerTests.cs +++ b/tests/SimPle.UnitTests/Matchmaking/LobbyBlockHandlerTests.cs @@ -130,7 +130,7 @@ await _lobbies.DidNotReceive().SaveAsync( public async Task ATerminalLobbyIsLeftAlone() { var lobby = GivenSharedLobby(); - lobby.Close(LobbyClosedReason.HostLeft); + lobby.Close(LobbyClosedReason.HostLeft, T0); await _sut.HandleAsync(BlockEvent(blocker: _host, blocked: _member));