diff --git a/coverage.unit.runsettings b/coverage.unit.runsettings
index bbc9cff..012f34f 100644
--- a/coverage.unit.runsettings
+++ b/coverage.unit.runsettings
@@ -14,7 +14,6 @@
[SimPle.Domain]SimPle.Domain.Chat.*,
[SimPle.Domain]SimPle.Domain.Games.*,
[SimPle.Domain]SimPle.Domain.Hardware.*,
- [SimPle.Domain]SimPle.Domain.Lobbies.*,
[SimPle.Domain]SimPle.Domain.Notifications.*
GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute
diff --git a/src/SimPle.Api/Controllers/LobbiesController.cs b/src/SimPle.Api/Controllers/LobbiesController.cs
new file mode 100644
index 0000000..5ad4dc2
--- /dev/null
+++ b/src/SimPle.Api/Controllers/LobbiesController.cs
@@ -0,0 +1,506 @@
+using System.IdentityModel.Tokens.Jwt;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+using SimPle.Api.Models;
+using SimPle.Application.Lobbies.DTOs;
+using SimPle.Application.Lobbies.Services;
+using SimPle.Shared.Common;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace SimPle.Api.Controllers;
+
+///
+/// The Module 6 lobby command surface.
+///
+/// Every route requires authentication, and the actor is always the JWT sub claim — no endpoint accepts a
+/// caller-supplied hostId/userId. Every state-changing endpoint additionally requires the
+/// X-Requested-With: XMLHttpRequest CSRF header, matching the rest of the API.
+///
+///
+/// Privacy-safe not-found (OWASP API1:2023). A lobby, invite, or credential the caller is not
+/// entitled to reach returns 404, never 403 — a 403 would confirm the id exists. Missing, expired,
+/// private-and-unauthorized, and another user's ids are all deliberately indistinguishable. 403 is reserved
+/// for a caller who is already a visible member and merely lacks the host role, where nothing is leaked
+/// by saying so.
+///
+///
+///
+/// A join credential is never a resource identifier. It travels in a request body on exactly one
+/// route (POST /api/lobbies/join) and is returned in exactly two places (create and rotate). No path, query
+/// string, log line, or event ever carries it.
+///
+///
+[ApiController]
+[Route("api/lobbies")]
+[Authorize]
+[Produces("application/json")]
+[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status500InternalServerError)]
+public sealed class LobbiesController : ControllerBase
+{
+ private readonly ILobbiesService _lobbies;
+
+ public LobbiesController(ILobbiesService lobbies)
+ {
+ _lobbies = lobbies;
+ }
+
+ // ── Create & read ────────────────────────────────────────────────────────
+
+ [HttpPost]
+ [EnableRateLimiting("lobby-create")]
+ [SwaggerOperation(
+ Summary = "Create a lobby and issue its join code + share link",
+ Description = "The plaintext join code and link token are returned here and at rotation only — they are " +
+ "stored as keyed digests and appear in no other response, log, or event.",
+ OperationId = "Lobbies_Create", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(typeof(CreateLobbyResultDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
+ public async Task Create([FromBody] CreateLobbyRequestDto request, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ var result = await _lobbies.CreateAsync(userId, request, ct);
+ if (!result.IsSuccess) return MapError(result.Error!);
+
+ return StatusCode(StatusCodes.Status201Created, result.Value);
+ }
+
+ [HttpGet]
+ [EnableRateLimiting("lobby-read")]
+ [SwaggerOperation(
+ Summary = "Browse open public lobbies (keyset cursor paged)",
+ Description = "Private, expired, full, and blocked lobbies never appear and never affect totals or cursors.",
+ OperationId = "Lobbies_GetPublic", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(typeof(CursorPage), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
+ public async Task GetPublic(
+ [FromQuery] int limit = 20,
+ [FromQuery] string? cursor = null,
+ CancellationToken ct = default)
+ {
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ Response.Headers.CacheControl = "private, no-store";
+ var result = await _lobbies.GetPublicAsync(userId, limit, cursor, ct);
+ return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
+ }
+
+ [HttpGet("capabilities/{gameSlug}")]
+ [EnableRateLimiting("lobby-read")]
+ [SwaggerOperation(
+ Summary = "Get the active capability profile for a game",
+ Description = "The pinned-version source (D2): capabilityVersion plus the allowed modes, time controls, " +
+ "tie-break rules, and spectator policies a client reads before create-lobby or a matchmaking " +
+ "ticket create, instead of guessing values the server will reject.",
+ OperationId = "Lobbies_GetCapabilityProfile", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(typeof(GameCapabilityProfileDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ public async Task GetCapabilityProfile([FromRoute] string gameSlug, CancellationToken ct)
+ {
+ if (!TryGetUserId(out _)) return Unauthorized();
+
+ Response.Headers.CacheControl = "private, no-store";
+ var result = await _lobbies.GetCapabilityProfileAsync(gameSlug, ct);
+ return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
+ }
+
+ [HttpGet("{lobbyId:guid}")]
+ [EnableRateLimiting("lobby-read")]
+ [SwaggerOperation(
+ Summary = "Get a lobby the caller may see",
+ Description = "Members see the full lobby. Non-members see an open public lobby. Everything else is a " +
+ "privacy-safe 404 — a private or foreign lobby is indistinguishable from one that does not exist.",
+ OperationId = "Lobbies_Get", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(typeof(LobbyDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
+ public async Task Get([FromRoute] Guid lobbyId, CancellationToken ct)
+ {
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ Response.Headers.CacheControl = "private, no-store";
+ var result = await _lobbies.GetAsync(userId, lobbyId, ct);
+ return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
+ }
+
+ [HttpGet("me/invites")]
+ [EnableRateLimiting("lobby-read")]
+ [SwaggerOperation(Summary = "The caller's pending, unexpired lobby invites",
+ OperationId = "Lobbies_GetMyInvites", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
+ public async Task GetMyInvites(CancellationToken ct)
+ {
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ Response.Headers.CacheControl = "private, no-store";
+ var result = await _lobbies.GetMyInvitesAsync(userId, ct);
+ return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
+ }
+
+ [HttpGet("me/active")]
+ [EnableRateLimiting("lobby-read")]
+ [SwaggerOperation(
+ Summary = "The caller's active lobby or matchmaking ticket",
+ Description = "At most one of the two is ever set — that is the one-active-lobby-or-ticket invariant.",
+ OperationId = "Lobbies_GetMyActive", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(typeof(ActiveContextDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
+ public async Task GetMyActive(CancellationToken ct)
+ {
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ Response.Headers.CacheControl = "private, no-store";
+ var result = await _lobbies.GetMyActiveAsync(userId, ct);
+ return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
+ }
+
+ // ── Join ─────────────────────────────────────────────────────────────────
+
+ [HttpPost("join")]
+ [EnableRateLimiting("lobby-join")]
+ [SwaggerOperation(
+ Summary = "Join a lobby by join code, share link token, or (for a Public+Open lobby) its id",
+ Description = "Exactly one of code, linkToken, or lobbyId. The credential forms are supplied in the body, " +
+ "never in the path — a credential is a secret, not a resource id. A wrong, expired, rotated, " +
+ "revoked, or closed-lobby credential all return the identical Lobbies.CredentialInvalid, so " +
+ "this endpoint is not an oracle; failed credential attempts are throttled specifically. " +
+ "lobbyId carries no throttle and is only honored when the target is currently Public and " +
+ "Open — the same visibility rule as the public browse listing — so a private, foreign, or " +
+ "non-open lobby's id answers with the identical privacy-safe Lobbies.NotFound.",
+ OperationId = "Lobbies_Join", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(typeof(LobbyDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
+ public async Task Join([FromBody] JoinLobbyRequestDto request, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ var result = await _lobbies.JoinByCredentialAsync(userId, request, ct);
+ return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
+ }
+
+ // ── Membership ───────────────────────────────────────────────────────────
+
+ [HttpPost("{lobbyId:guid}/leave")]
+ [EnableRateLimiting("lobby-write")]
+ [SwaggerOperation(
+ Summary = "Leave a lobby",
+ Description = "If the host leaves, hosting transfers to the longest-tenured eligible member (tie-broken by " +
+ "user id); the lobby closes when none exists.",
+ OperationId = "Lobbies_Leave", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
+ public async Task Leave([FromRoute] Guid lobbyId, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ var result = await _lobbies.LeaveAsync(userId, lobbyId, ct);
+ return result.IsSuccess ? NoContent() : MapError(result.Error!);
+ }
+
+ [HttpPut("{lobbyId:guid}/ready")]
+ [EnableRateLimiting("lobby-write")]
+ [SwaggerOperation(
+ Summary = "Set the caller's own readiness",
+ Description = "The host is implicitly ready and cannot un-ready.",
+ OperationId = "Lobbies_SetReadiness", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(typeof(LobbyDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
+ public async Task SetReadiness(
+ [FromRoute] Guid lobbyId, [FromBody] SetReadinessRequestDto request, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ var result = await _lobbies.SetReadinessAsync(userId, lobbyId, request, ct);
+ return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
+ }
+
+ [HttpPatch("{lobbyId:guid}/settings")]
+ [EnableRateLimiting("lobby-write")]
+ [SwaggerOperation(
+ Summary = "Host: change match settings",
+ Description = "Validated against the pinned capability profile before persistence. Every match-affecting " +
+ "change resets readiness for all joined non-host members; privacy and spectator policy do not.",
+ OperationId = "Lobbies_UpdateSettings", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(typeof(LobbyDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
+ public async Task UpdateSettings(
+ [FromRoute] Guid lobbyId, [FromBody] UpdateLobbySettingsRequestDto request, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ var result = await _lobbies.UpdateSettingsAsync(userId, lobbyId, request, ct);
+ return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
+ }
+
+ [HttpPost("{lobbyId:guid}/kick")]
+ [EnableRateLimiting("lobby-write")]
+ [SwaggerOperation(Summary = "Host: remove a member from the lobby",
+ OperationId = "Lobbies_Kick", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(typeof(LobbyDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
+ public async Task Kick(
+ [FromRoute] Guid lobbyId, [FromBody] KickMemberRequestDto request, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ var result = await _lobbies.KickAsync(userId, lobbyId, request, ct);
+ return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
+ }
+
+ // ── Credential rotation ──────────────────────────────────────────────────
+
+ [HttpPost("{lobbyId:guid}/credential/rotate")]
+ [EnableRateLimiting("lobby-write")]
+ [SwaggerOperation(
+ Summary = "Host: rotate the join code and share link",
+ Description = "The previous code and link are invalidated immediately — there is no window in which both work.",
+ OperationId = "Lobbies_RotateCredential", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(typeof(LobbyCredentialDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
+ public async Task RotateCredential([FromRoute] Guid lobbyId, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ Response.Headers.CacheControl = "private, no-store";
+ var result = await _lobbies.RotateCredentialAsync(userId, lobbyId, ct);
+ return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
+ }
+
+ // ── Invites ──────────────────────────────────────────────────────────────
+
+ [HttpPost("{lobbyId:guid}/invites")]
+ [EnableRateLimiting("lobby-invite")]
+ [SwaggerOperation(
+ Summary = "Host: invite a friend to the lobby",
+ Description = "Accepted friends only. This is what stops the invite endpoint being a way to reveal a " +
+ "private lobby's existence to an arbitrary user id.",
+ OperationId = "Lobbies_CreateInvite", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(typeof(LobbyInviteDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
+ public async Task CreateInvite(
+ [FromRoute] Guid lobbyId, [FromBody] CreateInviteRequestDto request, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ var result = await _lobbies.CreateInviteAsync(userId, lobbyId, request, ct);
+ if (!result.IsSuccess) return MapError(result.Error!);
+
+ return StatusCode(StatusCodes.Status201Created, result.Value);
+ }
+
+ [HttpDelete("{lobbyId:guid}/invites/{inviteId:guid}")]
+ [EnableRateLimiting("lobby-write")]
+ [SwaggerOperation(Summary = "Host: revoke a pending invite",
+ OperationId = "Lobbies_RevokeInvite", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
+ public async Task RevokeInvite(
+ [FromRoute] Guid lobbyId, [FromRoute] Guid inviteId, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ var result = await _lobbies.RevokeInviteAsync(userId, lobbyId, inviteId, ct);
+ return result.IsSuccess ? NoContent() : MapError(result.Error!);
+ }
+
+ ///
+ /// Reconciliation R6 — a route the approved spec's table omits.
+ ///
+ /// Without it an invitee cannot reach a seat at all: join takes a credential, and handing every invitee the
+ /// lobby's private code would both leak it and leave a revoked invite still redeemable. Accept runs the same
+ /// bounded transaction as a credential join, so an invited member is admitted under identical block, capacity,
+ /// and one-active-lobby-or-ticket rules.
+ ///
+ [HttpPost("invites/{inviteId:guid}/accept")]
+ [EnableRateLimiting("lobby-join")]
+ [SwaggerOperation(
+ Summary = "Accept a lobby invite and take a seat",
+ Description = "Enforces the same blocks, capacity, and one-active-lobby-or-ticket rules as a credential " +
+ "join — an invite is permission to try, never a bypass. Another user's invite id is a " +
+ "privacy-safe 404.",
+ OperationId = "Lobbies_AcceptInvite", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(typeof(LobbyDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
+ public async Task AcceptInvite([FromRoute] Guid inviteId, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ var result = await _lobbies.AcceptInviteAsync(userId, inviteId, ct);
+ return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
+ }
+
+ /// Reconciliation R6, with accept above.
+ [HttpPost("invites/{inviteId:guid}/decline")]
+ [EnableRateLimiting("lobby-write")]
+ [SwaggerOperation(Summary = "Decline a lobby invite",
+ OperationId = "Lobbies_DeclineInvite", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
+ public async Task DeclineInvite([FromRoute] Guid inviteId, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ var result = await _lobbies.DeclineInviteAsync(userId, inviteId, ct);
+ return result.IsSuccess ? NoContent() : MapError(result.Error!);
+ }
+
+ // ── Start ────────────────────────────────────────────────────────────────
+
+ [HttpPost("{lobbyId:guid}/start")]
+ [EnableRateLimiting("lobby-write")]
+ [SwaggerOperation(
+ Summary = "Host: start the match",
+ Description = "Returns 503 Lobbies.MatchRuntimeUnavailable while Module 8 is not registered — the lobby " +
+ "stays Open and no room is created. A committed match request is a durable request, never a " +
+ "created match.",
+ OperationId = "Lobbies_Start", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(typeof(StartLobbyResultDto), StatusCodes.Status202Accepted)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status503ServiceUnavailable)]
+ public async Task Start(
+ [FromRoute] Guid lobbyId, [FromBody] StartLobbyRequestDto request, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ var result = await _lobbies.StartAsync(userId, lobbyId, request, ct);
+ if (!result.IsSuccess) return MapError(result.Error!);
+
+ // 202, not 200: the lobby has entered Starting and a request is durable, but the match does not exist yet
+ // and will not until M8 answers. A 200 would imply the work is done.
+ return Accepted(result.Value);
+ }
+
+ // ── 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 Module 6 error catalogue to HTTP.
+ ///
+ /// Lobbies.NotFound and Lobbies.CredentialInvalid both become 404 — the credential case
+ /// deliberately included, so a wrong code is indistinguishable from a lobby that does not exist. Only
+ /// Lobbies.Forbidden (a visible member lacking the host role) is a 403; nothing is leaked by it.
+ ///
+ private IActionResult MapError(Error error)
+ {
+ var body = new ApiErrorResponse(new ApiErrorDetail(error.Code, error.Message, error.RetryAfterUtc));
+
+ switch (error.Code)
+ {
+ case LobbyErrors.NotFound:
+ case LobbyErrors.CredentialInvalid:
+ case LobbyErrors.CapabilityNotFound:
+ return NotFound(body);
+
+ case LobbyErrors.Forbidden:
+ case LobbyErrors.Blocked:
+ return StatusCode(StatusCodes.Status403Forbidden, body);
+
+ case LobbyErrors.Full:
+ case LobbyErrors.Closed:
+ case LobbyErrors.Expired:
+ case LobbyErrors.StaleRevision:
+ case LobbyErrors.AlreadyActive:
+ case LobbyErrors.CapabilityDisabled:
+ case LobbyErrors.ConcurrencyConflict:
+ case LobbyErrors.NotStartable:
+ return Conflict(body);
+
+ case LobbyErrors.MatchRuntimeUnavailable:
+ return StatusCode(StatusCodes.Status503ServiceUnavailable, body);
+
+ case LobbyErrors.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:
+ // Validation.Failed, Pagination.InvalidCursor, Lobbies.InvalidTarget, Auth.CsrfHeaderRequired.
+ return BadRequest(body);
+ }
+ }
+
+ private static ApiErrorResponse Error(string code, string message) =>
+ new(new ApiErrorDetail(code, message));
+}
diff --git a/src/SimPle.Api/Controllers/MatchRematchController.cs b/src/SimPle.Api/Controllers/MatchRematchController.cs
new file mode 100644
index 0000000..778a160
--- /dev/null
+++ b/src/SimPle.Api/Controllers/MatchRematchController.cs
@@ -0,0 +1,84 @@
+using System.IdentityModel.Tokens.Jwt;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+using SimPle.Api.Models;
+using SimPle.Application.Lobbies.DTOs;
+using SimPle.Application.Lobbies.Services;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace SimPle.Api.Controllers;
+
+///
+/// Rematch: creates a new lobby (which Module 6 owns) addressed off the terminal match it replays (which
+/// Module 8 will own).
+///
+///
+/// This lives in its own controller with a real api/matches prefix rather than as an absolute-route action
+/// hanging off . The absolute form works at runtime but
+/// scripts/check-contract-drift.mjs statically concatenates the controller prefix with the action template,
+/// and recorded the route as POST /api/lobbies//api/matches/*/rematch-lobbies. The frontend slice would then
+/// call the real path, find no matching route in the inventory, and trip a false drift failure on a gate that is
+/// supposed to catch exactly this class of mistake. A correctly-prefixed controller keeps the tooling honest.
+///
+///
+///
+/// Module 8 is free to add its own MatchesController for match resources; ASP.NET routes by template, not
+/// by class, so two controllers may share the prefix as long as their action templates do not collide.
+///
+///
+[ApiController]
+[Route("api/matches")]
+[Authorize]
+[Produces("application/json")]
+[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status500InternalServerError)]
+public sealed class MatchRematchController : ControllerBase
+{
+ private readonly ILobbiesService _lobbies;
+
+ public MatchRematchController(ILobbiesService lobbies)
+ {
+ _lobbies = lobbies;
+ }
+
+ [HttpPost("{terminalMatchId:guid}/rematch-lobbies")]
+ [EnableRateLimiting("lobby-create")]
+ [SwaggerOperation(
+ Summary = "Create a rematch lobby from a finished match",
+ Description = "Returns 503 Lobbies.MatchRuntimeUnavailable — Module 8 owns match records, so there is no " +
+ "terminal match to read the prior settings and participants from, and inventing a lobby from " +
+ "defaults would produce one that silently is not the rematch it claims to be. When M8 lands, " +
+ "nobody is auto-joined or auto-readied by a rematch and blocks are re-checked.",
+ OperationId = "Lobbies_CreateRematch", Tags = new[] { "Lobbies" })]
+ [ProducesResponseType(typeof(CreateLobbyResultDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status503ServiceUnavailable)]
+ public async Task CreateRematch([FromRoute] Guid terminalMatchId, CancellationToken ct)
+ {
+ if (!HasCsrfHeader())
+ {
+ return BadRequest(new ApiErrorResponse(new ApiErrorDetail(
+ "Auth.CsrfHeaderRequired", "The X-Requested-With header is required for this request.")));
+ }
+
+ if (!Guid.TryParse(User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value, out var userId))
+ return Unauthorized();
+
+ var result = await _lobbies.CreateRematchLobbyAsync(userId, terminalMatchId, ct);
+ if (!result.IsSuccess)
+ {
+ var body = new ApiErrorResponse(new ApiErrorDetail(result.Error!.Code, result.Error.Message));
+ return result.Error.Code == LobbyErrors.MatchRuntimeUnavailable
+ ? StatusCode(StatusCodes.Status503ServiceUnavailable, body)
+ : BadRequest(body);
+ }
+
+ return StatusCode(StatusCodes.Status201Created, result.Value);
+ }
+
+ private bool HasCsrfHeader() =>
+ string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.Ordinal);
+}
diff --git a/src/SimPle.Api/Controllers/MatchmakingController.cs b/src/SimPle.Api/Controllers/MatchmakingController.cs
new file mode 100644
index 0000000..3751f0f
--- /dev/null
+++ b/src/SimPle.Api/Controllers/MatchmakingController.cs
@@ -0,0 +1,177 @@
+using System.IdentityModel.Tokens.Jwt;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+using SimPle.Api.Models;
+using SimPle.Application.Lobbies.Services;
+using SimPle.Application.Matchmaking.DTOs;
+using SimPle.Application.Matchmaking.Services;
+using SimPle.Shared.Common;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace SimPle.Api.Controllers;
+
+///
+/// The Module 6 Quick Match ticket surface (slice 6C).
+///
+///
+/// Every route requires authentication, and the actor is always the JWT sub claim — no endpoint accepts a
+/// caller-supplied userId or rating. Every state-changing endpoint additionally requires the
+/// X-Requested-With: XMLHttpRequest CSRF header, matching the rest of the API.
+///
+///
+///
+/// Privacy-safe not-found (OWASP API1:2023). Another user's ticket id returns 404, never
+/// 403 — a 403 would confirm the id exists. A missing ticket and a foreign one are indistinguishable.
+///
+///
+///
+/// These endpoints work before Module 8. Enqueue, poll, and cancel are fully functional today; the
+/// matching worker is what waits for a match runtime. A ticket therefore queues, widens through its rating bands,
+/// and honestly times out — and dependencyReadiness on every response is how the client knows that without
+/// hardcoding it.
+///
+///
+[ApiController]
+[Route("api/matchmaking")]
+[Authorize]
+[Produces("application/json")]
+[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status500InternalServerError)]
+public sealed class MatchmakingController : ControllerBase
+{
+ private readonly IMatchmakingService _matchmaking;
+
+ public MatchmakingController(IMatchmakingService matchmaking)
+ {
+ _matchmaking = matchmaking;
+ }
+
+ [HttpPost("tickets")]
+ [EnableRateLimiting("matchmaking-enqueue")]
+ [SwaggerOperation(
+ Summary = "Enqueue a Quick Match ticket",
+ Description = "Idempotent by pool key: re-sending the identical ticket returns the live one rather than " +
+ "minting a second. A *different* ticket while one is live is 409 Matchmaking.AlreadyQueued, " +
+ "as is enqueueing while already in a lobby — a user holds one active lobby OR one active " +
+ "ticket, never both. The rating is a server-side snapshot (provisional 1200 until Module 10); " +
+ "a client cannot supply one and so cannot choose its own opponents.",
+ OperationId = "Matchmaking_CreateTicket", Tags = new[] { "Matchmaking" })]
+ [ProducesResponseType(typeof(TicketDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
+ public async Task CreateTicket([FromBody] CreateTicketRequestDto request, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ Response.Headers.CacheControl = "private, no-store";
+ var result = await _matchmaking.EnqueueAsync(userId, request, ct);
+ if (!result.IsSuccess) return MapError(result.Error!);
+
+ return StatusCode(StatusCodes.Status201Created, result.Value);
+ }
+
+ [HttpGet("tickets/{ticketId:guid}")]
+ [EnableRateLimiting("matchmaking-status")]
+ [SwaggerOperation(
+ Summary = "Poll a Quick Match ticket's status",
+ Description = "Polled every 2 seconds until Module 7 supplies live delivery. `currentBand` is the rating " +
+ "half-width the ticket accepts right now (100 → 200 → 400 as it ages, null once it has " +
+ "reached its deadline), so the UI can show the search visibly widening. Another user's " +
+ "ticket id is a privacy-safe 404.",
+ OperationId = "Matchmaking_GetTicket", Tags = new[] { "Matchmaking" })]
+ [ProducesResponseType(typeof(TicketDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
+ public async Task GetTicket([FromRoute] Guid ticketId, CancellationToken ct)
+ {
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ Response.Headers.CacheControl = "private, no-store";
+ var result = await _matchmaking.GetTicketAsync(userId, ticketId, ct);
+ return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
+ }
+
+ [HttpDelete("tickets/{ticketId:guid}")]
+ [EnableRateLimiting("matchmaking-write")]
+ [SwaggerOperation(
+ Summary = "Cancel a Quick Match ticket",
+ Description = "Returns 200 with the ticket's current status. A cancel that arrives after a worker has " +
+ "claimed the ticket is deliberately NOT an error — the user did nothing wrong and the queue " +
+ "simply got there first, so they receive the ticket's real state rather than a failure.",
+ OperationId = "Matchmaking_CancelTicket", Tags = new[] { "Matchmaking" })]
+ [ProducesResponseType(typeof(TicketDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
+ public async Task CancelTicket([FromRoute] Guid ticketId, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ Response.Headers.CacheControl = "private, no-store";
+ var result = await _matchmaking.CancelAsync(userId, ticketId, ct);
+ return result.IsSuccess ? Ok(result.Value) : 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(new ApiErrorResponse(new ApiErrorDetail(
+ "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 matchmaking error catalogue to HTTP. Enqueue shares several codes with the lobby surface — a user
+ /// who is already in a lobby, a disabled capability, a spent retry budget — and they are mapped identically
+ /// here, because they mean the same thing and a client should not have to learn two vocabularies for one module.
+ ///
+ private IActionResult MapError(Error error)
+ {
+ var body = new ApiErrorResponse(new ApiErrorDetail(error.Code, error.Message, error.RetryAfterUtc));
+
+ switch (error.Code)
+ {
+ case MatchmakingErrors.TicketNotFound:
+ return NotFound(body);
+
+ case LobbyErrors.Forbidden:
+ case LobbyErrors.Blocked:
+ return StatusCode(StatusCodes.Status403Forbidden, body);
+
+ case MatchmakingErrors.AlreadyQueued:
+ case MatchmakingErrors.TicketExpired:
+ case LobbyErrors.AlreadyActive:
+ case LobbyErrors.CapabilityDisabled:
+ case LobbyErrors.ConcurrencyConflict:
+ return Conflict(body);
+
+ case MatchmakingErrors.RuntimeUnavailable:
+ return StatusCode(StatusCodes.Status503ServiceUnavailable, body);
+
+ case LobbyErrors.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:
+ // Validation.Failed, Auth.CsrfHeaderRequired.
+ return BadRequest(body);
+ }
+ }
+}
diff --git a/src/SimPle.Api/Program.cs b/src/SimPle.Api/Program.cs
index 08e402d..345c7ac 100644
--- a/src/SimPle.Api/Program.cs
+++ b/src/SimPle.Api/Program.cs
@@ -21,8 +21,11 @@
using SimPle.Application.Common.Options;
using SimPle.Application.GameHost.Services;
using SimPle.Domain.GameHost;
+using SimPle.Domain.Games;
+using SimPle.Domain.Lobbies;
using SimPle.Infrastructure;
using SimPle.Infrastructure.Auth;
+using SimPle.Infrastructure.Capabilities;
using SimPle.Infrastructure.Games;
using SimPle.Infrastructure.Persistence;
@@ -90,6 +93,22 @@
"Jwt:SecretKey must be configured outside committed appsettings with at least 32 characters.")
.ValidateOnStart();
+// Module 6 — the server key that keys every lobby join-code / link-token digest. Validated on start for the same
+// reason as Jwt:SecretKey: a join code carries only ~60 bits, so an unkeyed or well-known digest would make every
+// code in the database offline-guessable. There is deliberately no dev fallback — the module fails closed rather
+// than silently degrading to a weak key.
+builder.Services.AddOptions()
+ .Bind(builder.Configuration.GetSection(LobbyCredentialOptions.SectionName))
+ .Validate(options =>
+ !string.IsNullOrWhiteSpace(options.Key) &&
+ options.Key.Length >= 32 &&
+ !options.Key.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase) &&
+ !options.Key.StartsWith("CONFIGURE", StringComparison.OrdinalIgnoreCase),
+ "LobbyCredential:Key must be configured outside committed appsettings with at least 32 characters.")
+ .Validate(options => LobbyAllowLists.Regions.Contains(options.DefaultRegion),
+ "LobbyCredential:DefaultRegion must be an allow-listed region (see LobbyAllowLists.Regions).")
+ .ValidateOnStart();
+
builder.Services.AddOptions()
.Bind(builder.Configuration.GetSection(RecaptchaOptions.SectionName))
.Validate(options =>
@@ -286,6 +305,31 @@ await context.HttpContext.Response.WriteAsJsonAsync(new ApiErrorResponse(
options.AddPolicy("catalog-read", context => AuthWindow(context, 120, TimeSpan.FromMinutes(1)));
options.AddPolicy("game-favorites", context => FriendWindow(context, "gfav", 60, TimeSpan.FromMinutes(1)));
+ // Module 6 lobby policies. All per-account (every lobby route is [Authorize]) with the usual coarse per-IP
+ // fallback. Creating a lobby is the expensive one — it mints a credential and takes a seat — so it is the
+ // tightest; reads are generous because the lobby page polls.
+ //
+ // Note that lobby-join throttles *all* join attempts, successful or not. It is NOT the defense against
+ // join-code guessing: a window loose enough to let invited members join freely is far too loose to protect a
+ // ~60-bit code. That job belongs to ILobbyJoinThrottle, which counts failures only (see
+ // MemoryCacheLobbyJoinThrottle) — the two are chained, and both must pass.
+ options.AddPolicy("lobby-create", context => FriendWindow(context, "lbcr", 10, TimeSpan.FromMinutes(1)));
+ options.AddPolicy("lobby-read", context => FriendWindow(context, "lbrd", 120, TimeSpan.FromMinutes(1)));
+ options.AddPolicy("lobby-join", context => FriendWindow(context, "lbjn", 20, TimeSpan.FromMinutes(1)));
+ options.AddPolicy("lobby-write", context => FriendWindow(context, "lbwr", 60, TimeSpan.FromMinutes(1)));
+ options.AddPolicy("lobby-invite", context => FriendWindow(context, "lbiv", 20, TimeSpan.FromMinutes(1)));
+
+ // Module 6 matchmaking policies (slice 6C). Per-account, with the usual coarse per-IP fallback.
+ //
+ // matchmaking-status is by far the loosest of the three, and deliberately so: the queue modal polls a ticket
+ // every 2 seconds for up to its 60-second deadline, which is ~30 requests per ticket before the user has done
+ // anything at all. A limit tight enough to look prudent here would throttle the module's own normal operation.
+ // Enqueue is the tight one — it takes the user's single active slot, so repeating it fast is either a bug or an
+ // attempt to churn the queue (OWASP API4:2023).
+ options.AddPolicy("matchmaking-enqueue", context => FriendWindow(context, "mmen", 10, TimeSpan.FromMinutes(1)));
+ options.AddPolicy("matchmaking-status", context => FriendWindow(context, "mmst", 120, TimeSpan.FromMinutes(1)));
+ options.AddPolicy("matchmaking-write", context => FriendWindow(context, "mmwr", 30, TimeSpan.FromMinutes(1)));
+
// Chained per-IP ceiling for discovery/people-search (spec: 30/min/account + 120/hour/IP), and for
// catalog search (spec: 120/min/IP catalog-read + 30/min/IP catalog-search when `query` is present).
// Scoped by request path/query so it only "bites" on these routes; every other route gets a permanent
@@ -426,6 +470,56 @@ static RateLimitPartition FriendWindow(HttpContext context, string prefi
Environment.Exit(seedResult.Success ? 0 : 1);
}
+// Module 6 — capability profiles (D2). Runs after --seed-game-catalog: every profile is FK'd to games.slug and is
+// validated as a subset of its catalog row, so seeding capabilities into an unseeded catalog fails closed with a
+// message naming the missing game rather than writing a profile nothing can resolve.
+if (args.Contains("--seed-game-capabilities"))
+{
+ using var scope = app.Services.CreateScope();
+ var seedDb = scope.ServiceProvider.GetRequiredService();
+ var seedClock = scope.ServiceProvider.GetRequiredService();
+ var seedLogger = scope.ServiceProvider.GetRequiredService().CreateLogger();
+ var seeder = new GameCapabilitySeeder(seedDb, seedClock, seedLogger);
+ var seedResult = seeder.SeedAsync().GetAwaiter().GetResult();
+ Console.WriteLine(seedResult.Message);
+ Environment.Exit(seedResult.Success ? 0 : 1);
+}
+
+// Local/CI-only lifecycle promotion. GameCatalogSeeder always seeds new games as ComingSoon (Module 4);
+// no controller or seeder ever called Game.MakeAvailable(), so no game could reach Available anywhere.
+// Mirrors the --seed-game-* flags above: never exposed as an HTTP endpoint, CLI-only.
+if (args.Contains("--publish-game"))
+{
+ var slugIndex = Array.IndexOf(args, "--publish-game") + 1;
+ var slug = slugIndex > 0 && slugIndex < args.Length ? args[slugIndex] : null;
+ if (string.IsNullOrWhiteSpace(slug))
+ {
+ Console.WriteLine("--publish-game requires a slug argument.");
+ Environment.Exit(1);
+ }
+
+ using var scope = app.Services.CreateScope();
+ var seedDb = scope.ServiceProvider.GetRequiredService();
+ var game = seedDb.Games.FirstOrDefault(g => g.Slug == slug);
+ if (game is null)
+ {
+ Console.WriteLine($"No game with slug '{slug}' found.");
+ Environment.Exit(1);
+ }
+
+ if (game!.Lifecycle != GameLifecycle.Available)
+ {
+ game.MakeAvailable();
+ seedDb.SaveChanges();
+ Console.WriteLine($"Promoted '{slug}' to Available.");
+ }
+ else
+ {
+ Console.WriteLine($"'{slug}' is already Available; no-op.");
+ }
+ Environment.Exit(0);
+}
+
app.Run();
public partial class Program { }
diff --git a/src/SimPle.Api/appsettings.Development.example.json b/src/SimPle.Api/appsettings.Development.example.json
index 7f642bf..2033f54 100644
--- a/src/SimPle.Api/appsettings.Development.example.json
+++ b/src/SimPle.Api/appsettings.Development.example.json
@@ -24,6 +24,10 @@
"VerificationUrl": "https://www.google.com/recaptcha/api/siteverify",
"DevBypassToken": "dev-captcha-bypass-token"
},
+ "LobbyCredential": {
+ "Key": "REPLACE_WITH_AT_LEAST_32_CHARACTER_SECRET_KEY",
+ "DefaultRegion": "eu-west"
+ },
"Google": {
"ClientId": "REPLACE_WITH_GOOGLE_OAUTH_CLIENT_ID"
},
diff --git a/src/SimPle.Application/Common/Interfaces/ILobbyCommandRunner.cs b/src/SimPle.Application/Common/Interfaces/ILobbyCommandRunner.cs
new file mode 100644
index 0000000..71f84c7
--- /dev/null
+++ b/src/SimPle.Application/Common/Interfaces/ILobbyCommandRunner.cs
@@ -0,0 +1,54 @@
+using SimPle.Shared.Common;
+
+namespace SimPle.Application.Common.Interfaces;
+
+///
+/// Runs one lobby command as a bounded, retried, whole-transaction unit of work (reconciliation R3).
+///
+///
+/// The existing PostgresRetry.SaveChangesAsync is not sufficient here and is deliberately left untouched.
+/// It re-issues only the save, on 40001/40P01 only. A last-seat join that loses the capacity
+/// race must instead re-read the lobby to discover it is now full and answer a typed Lobbies.Full;
+/// replaying just the save would re-commit a decision made against state that is now stale. The brief (Risk #5) is
+/// explicit: the application reruns the entire bounded transaction including the read and decision logic,
+/// then surfaces a typed conflict — never a 500.
+///
+///
+///
+/// Three distinct failures mean "someone beat you; look again", and all three are retried:
+///
+/// - 23505 unique violation — e.g. ux_lobby_members_one_joined_per_user rejecting a second seat.
+/// - 40001/40P01 serialization failure / deadlock.
+/// - A row-version (xmin) mismatch on the lobby, surfaced by EF as
+/// DbUpdateConcurrencyException. This is the one that actually catches concurrent last-seat joins:
+/// every join bumps Revision, so two racers both UPDATE … WHERE xmin = @loaded and the loser
+/// affects zero rows. No index does this work — the member index is keyed on
+/// UserId, so it happily admits two different users into the same last seat.
+///
+///
+///
+///
+/// Between attempts the change tracker is cleared, so the rerun genuinely re-reads rather than re-deciding against
+/// the losing attempt's stale entities. Once the budget is spent the runner returns a typed
+/// Lobbies.ConcurrencyConflict — the exception never escapes as a 500.
+///
+///
+public interface ILobbyCommandRunner
+{
+ ///
+ /// Runs inside one transaction, retrying the whole delegate on contention.
+ ///
+ /// takes a transaction-scoped advisory lock keyed on the actor. That is what
+ /// makes the cross-table "one active lobby or one active ticket" check real: two filtered
+ /// unique indexes on different tables cannot see each other, so without serializing an actor's seat-acquiring
+ /// commands against each other, a concurrent join and enqueue would both read "nothing active" and both commit
+ /// (brief Risk #2). The lock is released by the transaction, so nothing can leak it.
+ ///
+ /// A Result.Fail returned by the command is a decision, not a fault: it commits (nothing was written)
+ /// and is not retried. Only a contention exception triggers a rerun.
+ ///
+ Task> RunAsync(
+ Guid actorUserId,
+ Func>> command,
+ CancellationToken ct = default);
+}
diff --git a/src/SimPle.Application/Common/Interfaces/ILobbyRepository.cs b/src/SimPle.Application/Common/Interfaces/ILobbyRepository.cs
new file mode 100644
index 0000000..4617bf3
--- /dev/null
+++ b/src/SimPle.Application/Common/Interfaces/ILobbyRepository.cs
@@ -0,0 +1,155 @@
+using SimPle.Domain.Capabilities;
+using SimPle.Domain.Games;
+using SimPle.Domain.Lobbies;
+using SimPle.Domain.Outbox;
+using SimPle.Domain.Users;
+
+namespace SimPle.Application.Common.Interfaces;
+
+///
+/// Data access for lobbies, invites, join credentials, and start requests.
+///
+/// Reads used inside a command are tracked — the command layer mutates the aggregate it read and saves it
+/// in the same unit of work. Reads used to render a response are AsNoTracking.
+///
+/// Every write stages its caller-built rows in the same SaveChanges as the
+/// aggregate mutation, so a state change and its integration event commit or roll back together. There is no
+/// method here that saves an event without its cause, or a cause without its event.
+///
+public interface ILobbyRepository
+{
+ // ── Lobby reads ──────────────────────────────────────────────────────────
+
+ /// Tracked, with the member collection loaded. The read half of a read-decide-write command.
+ Task GetForUpdateAsync(Guid lobbyId, CancellationToken ct = default);
+
+ /// Untracked, with members. For rendering a response.
+ Task GetByIdAsync(Guid lobbyId, CancellationToken ct = default);
+
+ ///
+ /// Public discovery: Open + Public only, keyset-ordered on (CreatedAt, Id). A private,
+ /// expired, closed, or started lobby is not merely filtered out of the projection — it never enters the query,
+ /// so it cannot affect page length or the cursor.
+ ///
+ Task> GetPublicPageAsync(
+ int limit, DateTime? afterCreatedAt, Guid? afterId, CancellationToken ct = default);
+
+ /// The user's single joined nonterminal lobby, or null.
+ Task GetActiveLobbyForUserAsync(Guid userId, CancellationToken ct = default);
+
+ ///
+ /// The user's single nonterminal matchmaking ticket id, or null.
+ ///
+ /// Module 6B owns no ticket commands (those are 6C), but it must read this table: "one active lobby
+ /// or one active ticket" is a cross-table invariant, and the two filtered unique indexes
+ /// cannot see each other (brief Risk #2). The join/accept commands therefore check it inside the transaction
+ /// that seats the member.
+ ///
+ Task GetActiveTicketIdForUserAsync(Guid userId, CancellationToken ct = default);
+
+ // ── Credentials ──────────────────────────────────────────────────────────
+
+ Task GetActiveCredentialAsync(Guid lobbyId, CancellationToken ct = default);
+
+ ///
+ /// Resolves an active credential by its keyed digest. Returns null for a wrong, expired, rotated, revoked, or
+ /// unknown value alike — the caller maps every one of them to the same Lobbies.CredentialInvalid, so
+ /// the endpoint is not an oracle for which lobbies exist.
+ ///
+ Task FindActiveByCodeDigestAsync(string codeDigest, CancellationToken ct = default);
+
+ Task FindActiveByLinkTokenDigestAsync(string linkTokenDigest, CancellationToken ct = default);
+
+ // ── Invites ──────────────────────────────────────────────────────────────
+
+ Task GetInviteForUpdateAsync(Guid inviteId, CancellationToken ct = default);
+
+ Task GetPendingInviteAsync(Guid lobbyId, Guid inviteeUserId, CancellationToken ct = default);
+
+ ///
+ /// The invitee's pending, unexpired invites with the lobby and inviter needed to render them. The dashboard's
+ /// "N active" badge is this same bounded query — a count is never shown without the list it summarizes.
+ ///
+ Task> GetPendingInvitesForUserAsync(
+ Guid inviteeUserId, DateTime nowUtc, int limit, CancellationToken ct = default);
+
+ // ── Expiry sweep (slice 6C) ──────────────────────────────────────────────
+
+ ///
+ /// Open/Starting lobbies past their 2-hour deadline, tracked, bounded, oldest first.
+ ///
+ /// A lobby is swept to Expired rather than merely being treated as expired on read. Both exist,
+ /// and both are needed: the read-time IsExpired check is what makes an expired lobby immediately
+ /// unusable even if the sweep is behind, while the sweep is what actually frees its members from the
+ /// one-active-lobby invariant — a member row still pointing at a lobby whose state says Open would keep
+ /// its owner locked out of joining anything else forever.
+ ///
+ Task> GetExpiredLobbiesAsync(
+ DateTime nowUtc, int batchSize, CancellationToken ct = default);
+
+ /// Pending invites past their 30-minute deadline, tracked, bounded, oldest first.
+ Task> GetExpiredInvitesAsync(
+ DateTime nowUtc, int batchSize, CancellationToken ct = default);
+
+ // ── Start requests ───────────────────────────────────────────────────────
+
+ Task GetOpenStartRequestAsync(Guid lobbyId, int lobbyRevision, CancellationToken ct = default);
+
+ Task GetStartRequestByIdempotencyKeyAsync(
+ Guid lobbyId, string idempotencyKey, CancellationToken ct = default);
+
+ // ── Cross-module reads (M3 blocks, M4 catalog, M6 capability profiles) ───
+
+ Task GetCapabilityProfileAsync(
+ string gameSlug, int capabilityVersion, CancellationToken ct = default);
+
+ ///
+ /// The highest-versioned IsActive profile for a slug — the one a new lobby/ticket should pin. Not for
+ /// re-validating an existing lobby, which pins an exact (GameSlug, CapabilityVersion) instead.
+ ///
+ Task GetActiveCapabilityProfileAsync(
+ string gameSlug, CancellationToken ct = default);
+
+ Task GetGameAsync(string gameSlug, CancellationToken ct = default);
+
+ Task> GetGameModesAsync(Guid gameId, CancellationToken ct = default);
+
+ ///
+ /// The subset of that have a block with in
+ /// either direction. Returns the offenders rather than a bool so the caller can distinguish "you
+ /// blocked the host" from "a member blocked you" when it matters, and so one round trip covers a whole roster.
+ ///
+ Task> GetBlockedCounterpartsAsync(
+ Guid userId, IReadOnlyList candidateUserIds, CancellationToken ct = default);
+
+ Task> GetUsersAsync(
+ IReadOnlyList userIds, CancellationToken ct = default);
+
+ Task AreFriendsAsync(Guid userA, Guid userB, CancellationToken ct = default);
+
+ // ── Writes (each stages its outbox rows in the same SaveChanges) ─────────
+
+ Task AddLobbyAsync(
+ Lobby lobby, LobbyJoinCredential credential, IReadOnlyList events,
+ CancellationToken ct = default);
+
+ Task AddInviteAsync(LobbyInvite invite, IReadOnlyList events, CancellationToken ct = default);
+
+ Task AddStartRequestAsync(
+ LobbyStartRequest request, IReadOnlyList events, CancellationToken ct = default);
+
+ /// Rotation: the outgoing credential is already MarkRotated-ed by the caller.
+ Task RotateCredentialAsync(
+ LobbyJoinCredential outgoing, LobbyJoinCredential incoming, IReadOnlyList events,
+ CancellationToken ct = default);
+
+ ///
+ /// Persists whatever the command mutated on already-tracked aggregates, plus its events.
+ ///
+ /// This is the call that can throw — a unique-index violation (23505) from a racing writer, or a row-version
+ /// (xmin) mismatch from a lost update. Both are deliberately allowed to propagate: catching them here would
+ /// mean deciding the outcome without re-reading, which is exactly the bug R3 exists to prevent. The bounded
+ /// retry above the command re-runs the whole read-decide-write instead.
+ ///
+ Task SaveAsync(IReadOnlyList events, CancellationToken ct = default);
+}
diff --git a/src/SimPle.Application/Common/Interfaces/IMatchmakingRepository.cs b/src/SimPle.Application/Common/Interfaces/IMatchmakingRepository.cs
new file mode 100644
index 0000000..cc03f69
--- /dev/null
+++ b/src/SimPle.Application/Common/Interfaces/IMatchmakingRepository.cs
@@ -0,0 +1,103 @@
+using SimPle.Domain.Matchmaking;
+using SimPle.Domain.Outbox;
+
+namespace SimPle.Application.Common.Interfaces;
+
+///
+/// Data access for matchmaking tickets and assignments.
+///
+/// Like , nothing here catches a unique-violation or row-version exception:
+/// contention propagates to the bounded whole-command retry, which re-reads and answers truthfully instead of
+/// deciding against stale state (R3).
+///
+public interface IMatchmakingRepository
+{
+ // ── Ticket reads ─────────────────────────────────────────────────────────
+
+ /// Untracked. For rendering a status response.
+ Task GetTicketAsync(Guid ticketId, CancellationToken ct = default);
+
+ /// Tracked. The read half of a read-decide-write command (cancel).
+ Task GetTicketForUpdateAsync(Guid ticketId, CancellationToken ct = default);
+
+ ///
+ /// The user's single nonterminal ticket, tracked, or null.
+ ///
+ /// This is the queue's half of the cross-table "one active lobby or one active ticket"
+ /// invariant. It is only sound inside the command runner's transaction-scoped advisory lock on the actor —
+ /// the ticket index and the lobby-member index live on different tables and cannot see each other (Risk #2).
+ ///
+ Task GetActiveTicketForUserAsync(Guid userId, CancellationToken ct = default);
+
+ /// The active assignment for a ticket, or null. Used to project a matched ticket's handoff.
+ Task GetActiveAssignmentAsync(Guid ticketId, CancellationToken ct = default);
+
+ // ── Worker claim ─────────────────────────────────────────────────────────
+
+ ///
+ /// Claims up to queued, not-yet-expired tickets for this worker cycle, using
+ /// SELECT … FOR UPDATE SKIP LOCKED so two workers never block on — or double-claim — the same row.
+ /// Oldest first, which is what anchors proposals on the longest-waiting ticket (anti-starvation).
+ ///
+ ///
+ /// The row lock is not exclusivity (Risk #1). It stops two workers contending on one
+ /// row; a requeued ticket or a serialization retry can still attempt a second assignment. The partial unique
+ /// index UNIQUE (TicketId) WHERE State = 'Active' is the correctness boundary that makes double
+ /// assignment impossible, and the two-worker test asserts against exactly that.
+ ///
+ ///
+ ///
+ /// Must be called inside a transaction: the lock is transaction-scoped, so a worker crash rolls the claim back
+ /// and returns the ticket to Queued with no compensating action and no lease to expire.
+ ///
+ ///
+ /// Returns tracked entities — the caller mutates and saves them in the same unit of work.
+ ///
+ Task> ClaimQueuedTicketsAsync(
+ int batchSize, DateTime nowUtc, CancellationToken ct = default);
+
+ // ── Expiry sweep ─────────────────────────────────────────────────────────
+
+ /// Nonterminal tickets past their deadline, tracked, bounded. Oldest first.
+ Task> GetExpiredTicketsAsync(
+ DateTime nowUtc, int batchSize, CancellationToken ct = default);
+
+ // ── Observability ────────────────────────────────────────────────────────
+
+ ///
+ /// The age of the oldest queued ticket, or null when the queue is empty. Backs the
+ /// matchmaking-queue-age signal — the one number that says whether the queue is healthy without
+ /// exposing who is in it.
+ ///
+ Task GetOldestQueuedAgeAsync(DateTime nowUtc, CancellationToken ct = default);
+
+ // ── Cross-module reads ───────────────────────────────────────────────────
+
+ ///
+ /// Every M3 block among , in one round trip.
+ ///
+ /// Deliberately not called once per ticket owner:
+ /// that is the right shape for a lobby roster (one actor against a handful of members) and the wrong one for a
+ /// worker batch, where it would issue one query per ticket every cycle — a self-inflicted load problem that
+ /// grows exactly when the queue is busiest.
+ ///
+ Task> GetBlockedPairsAsync(
+ IReadOnlyList userIds, CancellationToken ct = default);
+
+ // ── Writes ───────────────────────────────────────────────────────────────
+
+ Task AddTicketAsync(MatchmakingTicket ticket, CancellationToken ct = default);
+
+ ///
+ /// Commits one proposal: every member ticket's state change, one per ticket
+ /// sharing a group id, and the single MatchRequestedV1 — all in one SaveChanges, so an assignment
+ /// can never exist without its event, nor an event without its assignment.
+ ///
+ Task AddAssignmentsAsync(
+ IReadOnlyList assignments,
+ IReadOnlyList events,
+ CancellationToken ct = default);
+
+ /// Persists whatever the caller mutated on already-tracked entities, plus any events.
+ Task SaveAsync(IReadOnlyList events, CancellationToken ct = default);
+}
diff --git a/src/SimPle.Application/Common/Interfaces/IOutboxRepository.cs b/src/SimPle.Application/Common/Interfaces/IOutboxRepository.cs
new file mode 100644
index 0000000..0be1482
--- /dev/null
+++ b/src/SimPle.Application/Common/Interfaces/IOutboxRepository.cs
@@ -0,0 +1,52 @@
+using SimPle.Domain.Outbox;
+
+namespace SimPle.Application.Common.Interfaces;
+
+///
+/// Data access for the transactional outbox's delivery side (D3). The producing side —
+/// staging rows inside the transaction that caused them — belongs to each module's own
+/// repository and is not here.
+///
+public interface IOutboxRepository
+{
+ ///
+ /// Leases up to undelivered messages for one handler, oldest first.
+ ///
+ ///
+ /// "Undelivered" means: the message's type is one this handler consumes, and it has no
+ /// row for this handler that is Processed, DeadLettered, or currently
+ /// leased to a live dispatcher. There is deliberately no global "processed" flag on the message — each handler
+ /// tracks its own progress, so one slow or failing consumer can never starve another, and a message is retained
+ /// until every registered handler has acknowledged it.
+ ///
+ ///
+ ///
+ /// The lease is what makes two dispatcher instances safe. It is taken with FOR UPDATE SKIP LOCKED over
+ /// the delivery rows in the same transaction that writes the lease expiry, so a second dispatcher skips rows the
+ /// first is holding rather than blocking on them — and an expired lease (a crashed dispatcher) is reclaimable,
+ /// which is why it is a timestamp and not a boolean.
+ ///
+ ///
+ ///
+ /// Returns the messages together with their (created-or-reclaimed) delivery rows, tracked. The caller marks each
+ /// one processed or failed and saves.
+ ///
+ ///
+ Task> LeaseAsync(
+ string handlerName,
+ IReadOnlyList eventTypes,
+ int batchSize,
+ DateTime nowUtc,
+ DateTime leaseUntilUtc,
+ CancellationToken ct = default);
+
+ /// Persists the delivery-row state changes the dispatcher made.
+ Task SaveAsync(CancellationToken ct = default);
+
+ ///
+ /// How far behind the oldest unprocessed delivery is, or null when there is none. Backs the outbox lag
+ /// signal: a dispatcher that has quietly stopped looks exactly like an idle one until this number grows.
+ ///
+ Task GetOldestPendingAgeAsync(
+ string handlerName, IReadOnlyList eventTypes, DateTime nowUtc, CancellationToken ct = default);
+}
diff --git a/src/SimPle.Application/Common/Interfaces/IWorkerTransaction.cs b/src/SimPle.Application/Common/Interfaces/IWorkerTransaction.cs
new file mode 100644
index 0000000..d289a7a
--- /dev/null
+++ b/src/SimPle.Application/Common/Interfaces/IWorkerTransaction.cs
@@ -0,0 +1,33 @@
+namespace SimPle.Application.Common.Interfaces;
+
+///
+/// One background-worker unit of work: a single transaction, retried as a whole on contention.
+///
+///
+/// This is 's sibling, minus the advisory lock. That difference is deliberate and
+/// is the reason there are two: the command runner serializes one actor's seat-acquiring commands against
+/// each other, which is meaningless for a worker — a worker has no actor, and taking a lock keyed on some arbitrary
+/// ticket's owner would serialize unrelated workers for no benefit. What the worker needs from a transaction is the
+/// other property: FOR UPDATE SKIP LOCKED row locks are transaction-scoped, so they must be taken and
+/// released inside one, and a crash mid-cycle must roll the claims back rather than strand them.
+///
+///
+///
+/// Contention is retried rather than surfaced. A worker has nobody to report a conflict to, and the losing side of
+/// a claim race has nothing to apologise for — the tickets it failed to claim are still Queued and the next
+/// cycle will see them.
+///
+///
+public interface IWorkerTransaction
+{
+ ///
+ /// Runs inside one transaction. On contention the whole delegate is re-run — including
+ /// its reads — after the change tracker is cleared, so the rerun genuinely re-reads rather than re-deciding
+ /// against the losing attempt's stale entities.
+ ///
+ /// If the retry budget is exhausted the exception propagates: the caller is a background loop, and the honest
+ /// response to persistent contention there is to log the cycle as failed and try again on the next tick, not to
+ /// invent a result.
+ ///
+ Task RunAsync(Func> work, CancellationToken ct = default);
+}
diff --git a/src/SimPle.Application/Common/Options/LobbyCredentialOptions.cs b/src/SimPle.Application/Common/Options/LobbyCredentialOptions.cs
new file mode 100644
index 0000000..e6b9496
--- /dev/null
+++ b/src/SimPle.Application/Common/Options/LobbyCredentialOptions.cs
@@ -0,0 +1,24 @@
+namespace SimPle.Application.Common.Options;
+
+///
+/// The server key used to compute keyed digests of lobby join codes and link tokens.
+///
+/// This is a secret and, like Jwt:SecretKey and Recaptcha:SecretKey, must be configured
+/// outside committed appsettings (environment variable LobbyCredential__Key, user-secrets, or the
+/// deployment's secret store). Startup validation fails closed when it is absent or a placeholder, so the module
+/// can never silently fall back to an unkeyed or well-known digest — which would make every join code in the
+/// database offline-guessable.
+///
+public sealed class LobbyCredentialOptions
+{
+ public const string SectionName = "LobbyCredential";
+
+ /// At least 32 characters, matching the JWT key's floor.
+ public string Key { get; set; } = string.Empty;
+
+ ///
+ /// The region a lobby/ticket falls back to when neither the request nor the user's profile names an
+ /// allow-listed one. Must itself be allow-listed (LobbyAllowLists.Regions).
+ ///
+ public string DefaultRegion { get; set; } = "eu-west";
+}
diff --git a/src/SimPle.Application/Common/Options/MatchmakingOptions.cs b/src/SimPle.Application/Common/Options/MatchmakingOptions.cs
new file mode 100644
index 0000000..efd3058
--- /dev/null
+++ b/src/SimPle.Application/Common/Options/MatchmakingOptions.cs
@@ -0,0 +1,74 @@
+namespace SimPle.Application.Common.Options;
+
+///
+/// The matching worker's loop and batch shape (slice 6C).
+///
+/// None of these are secrets, and all have working defaults — the module runs correctly with no configuration at
+/// all. They exist so a deployment can widen the batch or slow the loop without a rebuild.
+///
+public sealed class MatchmakingOptions
+{
+ public const string SectionName = "Matchmaking";
+
+ ///
+ /// How often a worker attempts a cycle. Two seconds matches the client's ticket poll: a shorter loop would make
+ /// the queue no faster from the player's side, since they cannot learn about a match until their next poll.
+ ///
+ public TimeSpan Interval { get; set; } = TimeSpan.FromSeconds(2);
+
+ ///
+ /// Tickets claimed per cycle. Bounded on purpose: an unbounded claim would let one worker take the whole queue
+ /// and turn a second worker into a spectator, and would make a single failed cycle roll back everything.
+ ///
+ public int BatchSize { get; set; } = 100;
+
+ ///
+ /// Whether the matching worker is hosted at all. Distinct from the M8 readiness gate — that gate is the
+ /// correctness boundary (the cycle refuses to run without a match runtime, no matter what this says);
+ /// this is an operational switch for the rollback plan, which calls for disabling the workers while preserving
+ /// every lobby and ticket record.
+ ///
+ public bool WorkerEnabled { get; set; } = true;
+}
+
+/// The expiry sweep's loop and batch shape. The sweep runs with or without Module 8 — see IExpirySweeper.
+public sealed class ExpiryOptions
+{
+ public const string SectionName = "Expiry";
+
+ ///
+ /// Comfortably inside the benchmark's 5-second expiry-lag budget, so a ticket's honest TimedOut lands
+ /// within a poll or two of its actual deadline rather than whenever the sweep next happens to wake.
+ ///
+ public TimeSpan Interval { get; set; } = TimeSpan.FromSeconds(2);
+
+ /// Rows of each kind (tickets, lobbies, invites) per sweep.
+ public int BatchSize { get; set; } = 200;
+
+ public bool WorkerEnabled { get; set; } = true;
+}
+
+/// The outbox dispatcher's loop, batch, lease, and retry budget (D3).
+public sealed class OutboxOptions
+{
+ public const string SectionName = "Outbox";
+
+ public TimeSpan Interval { get; set; } = TimeSpan.FromSeconds(5);
+
+ public int BatchSize { get; set; } = 50;
+
+ ///
+ /// How long a leased delivery is considered in-flight. It must comfortably exceed the slowest handler, because
+ /// a lease that expires while its handler is still working invites a second dispatcher to run the same event
+ /// concurrently — survivable (handlers are idempotent) but wasteful, and it makes the attempt count lie.
+ ///
+ public TimeSpan LeaseDuration { get; set; } = TimeSpan.FromMinutes(1);
+
+ ///
+ /// Attempts before a delivery is dead-lettered. Bounded so that one permanently-broken handler cannot retry
+ /// forever and crowd out every other event in the batch.
+ ///
+ public int MaxAttempts { get; set; } = 5;
+
+ public bool WorkerEnabled { get; set; } = true;
+}
diff --git a/src/SimPle.Application/DependencyInjection.cs b/src/SimPle.Application/DependencyInjection.cs
index 01a6aec..9911f37 100644
--- a/src/SimPle.Application/DependencyInjection.cs
+++ b/src/SimPle.Application/DependencyInjection.cs
@@ -1,8 +1,13 @@
using Microsoft.Extensions.DependencyInjection;
using SimPle.Application.Auth.Services;
+using SimPle.Application.Expiry;
using SimPle.Application.Friends.Services;
using SimPle.Application.GameHost.Services;
using SimPle.Application.Games.Services;
+using SimPle.Application.Lobbies.Services;
+using SimPle.Application.Matchmaking.Services;
+using SimPle.Application.Outbox;
+using SimPle.Application.Outbox.Handlers;
using SimPle.Application.People.Services;
using SimPle.Application.Profiles.Services;
@@ -17,6 +22,19 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+
+ // Module 6, slice 6C. The coordinator and the sweeper are application services rather than logic inside a
+ // BackgroundService on purpose: that is what lets the real-Postgres tests run two coordinators concurrently
+ // and assert zero duplicate assignment, which a timer tick buried in a hosted service could not.
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ // The outbox dispatcher (D3) — the codebase's first consumer side. M7/M8/M11 register their own
+ // IOutboxHandler here and inherit the machinery; they do not each build a consumer.
+ services.AddScoped();
+ services.AddScoped();
// IGameRegistry is registered separately by the composition root: building it requires the list of
// installed IHostedGameDefinition instances, which is composition-root knowledge (currently empty —
diff --git a/src/SimPle.Application/Expiry/ExpirySweeper.cs b/src/SimPle.Application/Expiry/ExpirySweeper.cs
new file mode 100644
index 0000000..b352b76
--- /dev/null
+++ b/src/SimPle.Application/Expiry/ExpirySweeper.cs
@@ -0,0 +1,100 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using SimPle.Application.Common.Interfaces;
+using SimPle.Application.Common.Options;
+using SimPle.Application.Lobbies.Outbox;
+using SimPle.Domain.Outbox;
+
+namespace SimPle.Application.Expiry;
+
+///
+/// The bounded expiry sweep. See for why it runs without Module 8 and why credentials
+/// are not swept.
+///
+public sealed class ExpirySweeper : IExpirySweeper
+{
+ private readonly IMatchmakingRepository _tickets;
+ private readonly ILobbyRepository _lobbies;
+ private readonly IWorkerTransaction _transaction;
+ private readonly ExpiryOptions _options;
+ private readonly TimeProvider _clock;
+ private readonly ILogger _logger;
+
+ public ExpirySweeper(
+ IMatchmakingRepository tickets,
+ ILobbyRepository lobbies,
+ IWorkerTransaction transaction,
+ IOptions options,
+ TimeProvider clock,
+ ILogger logger)
+ {
+ _tickets = tickets;
+ _lobbies = lobbies;
+ _transaction = transaction;
+ _options = options.Value;
+ _clock = clock;
+ _logger = logger;
+ }
+
+ public Task SweepAsync(CancellationToken ct = default) =>
+ _transaction.RunAsync(async token =>
+ {
+ var nowUtc = _clock.GetUtcNow().UtcDateTime;
+ var batchSize = _options.BatchSize;
+
+ var ticketRows = await _tickets.GetExpiredTicketsAsync(nowUtc, batchSize, token);
+ var lobbyRows = await _lobbies.GetExpiredLobbiesAsync(nowUtc, batchSize, token);
+ var inviteRows = await _lobbies.GetExpiredInvitesAsync(nowUtc, batchSize, token);
+
+ var ticketsExpired = 0;
+ var maxLag = TimeSpan.Zero;
+ foreach (var ticket in ticketRows)
+ {
+ if (!ticket.TryTimeOut(nowUtc)) continue; // idempotent: already terminal, or not actually due
+
+ ticketsExpired++;
+
+ // How late we were, per ticket. This is the signal — a sweep whose lag is creeping up is a sweep
+ // that is about to start letting tickets outlive their own deadline visibly.
+ var lag = nowUtc - ticket.DeadlineAtUtc;
+ if (lag > maxLag) maxLag = lag;
+ }
+
+ var events = new List();
+
+ var lobbiesExpired = 0;
+ foreach (var lobby in lobbyRows)
+ {
+ if (!lobby.TryExpire(nowUtc)) continue;
+
+ lobbiesExpired++;
+
+ // The close event carries the reason the aggregate recorded (Expired), so a consumer never has to
+ // infer why a lobby ended from the absence of anything else.
+ events.Add(LobbyOutbox.LobbyClosedEvent(lobby));
+ }
+
+ var invitesExpired = 0;
+ foreach (var invite in inviteRows)
+ {
+ if (invite.TryExpire(nowUtc)) invitesExpired++;
+ }
+
+ // An expired invite emits no event: nobody acted, and M11 has no notification to send for "an invite you
+ // ignored has quietly lapsed". The state change alone is the record.
+ if (ticketsExpired + lobbiesExpired + invitesExpired > 0)
+ {
+ // One save covers tickets, lobbies, and invites: both repositories are scoped over the *same*
+ // AppDbContext, so every entity read above is tracked by one change tracker and commits in one
+ // transaction. Saving through each repository in turn would split the sweep into three commits and
+ // let a crash leave a lobby expired but its invites still pending.
+ await _tickets.SaveAsync(events, token);
+
+ _logger.LogInformation(
+ "Expiry sweep. Tickets={Tickets} Lobbies={Lobbies} Invites={Invites} MaxTicketLagMs={LagMs}",
+ ticketsExpired, lobbiesExpired, invitesExpired, (long)maxLag.TotalMilliseconds);
+ }
+
+ return new ExpirySweepResult(ticketsExpired, lobbiesExpired, invitesExpired, maxLag);
+ }, ct);
+}
diff --git a/src/SimPle.Application/Expiry/IExpirySweeper.cs b/src/SimPle.Application/Expiry/IExpirySweeper.cs
new file mode 100644
index 0000000..09cd5e3
--- /dev/null
+++ b/src/SimPle.Application/Expiry/IExpirySweeper.cs
@@ -0,0 +1,47 @@
+namespace SimPle.Application.Expiry;
+
+///
+/// What one expiry sweep did. is how far past its deadline the most overdue ticket
+/// in this batch was — the module's expiry lag signal, and the number the benchmark holds under 5 seconds.
+/// It is measured, not assumed: a sweep that silently fell behind would otherwise look identical to one that had
+/// nothing to do.
+///
+public sealed record ExpirySweepResult(
+ int TicketsExpired,
+ int LobbiesExpired,
+ int InvitesExpired,
+ TimeSpan MaxTicketLag)
+{
+ public static readonly ExpirySweepResult Empty = new(0, 0, 0, TimeSpan.Zero);
+
+ public int Total => TicketsExpired + LobbiesExpired + InvitesExpired;
+}
+
+///
+/// Sweeps tickets past their 60-second deadline, lobbies past their 2-hour lifetime, and invites past their
+/// 30-minute lifetime.
+///
+///
+/// Unlike matching, the sweep runs with or without Module 8. That is the honest half of the queue:
+/// a player who enqueues before a match runtime exists still watches their band widen and still gets a truthful
+/// TimedOut at sixty seconds, instead of a ticket that sits Queued forever because the only thing
+/// that could have resolved it does not exist yet.
+///
+///
+///
+/// Every transition it drives is idempotent at the domain level (TryTimeOut / TryExpire return false
+/// rather than transitioning twice), so a sweep that overlaps with a previous run, or re-runs after a crash, cannot
+/// double-expire anything.
+///
+///
+///
+/// Join credentials are deliberately not swept. A credential is already dead the moment it is past its
+/// deadline — LobbyJoinCredential.CanRedeem checks the clock, and the join path calls it — so a sweep would
+/// change nothing a caller can observe. Rewriting its state row would be bookkeeping that buys no behavior, and the
+/// row is retained as the audit trail of which generation was live when.
+///
+///
+public interface IExpirySweeper
+{
+ Task SweepAsync(CancellationToken ct = default);
+}
diff --git a/src/SimPle.Application/Games/GameEntryActions.cs b/src/SimPle.Application/Games/GameEntryActions.cs
index 32e6994..f20542b 100644
--- a/src/SimPle.Application/Games/GameEntryActions.cs
+++ b/src/SimPle.Application/Games/GameEntryActions.cs
@@ -6,15 +6,17 @@ namespace SimPle.Application.Games;
/// The fixed set of entry-point actions projected onto every catalog/detail DTO. In M4 every action is
/// deferred — no game engine exists yet — per the spec's entry-actions table. A later module flips its
/// own action to enabled only after its backend and E2E gate pass; this list is never mutated per-request.
+/// M6 (Lobby & Matchmaking System) flipped its 3 owned actions after its backend + E2E gate passed; the
+/// frontend still re-checks each enabled action against the specific game's capabilities/player count.
///
public static class GameEntryActions
{
public static readonly IReadOnlyList All = new[]
{
new GameEntryActionDto("play-vs-ai", "deferred", "Games.EntryDeferred.AI", 9),
- new GameEntryActionDto("quick-match", "deferred", "Games.EntryDeferred.QuickMatch", 6),
- new GameEntryActionDto("create-lobby", "deferred", "Games.EntryDeferred.Lobby", 6),
- new GameEntryActionDto("invite-friend", "deferred", "Games.EntryDeferred.Invite", 6),
+ new GameEntryActionDto("quick-match", "enabled", "Games.EntryDeferred.QuickMatch", 6),
+ new GameEntryActionDto("create-lobby", "enabled", "Games.EntryDeferred.Lobby", 6),
+ new GameEntryActionDto("invite-friend", "enabled", "Games.EntryDeferred.Invite", 6),
new GameEntryActionDto("enter-match-room", "deferred", "Games.EntryDeferred.MatchRoom", 8),
};
}
diff --git a/src/SimPle.Application/Lobbies/DTOs/LobbyDtos.cs b/src/SimPle.Application/Lobbies/DTOs/LobbyDtos.cs
new file mode 100644
index 0000000..bf8b900
--- /dev/null
+++ b/src/SimPle.Application/Lobbies/DTOs/LobbyDtos.cs
@@ -0,0 +1,146 @@
+using SimPle.Shared.Common;
+
+namespace SimPle.Application.Lobbies.DTOs;
+
+///
+/// The full lobby view for a member or an authorized viewer.
+///
+/// It deliberately carries no credential. The join code and link token appear in exactly one place —
+/// , returned only by create and rotate — so there is no read path that could
+/// hand a private code to someone who merely holds the lobby id (Risk #7).
+///
+/// and exist so the client can render
+/// honest enabled/disabled controls without re-deriving host role, lifecycle, or which later modules are live.
+/// A client that guessed would eventually guess wrong and offer an action the server rejects.
+///
+public sealed record LobbyDto(
+ Guid LobbyId,
+ string GameSlug,
+ int CapabilityVersion,
+ string Privacy,
+ int MaxPlayers,
+ string TimeControlId,
+ bool Rated,
+ string ResolvedRegion,
+ string SpectatorPolicy,
+ string TieBreakRuleId,
+ bool AiFillRequested,
+ string State,
+ int Revision,
+ DateTime ExpiresAtUtc,
+ string? ClosedReason,
+ Guid HostUserId,
+ IReadOnlyList Seats,
+ IReadOnlyList AllowedActions,
+ DependencyReadinessDto DependencyReadiness);
+
+///
+/// One seat. Identity is the canonical shared — the same shape M3 search and
+/// friend lists use — so a member avatar/name always links to the real profile and a lobby never invents its own
+/// identity fields.
+///
+public sealed record LobbySeatDto(
+ PublicIdentityDto Identity,
+ bool IsHost,
+ bool IsReady,
+ DateTime JoinedAtUtc);
+
+///
+/// Which downstream modules are actually live. Every value is false in Module 6 and is read from the
+/// real probe, never hardcoded in the client — so when M7/M8/M9 land, the UI turns on without a frontend
+/// change, and until then it cannot claim an action that would fail.
+///
+public sealed record DependencyReadinessDto(
+ bool Chat,
+ bool MatchRuntime,
+ bool AiParticipants);
+
+///
+/// The plaintext join credential. Returned only from create and rotate, and never persisted in this
+/// form — the database holds keyed digests alone. Rotating supersedes the previous value immediately.
+///
+public sealed record LobbyCredentialDto(
+ string Code,
+ string LinkToken,
+ int Generation,
+ DateTime ExpiresAtUtc);
+
+public sealed record CreateLobbyResultDto(
+ LobbyDto Lobby,
+ LobbyCredentialDto Credential);
+
+///
+/// A public-discovery row. Strictly narrower than : no seat roster, no readiness, no
+/// credential — a lobby you have not joined tells you only what you need in order to decide to join it.
+/// Private lobbies never appear here at all.
+///
+public sealed record LobbySummaryDto(
+ Guid LobbyId,
+ string GameSlug,
+ int MaxPlayers,
+ int JoinedCount,
+ string TimeControlId,
+ bool Rated,
+ string ResolvedRegion,
+ string SpectatorPolicy,
+ PublicIdentityDto Host,
+ DateTime CreatedAt,
+ DateTime ExpiresAtUtc);
+
+public sealed record LobbyInviteDto(
+ Guid InviteId,
+ Guid LobbyId,
+ string GameSlug,
+ PublicIdentityDto Inviter,
+ string State,
+ DateTime ExpiresAtUtc);
+
+///
+/// The dashboard's "what am I currently in" query. Exactly one of the two is ever set — that is the
+/// one-active-lobby-or-ticket invariant, surfaced rather than re-derived by the client.
+///
+public sealed record ActiveContextDto(
+ LobbyDto? Lobby,
+ Guid? TicketId);
+
+///
+/// The outcome of a start attempt. Before M8 registers a consumer no start can succeed at all, so this type is
+/// reachable only once M8 exists; the honest 503 (Lobbies.MatchRuntimeUnavailable) is what M6 returns
+/// today. A committed is a durable request, never a created match.
+///
+public sealed record StartLobbyResultDto(
+ Guid LobbyId,
+ Guid MatchRequestId,
+ string State,
+ int Revision);
+
+///
+/// The capability source (D2), read-only. Lets a client populate capabilityVersion and the allowed
+/// modes/time controls/tie-break rules/spectator policies before calling create-lobby or create-ticket,
+/// instead of guessing values the server will reject. Always the highest-versioned IsActive profile for the
+/// slug — a lobby/ticket already in flight keeps meaning what it meant at creation via its own pinned version, so
+/// this route is never used to reinterpret an existing lobby.
+///
+public sealed record GameCapabilityProfileDto(
+ string GameSlug,
+ int CapabilityVersion,
+ int MinPlayers,
+ int MaxPlayers,
+ IReadOnlyList AllowedModes,
+ IReadOnlyList TimeControls,
+ IReadOnlyList TieBreakRules,
+ IReadOnlyList SpectatorPolicies,
+ bool RatedEligible,
+ bool AiFillEligible);
+
+/// The action names surfaced in .
+public static class LobbyActions
+{
+ public const string Leave = "leave";
+ public const string Ready = "ready";
+ public const string Settings = "settings";
+ public const string Invite = "invite";
+ public const string Kick = "kick";
+ public const string RotateCredential = "rotate-credential";
+ public const string Start = "start";
+}
diff --git a/src/SimPle.Application/Lobbies/DTOs/LobbyRequests.cs b/src/SimPle.Application/Lobbies/DTOs/LobbyRequests.cs
new file mode 100644
index 0000000..3bdcac8
--- /dev/null
+++ b/src/SimPle.Application/Lobbies/DTOs/LobbyRequests.cs
@@ -0,0 +1,76 @@
+namespace SimPle.Application.Lobbies.DTOs;
+
+///
+/// Lobby creation. may be "Auto" or omitted — the server resolves it and stores
+/// an explicit region; "Auto" is never persisted.
+///
+/// There is deliberately no hostUserId: the actor is always the JWT sub claim. A body-supplied
+/// identity is the classic BOLA vector and is not accepted anywhere in this module.
+///
+public sealed record CreateLobbyRequestDto(
+ string GameSlug,
+ int CapabilityVersion,
+ string Privacy,
+ int MaxPlayers,
+ string TimeControlId,
+ bool Rated,
+ string? Region,
+ string SpectatorPolicy,
+ string TieBreakRuleId,
+ bool AiFillRequested);
+
+///
+/// Join a lobby. Exactly one of / /
+/// is supplied.
+///
+/// The credential travels in the body, never in the path: a credential is a secret, not a resource
+/// identifier, and a path segment lands in access logs, referrers, and browser history.
+///
+/// is a resource id, not a secret — it is only ever honored when the target lobby is
+/// currently Public and Open (the same visibility rule as the public browse listing and single-lobby
+/// read), so naming a private or foreign lobby's id here answers with the same privacy-safe not-found as everywhere
+/// else in this module. It carries no join throttle: unlike a guessed code, it identifies a specific already-public
+/// row rather than searching a credential space.
+///
+public sealed record JoinLobbyRequestDto(
+ string? Code,
+ string? LinkToken,
+ Guid? LobbyId = null);
+
+///
+/// Host settings change. Sends the whole settings tuple, so a partial update can never leave the lobby
+/// half-validated. is the optimistic-concurrency token: a mismatch is a typed
+/// Lobbies.StaleRevision carrying the current state, never a 500 and never a silent overwrite.
+///
+public sealed record UpdateLobbySettingsRequestDto(
+ string GameSlug,
+ int CapabilityVersion,
+ string Privacy,
+ int MaxPlayers,
+ string TimeControlId,
+ bool Rated,
+ string? Region,
+ string SpectatorPolicy,
+ string TieBreakRuleId,
+ bool AiFillRequested,
+ int ExpectedRevision);
+
+public sealed record SetReadinessRequestDto(
+ bool IsReady,
+ int ExpectedRevision);
+
+public sealed record KickMemberRequestDto(
+ Guid TargetUserId,
+ int ExpectedRevision);
+
+public sealed record CreateInviteRequestDto(
+ Guid InviteeUserId);
+
+///
+/// Start. makes a client retry replay the existing request rather than mint a
+/// second one — the partial unique index on (LobbyId, LobbyRevision) WHERE State = 'Open' is what actually
+/// enforces that; the key is how the caller recognizes its own prior attempt.
+///
+public sealed record StartLobbyRequestDto(
+ int ExpectedRevision,
+ string IdempotencyKey);
diff --git a/src/SimPle.Application/Lobbies/Outbox/LobbyOutbox.cs b/src/SimPle.Application/Lobbies/Outbox/LobbyOutbox.cs
new file mode 100644
index 0000000..fd09138
--- /dev/null
+++ b/src/SimPle.Application/Lobbies/Outbox/LobbyOutbox.cs
@@ -0,0 +1,121 @@
+using System.Text.Json;
+using SimPle.Domain.Lobbies;
+using SimPle.Domain.Outbox;
+
+namespace SimPle.Application.Lobbies.Outbox;
+
+///
+/// Builds Module 6's lobby integration events, mirroring
+/// and . Every payload carries minimum ids only.
+///
+/// No join code, link token, or digest ever appears in a payload. An outbox row is durable,
+/// replayable, and read by every future consumer (M7/M8/M11) — a credential that reached one would be permanently
+/// disclosed to modules that have no business holding it (Risk #7).
+///
+/// Each message is staged inside the same transaction as the aggregate mutation. The unique
+/// (AggregateId, EventType, AggregateDomainVersion) index is what makes a retried transition idempotent:
+/// is the domain version, so replaying a command at the same revision cannot stage
+/// the same logical event twice.
+///
+/// MatchRequestedV1 is the one event M8 consumes to create a match. Committing it means a durable
+/// request exists — never that a match does (Risk #6).
+///
+public static class LobbyOutbox
+{
+ public const int EventVersion = 1;
+
+ private const string LobbyAggregate = "Lobby";
+ private const string InviteAggregate = "LobbyInvite";
+ private const string StartRequestAggregate = "LobbyStartRequest";
+
+ public const string LobbyCreated = "LobbyCreatedV1";
+ public const string LobbyMemberJoined = "LobbyMemberJoinedV1";
+ public const string LobbyMemberLeft = "LobbyMemberLeftV1";
+ public const string LobbyMemberKicked = "LobbyMemberKickedV1";
+ public const string LobbyHostTransferred = "LobbyHostTransferredV1";
+ public const string LobbySettingsChanged = "LobbySettingsChangedV1";
+ public const string LobbyClosed = "LobbyClosedV1";
+ public const string LobbyInviteCreated = "LobbyInviteCreatedV1";
+ public const string LobbyInviteAccepted = "LobbyInviteAcceptedV1";
+ public const string LobbyInviteRevoked = "LobbyInviteRevokedV1";
+ public const string LobbyCredentialRotated = "LobbyCredentialRotatedV1";
+ public const string MatchRequested = "MatchRequestedV1";
+
+ public static OutboxMessage LobbyCreatedEvent(Lobby lobby) =>
+ LobbyEvent(lobby, LobbyCreated, new { lobbyId = lobby.Id, hostUserId = lobby.HostUserId, gameSlug = lobby.GameSlug });
+
+ public static OutboxMessage MemberJoinedEvent(Lobby lobby, Guid userId) =>
+ LobbyEvent(lobby, LobbyMemberJoined, new { lobbyId = lobby.Id, userId });
+
+ public static OutboxMessage MemberLeftEvent(Lobby lobby, Guid userId) =>
+ LobbyEvent(lobby, LobbyMemberLeft, new { lobbyId = lobby.Id, userId });
+
+ public static OutboxMessage MemberKickedEvent(Lobby lobby, Guid userId, Guid removedByUserId) =>
+ LobbyEvent(lobby, LobbyMemberKicked, new { lobbyId = lobby.Id, userId, removedByUserId });
+
+ public static OutboxMessage HostTransferredEvent(Lobby lobby, Guid newHostUserId) =>
+ LobbyEvent(lobby, LobbyHostTransferred, new { lobbyId = lobby.Id, newHostUserId });
+
+ public static OutboxMessage SettingsChangedEvent(Lobby lobby) =>
+ LobbyEvent(lobby, LobbySettingsChanged, new { lobbyId = lobby.Id, gameSlug = lobby.GameSlug, capabilityVersion = lobby.CapabilityVersion });
+
+ public static OutboxMessage LobbyClosedEvent(Lobby lobby) =>
+ LobbyEvent(lobby, LobbyClosed, new { lobbyId = lobby.Id, reason = lobby.ClosedReason?.ToString() });
+
+ /// Rotation carries the generation only — never the old or new digest, let alone the plaintext.
+ public static OutboxMessage CredentialRotatedEvent(Lobby lobby, int generation) =>
+ LobbyEvent(lobby, LobbyCredentialRotated, new { lobbyId = lobby.Id, generation });
+
+ ///
+ /// 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.
+ ///
+ public static OutboxMessage MatchRequestedEvent(Lobby lobby, LobbyStartRequest request) =>
+ OutboxMessage.Create(
+ StartRequestAggregate, request.Id, MatchRequested, EventVersion,
+ aggregateDomainVersion: request.LobbyRevision, requestCycleId: request.LobbyRevision,
+ Serialize(new
+ {
+ matchRequestId = request.MatchRequestId,
+ lobbyId = request.LobbyId,
+ lobbyRevision = request.LobbyRevision,
+ }));
+
+ public static OutboxMessage InviteCreatedEvent(LobbyInvite invite) =>
+ InviteEvent(invite, LobbyInviteCreated);
+
+ public static OutboxMessage InviteAcceptedEvent(LobbyInvite invite) =>
+ InviteEvent(invite, LobbyInviteAccepted);
+
+ public static OutboxMessage InviteRevokedEvent(LobbyInvite invite) =>
+ InviteEvent(invite, LobbyInviteRevoked);
+
+ ///
+ /// The lobby's doubles as the aggregate domain version: it is bumped by exactly
+ /// the mutations that emit an event, so the outbox's uniqueness index makes a replayed command a no-op rather
+ /// than a duplicate delivery.
+ ///
+ private static OutboxMessage LobbyEvent(Lobby lobby, string eventType, object payload) =>
+ OutboxMessage.Create(
+ LobbyAggregate, lobby.Id, eventType, EventVersion,
+ aggregateDomainVersion: lobby.Revision, requestCycleId: lobby.Revision,
+ Serialize(payload));
+
+ ///
+ /// An invite has no revision counter — but its lifecycle is Pending -> Accepted|Revoked|Expired,
+ /// a single terminal step, so the state ordinal is a sufficient and stable domain version.
+ ///
+ private static OutboxMessage InviteEvent(LobbyInvite invite, string eventType) =>
+ OutboxMessage.Create(
+ InviteAggregate, invite.Id, eventType, EventVersion,
+ aggregateDomainVersion: (int)invite.State, requestCycleId: (int)invite.State,
+ Serialize(new
+ {
+ inviteId = invite.Id,
+ lobbyId = invite.LobbyId,
+ inviterUserId = invite.InviterUserId,
+ inviteeUserId = invite.InviteeUserId,
+ }));
+
+ private static string Serialize(object payload) => JsonSerializer.Serialize(payload);
+}
diff --git a/src/SimPle.Application/Lobbies/Services/ILobbiesService.cs b/src/SimPle.Application/Lobbies/Services/ILobbiesService.cs
new file mode 100644
index 0000000..42b8255
--- /dev/null
+++ b/src/SimPle.Application/Lobbies/Services/ILobbiesService.cs
@@ -0,0 +1,140 @@
+using SimPle.Application.Lobbies.DTOs;
+using SimPle.Shared.Common;
+
+namespace SimPle.Application.Lobbies.Services;
+
+///
+/// The lobby command surface (slice 6B). Matchmaking tickets, the matching/expiry workers, and the outbox
+/// dispatcher are slice 6C and are not here.
+///
+/// Every method takes the actor as its first argument and it is always the JWT sub claim — no method
+/// accepts a caller-supplied identity. Authorization is re-evaluated against the object's current membership and
+/// host role on every call, never against a prior step's result.
+///
+public interface ILobbiesService
+{
+ Task> CreateAsync(
+ Guid actorUserId, CreateLobbyRequestDto request, CancellationToken ct = default);
+
+ ///
+ /// The active capability profile (D2) for a game slug — the pinned-version source a client reads before
+ /// calling or a matchmaking ticket create, so it never has to guess a
+ /// capabilityVersion or an allowed time control/tie-break rule/spectator policy.
+ ///
+ Task> GetCapabilityProfileAsync(
+ string gameSlug, CancellationToken ct = default);
+
+ /// Member or authorized viewer. A foreign/private/missing lobby is one indistinguishable 404.
+ Task> GetAsync(Guid actorUserId, Guid lobbyId, CancellationToken ct = default);
+
+ /// Bounded public discovery. Private lobbies never appear and never affect totals or cursors.
+ Task>> GetPublicAsync(
+ Guid actorUserId, int limit, string? cursor, CancellationToken ct = default);
+
+ ///
+ /// Join a lobby. Code/link token (secrets, carried in the body — never a path segment) or a lobbyId (a resource
+ /// identifier, only honored when that lobby is currently Public and Open).
+ ///
+ Task> JoinByCredentialAsync(
+ Guid actorUserId, JoinLobbyRequestDto request, CancellationToken ct = default);
+
+ Task LeaveAsync(Guid actorUserId, Guid lobbyId, CancellationToken ct = default);
+
+ Task> SetReadinessAsync(
+ Guid actorUserId, Guid lobbyId, SetReadinessRequestDto request, CancellationToken ct = default);
+
+ Task> UpdateSettingsAsync(
+ Guid actorUserId, Guid lobbyId, UpdateLobbySettingsRequestDto request, CancellationToken ct = default);
+
+ Task> KickAsync(
+ Guid actorUserId, Guid lobbyId, KickMemberRequestDto request, CancellationToken ct = default);
+
+ Task> RotateCredentialAsync(
+ Guid actorUserId, Guid lobbyId, CancellationToken ct = default);
+
+ Task> CreateInviteAsync(
+ Guid actorUserId, Guid lobbyId, CreateInviteRequestDto request, CancellationToken ct = default);
+
+ Task RevokeInviteAsync(
+ Guid actorUserId, Guid lobbyId, Guid inviteId, CancellationToken ct = default);
+
+ ///
+ /// Redeem a targeted invite (R6 — a route the approved spec's table omits).
+ ///
+ /// Without it an invitee has no way to reach a seat: join takes a credential, and handing every invitee the
+ /// lobby's private code would both leak it and make a revoked invite still redeemable. Accept runs the same
+ /// bounded transaction as a credential join — block re-check, one-active-lobby-or-ticket, capacity, readiness
+ /// reset — so an invited member is seated under exactly the same invariants as any other.
+ ///
+ Task> AcceptInviteAsync(Guid actorUserId, Guid inviteId, CancellationToken ct = default);
+
+ Task DeclineInviteAsync(Guid actorUserId, Guid inviteId, CancellationToken ct = default);
+
+ /// Returns Lobbies.MatchRuntimeUnavailable while no M8 consumer is registered.
+ Task> StartAsync(
+ Guid actorUserId, Guid lobbyId, StartLobbyRequestDto request, CancellationToken ct = default);
+
+ Task>> GetMyInvitesAsync(
+ Guid actorUserId, CancellationToken ct = default);
+
+ /// The dashboard's active lobby or ticket. Never both — that is the invariant.
+ Task> GetMyActiveAsync(Guid actorUserId, CancellationToken ct = default);
+
+ ///
+ /// Rematch from a terminal match. M8 owns matches and does not exist, so there is no terminal match to read
+ /// and this honestly returns Lobbies.MatchRuntimeUnavailable rather than fabricating a lobby from
+ /// settings nobody recorded.
+ ///
+ Task> CreateRematchLobbyAsync(
+ Guid actorUserId, Guid terminalMatchId, CancellationToken ct = default);
+}
+
+///
+/// Module 6's error catalogue, in the codebase's PascalCase dot-namespaced house style
+/// (reconciliation R1 — the brief writes them lobby.snake_case, but every existing
+/// consumer, including the frontend's switch (error.code) mappings, reads Games.NotFound /
+/// Friends.RequestCooldown. Identical semantics; one casing scheme.)
+///
+public static class LobbyErrors
+{
+ ///
+ /// Privacy-safe. Deliberately returned for missing, expired-and-swept, private-and-
+ /// unauthorized, and another user's lobby alike. A 403 here would confirm the id exists, which is
+ /// exactly the BOLA leak (OWASP API1:2023) the 404 exists to close.
+ ///
+ public const string NotFound = "Lobbies.NotFound";
+
+ public const string Full = "Lobbies.Full";
+ public const string Closed = "Lobbies.Closed";
+ public const string Expired = "Lobbies.Expired";
+ public const string Blocked = "Lobbies.Blocked";
+ public const string StaleRevision = "Lobbies.StaleRevision";
+
+ /// Actor is a member but is not the host. Safe to disclose — they can already see the lobby.
+ public const string Forbidden = "Lobbies.Forbidden";
+
+ public const string AlreadyActive = "Lobbies.AlreadyActive";
+ public const string CapabilityDisabled = "Lobbies.CapabilityDisabled";
+
+ /// No active capability profile exists for the requested slug. Not privacy-sensitive — game slugs are
+ /// public catalog data — so this is a plain 404 naming the actual code, unlike .
+ public const string CapabilityNotFound = "Lobbies.CapabilityNotFound";
+
+ /// M8 is not registered. Start, rematch, and queue execution are honestly unavailable.
+ public const string MatchRuntimeUnavailable = "Lobbies.MatchRuntimeUnavailable";
+
+ ///
+ /// Deliberately indistinguishable across wrong / expired / rotated / revoked / closed-lobby.
+ /// Any finer-grained answer would turn the join endpoint into an oracle for which codes are live.
+ ///
+ public const string CredentialInvalid = "Lobbies.CredentialInvalid";
+
+ /// The bounded retry budget was spent. A typed 409 — never a 500 (brief Risk #5).
+ public const string ConcurrencyConflict = "Lobbies.ConcurrencyConflict";
+
+ public const string NotStartable = "Lobbies.NotStartable";
+ public const string InvalidTarget = "Lobbies.InvalidTarget";
+ public const string ValidationFailed = "Validation.Failed";
+ public const string InvalidCursor = "Pagination.InvalidCursor";
+ public const string RateLimitExceeded = "RateLimit.Exceeded";
+}
diff --git a/src/SimPle.Application/Lobbies/Services/ILobbyCredentialHasher.cs b/src/SimPle.Application/Lobbies/Services/ILobbyCredentialHasher.cs
new file mode 100644
index 0000000..828ef6b
--- /dev/null
+++ b/src/SimPle.Application/Lobbies/Services/ILobbyCredentialHasher.cs
@@ -0,0 +1,26 @@
+namespace SimPle.Application.Lobbies.Services;
+
+///
+/// Turns a plaintext join credential into the keyed digest that is the only form ever persisted, and compares a
+/// user-supplied attempt against a stored digest in constant time.
+///
+/// Keyed (HMAC), not a bare hash: a join code carries only ~60 bits, so an attacker who obtained the database could
+/// exhaust an unkeyed digest offline. The server key makes the stored digests useless without it.
+///
+/// Constant-time comparison closes the timing side channel — a byte-by-byte compare would leak how much of a
+/// guessed code was correct, turning a 60-bit search into a per-character one.
+///
+public interface ILobbyCredentialHasher
+{
+ /// Digest of a manual join code. Normalizes the input first, so casing and separators do not matter.
+ string HashCode(string plaintextCode);
+
+ /// Digest of a 128-bit share-link token. Not normalized — the token is machine-generated and exact.
+ string HashLinkToken(string plaintextLinkToken);
+
+ ///
+ /// Constant-time equality of two digests. Always compare through this — never with ==, which
+ /// short-circuits on the first differing byte.
+ ///
+ bool DigestsMatch(string storedDigest, string candidateDigest);
+}
diff --git a/src/SimPle.Application/Lobbies/Services/ILobbyJoinThrottle.cs b/src/SimPle.Application/Lobbies/Services/ILobbyJoinThrottle.cs
new file mode 100644
index 0000000..c2aaff0
--- /dev/null
+++ b/src/SimPle.Application/Lobbies/Services/ILobbyJoinThrottle.cs
@@ -0,0 +1,27 @@
+namespace SimPle.Application.Lobbies.Services;
+
+///
+/// Throttles failed join-credential attempts specifically (OWASP API4:2023).
+///
+/// This cannot be an ASP.NET rate-limit policy, and the distinction matters. A rate limiter spends a permit on
+/// every request, so a policy tight enough to stop code-guessing (a wrong 60-bit code is cheap to spam) would
+/// equally punish members legitimately joining lobbies they were invited to. Only failures are interesting, so
+/// only failures are counted — a caller with a correct code is never throttled by this at all.
+///
+/// The counter is keyed on the authenticated actor, so it survives a client changing IPs, and it is bumped
+/// after the constant-time digest comparison, so it leaks nothing about which part of a guess was right.
+///
+public interface ILobbyJoinThrottle
+{
+ ///
+ /// The instant the actor may next attempt a credential join, or null when they are not throttled.
+ /// Returned rather than a bare bool so the caller can emit an accurate Retry-After.
+ ///
+ Task GetRetryAfterUtcAsync(Guid actorUserId, CancellationToken ct = default);
+
+ /// Records one failed credential attempt. Called only on failure.
+ Task RecordFailureAsync(Guid actorUserId, CancellationToken ct = default);
+
+ /// Clears the actor's failure streak after a successful join.
+ Task ClearAsync(Guid actorUserId, CancellationToken ct = default);
+}
diff --git a/src/SimPle.Application/Lobbies/Services/IMatchRuntimeProbe.cs b/src/SimPle.Application/Lobbies/Services/IMatchRuntimeProbe.cs
new file mode 100644
index 0000000..2e9dc1b
--- /dev/null
+++ b/src/SimPle.Application/Lobbies/Services/IMatchRuntimeProbe.cs
@@ -0,0 +1,47 @@
+namespace SimPle.Application.Lobbies.Services;
+
+///
+/// Module 8's readiness probe, as seen from Module 6.
+///
+/// M8 does not exist. Rather than scatter // TODO: M8 through the command layer, M6 builds the real call
+/// site now against this interface and ships an implementation that honestly reports "no runtime". When M8 lands
+/// it replaces the implementation and every gate below turns on without a single change to the lobby commands.
+///
+/// This is what keeps the brief's central promise enforceable: until a consumer is registered, Start returns
+/// Lobbies.MatchRuntimeUnavailable, the lobby stays Open, and nothing anywhere claims a match was
+/// created (Risk #6).
+///
+public interface IMatchRuntimeProbe
+{
+ ///
+ /// Whether a match runtime is registered and healthy. False in Module 6, which is what makes Start honestly
+ /// unavailable rather than silently broken.
+ ///
+ Task IsAvailableAsync(CancellationToken ct = default);
+
+ ///
+ /// Whether the user is a participant in a live (PendingStart|Active|PausePending|Paused) match.
+ ///
+ /// Re-asked at join, invite-accept, enqueue, assignment, and start rather than once up front — the state can
+ /// change between steps, so a single check would be a race, not a guarantee (brief Risk #2).
+ ///
+ /// With no runtime there are no matches, so this is trivially false today. That is a true answer, not a stub:
+ /// a user genuinely cannot be in a live match when no match can exist.
+ ///
+ Task IsInActiveMatchAsync(Guid userId, CancellationToken ct = default);
+}
+
+///
+/// Module 7's live-delivery readiness, and Module 9's AI participants. Both are surfaced to the client through
+/// dependencyReadiness so the UI renders an honest disabled control naming the owning module instead of
+/// hardcoding "coming soon" copy it would later have to hunt down and remove.
+///
+public interface IChatRuntimeProbe
+{
+ Task IsAvailableAsync(CancellationToken ct = default);
+}
+
+public interface IAiParticipantProbe
+{
+ Task IsAvailableAsync(CancellationToken ct = default);
+}
diff --git a/src/SimPle.Application/Lobbies/Services/LobbiesService.cs b/src/SimPle.Application/Lobbies/Services/LobbiesService.cs
new file mode 100644
index 0000000..75d73fd
--- /dev/null
+++ b/src/SimPle.Application/Lobbies/Services/LobbiesService.cs
@@ -0,0 +1,945 @@
+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.GameHost.Services;
+using SimPle.Application.Lobbies.DTOs;
+using SimPle.Application.Lobbies.Outbox;
+using SimPle.Domain.Games;
+using SimPle.Domain.Lobbies;
+using SimPle.Domain.Outbox;
+using SimPle.Domain.Users;
+using SimPle.Shared.Common;
+
+namespace SimPle.Application.Lobbies.Services;
+
+///
+/// The lobby command surface. Every mutation runs through , which re-runs the
+/// whole read-decide-write delegate on contention (R3) — so the code below may be executed more than once per
+/// request and must therefore make no decision it did not re-read.
+///
+public sealed class LobbiesService : ILobbiesService
+{
+ private readonly ILobbyRepository _lobbies;
+ private readonly ILobbyCommandRunner _runner;
+ private readonly ILobbyCredentialHasher _hasher;
+ private readonly ILobbyJoinThrottle _joinThrottle;
+ private readonly IMatchRuntimeProbe _matchRuntime;
+ private readonly IChatRuntimeProbe _chatRuntime;
+ private readonly IAiParticipantProbe _aiParticipants;
+ private readonly IGameRegistry _engines;
+ private readonly IUserRepository _users;
+ private readonly IFileStorageService _storage;
+ private readonly StorageOptions _storageOptions;
+ private readonly LobbyCredentialOptions _credentialOptions;
+ private readonly TimeProvider _clock;
+ private readonly ILogger _logger;
+
+ private const int DefaultLimit = 20;
+ private const int MaxLimit = 50;
+
+ ///
+ /// Bounded retries when a freshly generated join code collides with a live one (23505 on the code digest
+ /// index). At 60 bits of entropy against a handful of concurrently open lobbies, a single collision is already
+ /// vanishingly unlikely and a second is not a thing that happens — but "vanishingly unlikely" is not "cannot",
+ /// and an unbounded loop on a bug in the generator would spin forever.
+ ///
+ private const int MaxCredentialAttempts = 5;
+
+ public LobbiesService(
+ ILobbyRepository lobbies,
+ ILobbyCommandRunner runner,
+ ILobbyCredentialHasher hasher,
+ ILobbyJoinThrottle joinThrottle,
+ IMatchRuntimeProbe matchRuntime,
+ IChatRuntimeProbe chatRuntime,
+ IAiParticipantProbe aiParticipants,
+ IGameRegistry engines,
+ IUserRepository users,
+ IFileStorageService storage,
+ IOptions storageOptions,
+ IOptions credentialOptions,
+ TimeProvider clock,
+ ILogger logger)
+ {
+ _lobbies = lobbies;
+ _runner = runner;
+ _hasher = hasher;
+ _joinThrottle = joinThrottle;
+ _matchRuntime = matchRuntime;
+ _chatRuntime = chatRuntime;
+ _aiParticipants = aiParticipants;
+ _engines = engines;
+ _users = users;
+ _storage = storage;
+ _storageOptions = storageOptions.Value;
+ _credentialOptions = credentialOptions.Value;
+ _clock = clock;
+ _logger = logger;
+ }
+
+ private DateTime NowUtc => _clock.GetUtcNow().UtcDateTime;
+
+ // ── Create ───────────────────────────────────────────────────────────────
+
+ public Task> CreateAsync(
+ Guid actorUserId, CreateLobbyRequestDto request, CancellationToken ct = default) =>
+ _runner.RunAsync(actorUserId, async token =>
+ {
+ var actor = await _users.GetByIdAsync(actorUserId, token);
+ if (actor is null || actor.IsAccountSuspended())
+ return Fail(LobbyErrors.Forbidden, "Your account cannot host a lobby.");
+
+ var settingsResult = BuildSettings(
+ request.GameSlug, request.CapabilityVersion, request.Privacy, request.MaxPlayers,
+ request.TimeControlId, request.Rated, request.Region, request.SpectatorPolicy,
+ request.TieBreakRuleId, request.AiFillRequested, actor);
+ if (!settingsResult.IsSuccess)
+ return Result.Fail(settingsResult.Error!);
+
+ var settings = settingsResult.Value!;
+
+ var capability = await ValidateCapabilityAsync(settings, token);
+ if (!capability.IsSuccess)
+ return Result.Fail(capability.Error!);
+
+ // Re-checked inside the transaction that inserts, not once up front — the state can change between a
+ // pre-flight check and the write, and only the transaction sees the truth (brief Risk #2).
+ var active = await EnsureNotAlreadyActiveAsync(actorUserId, token);
+ if (!active.IsSuccess)
+ return Result.Fail(active.Error!);
+
+ var nowUtc = NowUtc;
+ var lobby = Lobby.Create(actorUserId, settings, correlationId: Guid.NewGuid(), nowUtc);
+
+ var (credential, plaintext) = IssueCredential(lobby.Id, generation: 1, nowUtc);
+
+ await _lobbies.AddLobbyAsync(
+ lobby, credential,
+ new[] { LobbyOutbox.LobbyCreatedEvent(lobby) },
+ token);
+
+ var dto = await ProjectAsync(lobby, actorUserId, token);
+ return Result.Ok(new CreateLobbyResultDto(dto, plaintext));
+ }, ct);
+
+ // ── Reads ────────────────────────────────────────────────────────────────
+
+ public async Task> GetAsync(Guid actorUserId, Guid lobbyId, CancellationToken ct = default)
+ {
+ var lobby = await _lobbies.GetByIdAsync(lobbyId, ct);
+ if (lobby is null) return NotFound();
+
+ // Authorization, re-evaluated on every read against the object's current membership — never against a
+ // token, a prior response, or the fact that the caller happens to hold the id.
+ var isMember = lobby.FindJoinedMember(actorUserId) is not null;
+ var isPubliclyVisible = lobby.Privacy == LobbyPrivacy.Public && lobby.State == LobbyState.Open;
+
+ // A non-member looking at a private lobby gets the same 404 as a caller naming an id that never existed.
+ if (!isMember && !isPubliclyVisible) return NotFound();
+
+ return Result.Ok(await ProjectAsync(lobby, actorUserId, ct));
+ }
+
+ public async Task>> GetPublicAsync(
+ Guid actorUserId, int limit, string? cursor, CancellationToken ct = default)
+ {
+ if (limit < 1 || limit > MaxLimit)
+ return Fail>(LobbyErrors.ValidationFailed, "Page size must be between 1 and 50.");
+
+ DateTime? afterCreatedAt = null;
+ Guid? afterId = null;
+ if (cursor is not null)
+ {
+ if (!Cursor.TryDecodeTimeId(cursor, out var createdAt, out var id))
+ return Fail>(LobbyErrors.InvalidCursor, "The pagination cursor is invalid.");
+ afterCreatedAt = createdAt;
+ afterId = id;
+ }
+
+ var nowUtc = NowUtc;
+ var rows = await _lobbies.GetPublicPageAsync(limit, afterCreatedAt, afterId, ct);
+
+ // The keyset index cannot express "not expired", "not full", or "not blocked", so those three are applied
+ // here. That means a page can come back shorter than `limit` — which is correct and deliberate: topping it
+ // up would either leak that a hidden row existed (via the cursor skipping ahead) or require an unbounded
+ // scan. The cursor still advances past every row the query saw, so pagination never duplicates or stalls.
+ var hostIds = rows.Select(l => l.HostUserId).Distinct().ToList();
+ var blockedHosts = await _lobbies.GetBlockedCounterpartsAsync(actorUserId, hostIds, ct);
+ var blocked = blockedHosts.ToHashSet();
+
+ var visible = rows
+ .Where(l => !l.IsExpired(nowUtc))
+ .Where(l => l.JoinedCount < l.MaxPlayers)
+ .Where(l => !blocked.Contains(l.HostUserId))
+ .ToList();
+
+ var users = await _lobbies.GetUsersAsync(visible.Select(l => l.HostUserId).Distinct().ToList(), ct);
+
+ var items = new List(visible.Count);
+ foreach (var lobby in visible)
+ {
+ if (!users.TryGetValue(lobby.HostUserId, out var host)) continue;
+ items.Add(new LobbySummaryDto(
+ lobby.Id, lobby.GameSlug, lobby.MaxPlayers, lobby.JoinedCount, lobby.TimeControlId,
+ lobby.Rated, lobby.ResolvedRegion, lobby.SpectatorPolicy.ToString(),
+ await ToIdentityAsync(host, ct), lobby.CreatedAt, lobby.ExpiresAtUtc));
+ }
+
+ // The cursor is derived from the last row the *query* returned, not the last visible one — otherwise a
+ // trailing filtered-out row would be re-fetched forever and the page would never advance.
+ var next = rows.Count == limit
+ ? Cursor.EncodeTimeId(rows[^1].CreatedAt, rows[^1].Id)
+ : null;
+
+ return Result>.Ok(new CursorPage(items, next));
+ }
+
+ public async Task>> GetMyInvitesAsync(
+ Guid actorUserId, CancellationToken ct = default)
+ {
+ var rows = await _lobbies.GetPendingInvitesForUserAsync(actorUserId, NowUtc, DefaultLimit, ct);
+
+ var items = new List(rows.Count);
+ foreach (var (invite, lobby, inviter) in rows)
+ {
+ items.Add(new LobbyInviteDto(
+ invite.Id, invite.LobbyId, lobby.GameSlug,
+ await ToIdentityAsync(inviter, ct), invite.State.ToString(), invite.ExpiresAtUtc));
+ }
+
+ return Result>.Ok(items);
+ }
+
+ public async Task> GetCapabilityProfileAsync(
+ string gameSlug, CancellationToken ct = default)
+ {
+ var profile = await _lobbies.GetActiveCapabilityProfileAsync(gameSlug, ct);
+ if (profile is null)
+ return Fail(
+ LobbyErrors.CapabilityNotFound, "That game has no active capability profile.");
+
+ return Result.Ok(new GameCapabilityProfileDto(
+ profile.GameSlug, profile.CapabilityVersion, profile.MinPlayers, profile.MaxPlayers,
+ profile.AllowedModes, profile.TimeControls, profile.TieBreakRules, profile.SpectatorPolicies,
+ profile.RatedEligible, profile.AiFillEligible));
+ }
+
+ public async Task> GetMyActiveAsync(Guid actorUserId, CancellationToken ct = default)
+ {
+ var lobby = await _lobbies.GetActiveLobbyForUserAsync(actorUserId, ct);
+ if (lobby is not null)
+ return Result.Ok(new ActiveContextDto(await ProjectAsync(lobby, actorUserId, ct), null));
+
+ var ticketId = await _lobbies.GetActiveTicketIdForUserAsync(actorUserId, ct);
+ return Result.Ok(new ActiveContextDto(null, ticketId));
+ }
+
+ // ── Join by credential ───────────────────────────────────────────────────
+
+ public async Task> JoinByCredentialAsync(
+ Guid actorUserId, JoinLobbyRequestDto request, CancellationToken ct = default)
+ {
+ var hasCode = !string.IsNullOrWhiteSpace(request.Code);
+ var hasToken = !string.IsNullOrWhiteSpace(request.LinkToken);
+ var hasLobbyId = request.LobbyId is Guid id && id != Guid.Empty;
+
+ var modeCount = (hasCode ? 1 : 0) + (hasToken ? 1 : 0) + (hasLobbyId ? 1 : 0);
+ if (modeCount != 1)
+ return Fail(LobbyErrors.ValidationFailed, "Supply exactly one of code, linkToken, or lobbyId.");
+
+ // Joining by id skips the credential path entirely: a lobbyId is a resource identifier, not a secret, so
+ // there is nothing here for the join throttle to protect against. It is only ever honored for a lobby that
+ // is already Public and Open — the same visibility rule as the public browse listing and single-lobby
+ // read — so naming a private, foreign, closed, or expired lobby's id answers with the identical
+ // privacy-safe not-found used everywhere else in this module.
+ if (hasLobbyId)
+ {
+ return await _runner.RunAsync(actorUserId, async token =>
+ {
+ var nowUtc = NowUtc;
+ var lobby = await _lobbies.GetForUpdateAsync(request.LobbyId!.Value, token);
+ if (lobby is null || lobby.Privacy != LobbyPrivacy.Public || lobby.IsTerminal || lobby.IsExpired(nowUtc))
+ return NotFound();
+
+ return await SeatMemberAsync(lobby, actorUserId, nowUtc, extraEvents: null, token);
+ }, ct);
+ }
+
+ // Failed attempts are throttled specifically (a wrong 60-bit code is cheap to spam), and the check runs
+ // before any digest work so a throttled attacker cannot even measure the comparison.
+ var retryAfter = await _joinThrottle.GetRetryAfterUtcAsync(actorUserId, ct);
+ if (retryAfter is DateTime until)
+ {
+ return Result.Fail(new Error(
+ LobbyErrors.RateLimitExceeded, "Too many failed join attempts. Please try again later.")
+ {
+ RetryAfterUtc = until,
+ });
+ }
+
+ var result = await _runner.RunAsync(actorUserId, async token =>
+ {
+ var nowUtc = NowUtc;
+
+ var credential = hasCode
+ ? await _lobbies.FindActiveByCodeDigestAsync(_hasher.HashCode(request.Code!), token)
+ : await _lobbies.FindActiveByLinkTokenDigestAsync(_hasher.HashLinkToken(request.LinkToken!), token);
+
+ // Wrong, expired, rotated, revoked, and unknown all land here, and all answer identically. A caller
+ // learns only "that did not work" — never whether the lobby exists.
+ if (credential is null || !credential.CanRedeem(nowUtc))
+ return CredentialInvalid();
+
+ var lobby = await _lobbies.GetForUpdateAsync(credential.LobbyId, token);
+ if (lobby is null || lobby.IsTerminal || lobby.IsExpired(nowUtc))
+ return CredentialInvalid();
+
+ return await SeatMemberAsync(lobby, actorUserId, nowUtc, extraEvents: null, token);
+ }, ct);
+
+ // Only a genuinely invalid credential feeds the throttle. A caller rejected for being already-active, or
+ // for landing in a full lobby, learned nothing about the code — counting those would let an unrelated
+ // failure lock a legitimate user out of joining.
+ if (!result.IsSuccess && result.Error!.Code == LobbyErrors.CredentialInvalid)
+ await _joinThrottle.RecordFailureAsync(actorUserId, ct);
+ else if (result.IsSuccess)
+ await _joinThrottle.ClearAsync(actorUserId, ct);
+
+ return result;
+ }
+
+ // ── Membership mutations ─────────────────────────────────────────────────
+
+ public Task LeaveAsync(Guid actorUserId, Guid lobbyId, CancellationToken ct = default) =>
+ Unit(_runner.RunAsync(actorUserId, async token =>
+ {
+ var nowUtc = NowUtc;
+ var lobby = await _lobbies.GetForUpdateAsync(lobbyId, token);
+ if (lobby is null) return NotFound();
+
+ // A caller who is not a seated member must not be able to tell a real lobby from a fictional one.
+ if (lobby.FindJoinedMember(actorUserId) is null) return NotFound();
+
+ var leave = lobby.Leave(actorUserId, nowUtc);
+ if (leave.Outcome != LobbyOutcome.Ok) return MapOutcome(leave.Outcome);
+
+ var events = new List { LobbyOutbox.MemberLeftEvent(lobby, actorUserId) };
+ if (leave.NewHostUserId is Guid newHost)
+ events.Add(LobbyOutbox.HostTransferredEvent(lobby, newHost));
+ if (leave.ClosedReason is not null)
+ events.Add(LobbyOutbox.LobbyClosedEvent(lobby));
+
+ await _lobbies.SaveAsync(events, token);
+ return Result.Ok(true);
+ }, ct));
+
+ public Task> SetReadinessAsync(
+ Guid actorUserId, Guid lobbyId, SetReadinessRequestDto request, CancellationToken ct = default) =>
+ _runner.RunAsync(actorUserId, async token =>
+ {
+ var nowUtc = NowUtc;
+ var lobby = await _lobbies.GetForUpdateAsync(lobbyId, token);
+ if (lobby is null || lobby.FindJoinedMember(actorUserId) is null) return NotFound();
+
+ var stale = CheckRevision(lobby, request.ExpectedRevision);
+ if (stale is not null) return Result.Fail(stale);
+
+ var outcome = lobby.SetReadiness(actorUserId, request.IsReady, nowUtc);
+ if (outcome != LobbyOutcome.Ok) return MapOutcome(outcome);
+
+ await _lobbies.SaveAsync(Array.Empty(), token);
+ return Result.Ok(await ProjectAsync(lobby, actorUserId, token));
+ }, ct);
+
+ public Task> UpdateSettingsAsync(
+ Guid actorUserId, Guid lobbyId, UpdateLobbySettingsRequestDto request, CancellationToken ct = default) =>
+ _runner.RunAsync(actorUserId, async token =>
+ {
+ var nowUtc = NowUtc;
+ var lobby = await _lobbies.GetForUpdateAsync(lobbyId, token);
+ if (lobby is null || lobby.FindJoinedMember(actorUserId) is null) return NotFound();
+
+ var stale = CheckRevision(lobby, request.ExpectedRevision);
+ if (stale is not null) return Result.Fail(stale);
+
+ var actor = await _users.GetByIdAsync(actorUserId, token);
+ if (actor is null) return NotFound();
+
+ var settingsResult = BuildSettings(
+ request.GameSlug, request.CapabilityVersion, request.Privacy, request.MaxPlayers,
+ request.TimeControlId, request.Rated, request.Region, request.SpectatorPolicy,
+ request.TieBreakRuleId, request.AiFillRequested, actor);
+ if (!settingsResult.IsSuccess) return Result.Fail(settingsResult.Error!);
+
+ // Capability is re-validated on every change, not just at create — a profile can be deactivated
+ // underneath a live lobby, which is exactly what makes "capability disabled after create" a real path.
+ var capability = await ValidateCapabilityAsync(settingsResult.Value!, token);
+ if (!capability.IsSuccess) return Result.Fail(capability.Error!);
+
+ var outcome = lobby.ChangeSettings(actorUserId, settingsResult.Value!, nowUtc);
+ if (outcome != LobbyOutcome.Ok) return MapOutcome(outcome);
+
+ await _lobbies.SaveAsync(new[] { LobbyOutbox.SettingsChangedEvent(lobby) }, token);
+ return Result.Ok(await ProjectAsync(lobby, actorUserId, token));
+ }, ct);
+
+ public Task> KickAsync(
+ Guid actorUserId, Guid lobbyId, KickMemberRequestDto request, CancellationToken ct = default) =>
+ _runner.RunAsync(actorUserId, async token =>
+ {
+ var nowUtc = NowUtc;
+ var lobby = await _lobbies.GetForUpdateAsync(lobbyId, token);
+ if (lobby is null || lobby.FindJoinedMember(actorUserId) is null) return NotFound();
+
+ var stale = CheckRevision(lobby, request.ExpectedRevision);
+ if (stale is not null) return Result.Fail(stale);
+
+ var outcome = lobby.Kick(actorUserId, request.TargetUserId, nowUtc);
+ if (outcome != LobbyOutcome.Ok) return MapOutcome(outcome);
+
+ await _lobbies.SaveAsync(
+ new[] { LobbyOutbox.MemberKickedEvent(lobby, request.TargetUserId, actorUserId) }, token);
+ return Result.Ok(await ProjectAsync(lobby, actorUserId, token));
+ }, ct);
+
+ // ── Credential rotation ──────────────────────────────────────────────────
+
+ public Task> RotateCredentialAsync(
+ Guid actorUserId, Guid lobbyId, CancellationToken ct = default) =>
+ _runner.RunAsync(actorUserId, async token =>
+ {
+ var nowUtc = NowUtc;
+ var lobby = await _lobbies.GetForUpdateAsync(lobbyId, token);
+ if (lobby is null || lobby.FindJoinedMember(actorUserId) is null)
+ return NotFound();
+
+ if (!lobby.IsHost(actorUserId))
+ return Fail(LobbyErrors.Forbidden, "Only the host can rotate the join credential.");
+ if (lobby.IsTerminal) return MapOutcome(LobbyOutcome.Closed);
+ if (lobby.IsExpired(nowUtc)) return MapOutcome(LobbyOutcome.Expired);
+
+ var outgoing = await _lobbies.GetActiveCredentialAsync(lobbyId, token);
+ if (outgoing is null) return NotFound();
+
+ outgoing.MarkRotated(nowUtc);
+ var (incoming, plaintext) = IssueCredential(lobbyId, outgoing.Generation + 1, nowUtc);
+
+ // The old value is dead the instant this commits — the rotated row can no longer be redeemed, and the
+ // new row is a different secret entirely. There is no window in which both work.
+ await _lobbies.RotateCredentialAsync(
+ outgoing, incoming,
+ new[] { LobbyOutbox.CredentialRotatedEvent(lobby, incoming.Generation) },
+ token);
+
+ return Result.Ok(plaintext);
+ }, ct);
+
+ // ── Invites ──────────────────────────────────────────────────────────────
+
+ public Task> CreateInviteAsync(
+ Guid actorUserId, Guid lobbyId, CreateInviteRequestDto request, CancellationToken ct = default) =>
+ _runner.RunAsync(actorUserId, async token =>
+ {
+ var nowUtc = NowUtc;
+ var lobby = await _lobbies.GetForUpdateAsync(lobbyId, token);
+ if (lobby is null || lobby.FindJoinedMember(actorUserId) is null) return NotFound();
+
+ if (!lobby.IsHost(actorUserId))
+ return Fail(LobbyErrors.Forbidden, "Only the host can invite.");
+ if (lobby.IsTerminal) return MapOutcome(LobbyOutcome.Closed);
+ if (lobby.IsExpired(nowUtc)) return MapOutcome(LobbyOutcome.Expired);
+
+ if (request.InviteeUserId == actorUserId)
+ return Fail(LobbyErrors.InvalidTarget, "You cannot invite yourself.");
+
+ var invitee = await _users.GetByIdAsync(request.InviteeUserId, token);
+ if (invitee is null || invitee.IsAccountSuspended())
+ return Fail(LobbyErrors.InvalidTarget, "That player cannot be invited.");
+
+ // Friends-only. This is what stops the invite endpoint being a way to reveal a private lobby (and its
+ // existence) to an arbitrary user id.
+ if (!await _lobbies.AreFriendsAsync(actorUserId, request.InviteeUserId, token))
+ return Fail(LobbyErrors.InvalidTarget, "You can only invite friends.");
+
+ var blocked = await _lobbies.GetBlockedCounterpartsAsync(
+ request.InviteeUserId, RosterOf(lobby), token);
+ if (blocked.Count > 0)
+ return Fail(LobbyErrors.Blocked, "A block exists between that player and this lobby.");
+
+ // Re-inviting someone who already holds a live invite replays it rather than stacking duplicates.
+ var existing = await _lobbies.GetPendingInviteAsync(lobbyId, request.InviteeUserId, token);
+ if (existing is not null && existing.CanAccept(nowUtc))
+ {
+ var inviterForExisting = await _users.GetByIdAsync(actorUserId, token);
+ return Result.Ok(new LobbyInviteDto(
+ existing.Id, lobbyId, lobby.GameSlug, await ToIdentityAsync(inviterForExisting!, token),
+ existing.State.ToString(), existing.ExpiresAtUtc));
+ }
+
+ var invite = LobbyInvite.Create(lobbyId, actorUserId, request.InviteeUserId, nowUtc);
+ await _lobbies.AddInviteAsync(invite, new[] { LobbyOutbox.InviteCreatedEvent(invite) }, token);
+
+ var inviter = await _users.GetByIdAsync(actorUserId, token);
+ return Result.Ok(new LobbyInviteDto(
+ invite.Id, lobbyId, lobby.GameSlug, await ToIdentityAsync(inviter!, token),
+ invite.State.ToString(), invite.ExpiresAtUtc));
+ }, ct);
+
+ public Task RevokeInviteAsync(
+ Guid actorUserId, Guid lobbyId, Guid inviteId, CancellationToken ct = default) =>
+ Unit(_runner.RunAsync(actorUserId, async token =>
+ {
+ var nowUtc = NowUtc;
+ var lobby = await _lobbies.GetForUpdateAsync(lobbyId, token);
+ if (lobby is null || lobby.FindJoinedMember(actorUserId) is null) return NotFound();
+ if (!lobby.IsHost(actorUserId))
+ return Fail(LobbyErrors.Forbidden, "Only the host can revoke an invite.");
+
+ var invite = await _lobbies.GetInviteForUpdateAsync(inviteId, token);
+ // An invite id belonging to a different lobby is a 404, not a 403 — the caller must not learn it exists.
+ if (invite is null || invite.LobbyId != lobbyId) return NotFound();
+
+ var outcome = invite.Revoke(nowUtc);
+ if (outcome != LobbyOutcome.Ok) return MapOutcome(outcome);
+
+ await _lobbies.SaveAsync(new[] { LobbyOutbox.InviteRevokedEvent(invite) }, token);
+ return Result.Ok(true);
+ }, ct));
+
+ public Task> AcceptInviteAsync(
+ Guid actorUserId, Guid inviteId, CancellationToken ct = default) =>
+ _runner.RunAsync(actorUserId, async token =>
+ {
+ var nowUtc = NowUtc;
+ var invite = await _lobbies.GetInviteForUpdateAsync(inviteId, token);
+
+ // Another user's invite id is indistinguishable from one that never existed.
+ if (invite is null || invite.InviteeUserId != actorUserId) return NotFound();
+
+ if (!invite.CanAccept(nowUtc))
+ {
+ return invite.IsExpired(nowUtc)
+ ? MapOutcome(LobbyOutcome.Expired)
+ : MapOutcome(LobbyOutcome.Closed);
+ }
+
+ var lobby = await _lobbies.GetForUpdateAsync(invite.LobbyId, token);
+ if (lobby is null) return NotFound();
+ if (lobby.IsTerminal) return MapOutcome(LobbyOutcome.Closed);
+ if (lobby.IsExpired(nowUtc)) return MapOutcome(LobbyOutcome.Expired);
+
+ var accept = invite.Accept(nowUtc);
+ if (accept != LobbyOutcome.Ok) return MapOutcome(accept);
+
+ // Accepting seats the member under exactly the invariants a credential join enforces — an invite is a
+ // permission to try, never a bypass of blocks, capacity, or one-active-lobby-or-ticket.
+ return await SeatMemberAsync(
+ lobby, actorUserId, nowUtc,
+ extraEvents: new[] { LobbyOutbox.InviteAcceptedEvent(invite) },
+ token);
+ }, ct);
+
+ public Task DeclineInviteAsync(Guid actorUserId, Guid inviteId, CancellationToken ct = default) =>
+ Unit(_runner.RunAsync(actorUserId, async token =>
+ {
+ var nowUtc = NowUtc;
+ var invite = await _lobbies.GetInviteForUpdateAsync(inviteId, token);
+ if (invite is null || invite.InviteeUserId != actorUserId) return NotFound();
+
+ // Declining is modelled as a revoke of the same pending invite: the lifecycle has one terminal
+ // "withdrawn" state, and who withdrew it is already recorded by the actor on the event.
+ var outcome = invite.Revoke(nowUtc);
+ if (outcome != LobbyOutcome.Ok) return MapOutcome(outcome);
+
+ await _lobbies.SaveAsync(new[] { LobbyOutbox.InviteRevokedEvent(invite) }, token);
+ return Result.Ok(true);
+ }, ct));
+
+ // ── Start ────────────────────────────────────────────────────────────────
+
+ public Task> StartAsync(
+ Guid actorUserId, Guid lobbyId, StartLobbyRequestDto request, CancellationToken ct = default) =>
+ _runner.RunAsync(actorUserId, async token =>
+ {
+ var nowUtc = NowUtc;
+ var lobby = await _lobbies.GetForUpdateAsync(lobbyId, token);
+ if (lobby is null || lobby.FindJoinedMember(actorUserId) is null)
+ return NotFound();
+
+ if (!lobby.IsHost(actorUserId))
+ return Fail(LobbyErrors.Forbidden, "Only the host can start the match.");
+
+ // An already-committed start replays rather than minting a second request. Checked before the
+ // revision guard: a client retrying the identical command must get its own result back, not a
+ // stale-revision error caused by its own first attempt having bumped the revision.
+ var replay = await _lobbies.GetStartRequestByIdempotencyKeyAsync(lobbyId, request.IdempotencyKey, token);
+ if (replay is not null)
+ {
+ return Result.Ok(new StartLobbyResultDto(
+ lobbyId, replay.MatchRequestId, lobby.State.ToString(), lobby.Revision));
+ }
+
+ var stale = CheckRevision(lobby, request.ExpectedRevision);
+ if (stale is not null) return Result.Fail(stale);
+
+ // ── The M8 gate, checked FIRST (deliberate ordering; see below) ──
+ //
+ // The spec lists the engine (M5) check before the runtime (M8) check. With zero engines installed,
+ // following that order literally would answer `Lobbies.CapabilityDisabled` — blaming *this lobby's
+ // configuration* for what is actually a platform-wide absence of any match runtime at all. That is a
+ // misleading error, and it contradicts the spec's own normative promise that "until M8 registers a
+ // consumer, Start returns Lobbies.MatchRuntimeUnavailable". The honest answer wins.
+ if (!await _matchRuntime.IsAvailableAsync(token))
+ {
+ _logger.LogInformation(
+ "Security: Start rejected, no match runtime. ActorId={ActorId} LobbyId={LobbyId} Action={Action} Result={Result}",
+ actorUserId, lobbyId, "LobbyStart", "MatchRuntimeUnavailable");
+ return Fail(
+ LobbyErrors.MatchRuntimeUnavailable,
+ "Matches cannot start yet — the match runtime arrives with Module 8.");
+ }
+
+ var roster = RosterOf(lobby);
+
+ // Blocks are re-checked at start, not trusted from join time: a block created while the lobby filled
+ // must stop the match, and only a check here sees it.
+ foreach (var member in roster)
+ {
+ var blocked = await _lobbies.GetBlockedCounterpartsAsync(member, roster, token);
+ if (blocked.Count > 0)
+ return Fail(LobbyErrors.Blocked, "A block exists between two members of this lobby.");
+ }
+
+ foreach (var member in roster)
+ {
+ if (await _matchRuntime.IsInActiveMatchAsync(member, token))
+ return Fail(LobbyErrors.AlreadyActive, "A member is already in a live match.");
+ }
+
+ var capability = await ValidateCapabilityAsync(lobby.CurrentSettings, token);
+ if (!capability.IsSuccess) return Result.Fail(capability.Error!);
+
+ // M5 engine availability. The lobby pins a *capability* version, not an engine version — the two are
+ // different concepts and M6 has no field for the latter. Until M8 exists to instantiate a specific
+ // engine, the answerable question is "can any installed engine host this game at all", which is what
+ // this asks. Selecting the exact (slug, engineVersion) is M8's, since M8 is what records it on the
+ // match it creates.
+ if (!_engines.RegisteredDefinitions.Any(d => string.Equals(d.Slug, lobby.GameSlug, StringComparison.Ordinal)))
+ {
+ return Fail(
+ LobbyErrors.CapabilityDisabled, "No engine is installed for this game.");
+ }
+
+ var outcome = lobby.BeginStarting(actorUserId, nowUtc);
+ if (outcome != LobbyOutcome.Ok) return MapOutcome(outcome);
+
+ // The state change and the event commit together or not at all. A committed MatchRequestedV1 is a
+ // durable *request* — not a match (Risk #6).
+ var startRequest = LobbyStartRequest.Open(
+ lobbyId, lobby.Revision, matchRequestId: Guid.NewGuid(),
+ request.IdempotencyKey, lobby.CorrelationId);
+
+ await _lobbies.AddStartRequestAsync(
+ startRequest,
+ new[] { LobbyOutbox.MatchRequestedEvent(lobby, startRequest) },
+ token);
+
+ return Result.Ok(new StartLobbyResultDto(
+ lobbyId, startRequest.MatchRequestId, lobby.State.ToString(), lobby.Revision));
+ }, ct);
+
+ public Task> CreateRematchLobbyAsync(
+ Guid actorUserId, Guid terminalMatchId, CancellationToken ct = default)
+ {
+ // M8 owns matches. There is no match store to read a terminal match's pinned settings and participants
+ // from, so there is nothing honest to build a rematch out of. Fabricating one from defaults would produce
+ // a lobby that silently is not the rematch it claims to be.
+ _logger.LogInformation(
+ "Security: Rematch rejected, no match runtime. ActorId={ActorId} MatchId={MatchId} Action={Action} Result={Result}",
+ actorUserId, terminalMatchId, "LobbyRematch", "MatchRuntimeUnavailable");
+
+ return Task.FromResult(Fail(
+ LobbyErrors.MatchRuntimeUnavailable,
+ "Rematch arrives with Module 8, which owns match records."));
+ }
+
+ // ── Shared command logic ─────────────────────────────────────────────────
+
+ ///
+ /// Seats an actor in a lobby they are entitled to reach. Shared verbatim by credential-join and invite-accept
+ /// so the two can never drift apart on blocks, capacity, or the one-active-lobby-or-ticket rule.
+ ///
+ private async Task> SeatMemberAsync(
+ Lobby lobby, Guid actorUserId, DateTime nowUtc, IReadOnlyList? extraEvents, CancellationToken ct)
+ {
+ // Already seated: idempotent, so a double-submitted join returns the lobby rather than an error.
+ if (lobby.FindJoinedMember(actorUserId) is not null)
+ return Result.Ok(await ProjectAsync(lobby, actorUserId, ct));
+
+ var actor = await _users.GetByIdAsync(actorUserId, ct);
+ if (actor is null || actor.IsAccountSuspended())
+ return Fail(LobbyErrors.Forbidden, "Your account cannot join a lobby.");
+
+ var active = await EnsureNotAlreadyActiveAsync(actorUserId, ct);
+ if (!active.IsSuccess) return Result