Finalize API envelopes and close validation drift tasks

This commit is contained in:
2026-02-07 01:35:56 +01:00
parent f615ef3a4a
commit 20daecd3eb
15 changed files with 104 additions and 109 deletions

View File

@@ -54,3 +54,28 @@ public record ResultItemDto(
IReadOnlyList<int> LinkedIds, IReadOnlyList<int> LinkedIds,
IReadOnlyList<string> LinkedTitles IReadOnlyList<string> LinkedTitles
); );
public record AuthSessionResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin);
public record StateSummaryResponse(
Phase CurrentPhase,
bool VotesFinal,
bool HasJoker,
bool ResultsOpen,
DateTimeOffset UpdatedAt,
int Players,
int Suggestions,
int Votes
);
public record MeResponse(
Guid Id,
string Username,
string? DisplayName,
bool IsAdmin,
Phase CurrentPhase,
bool VotesFinal,
bool HasJoker
);
public record PhaseTransitionResponse(Phase CurrentPhase, bool ResultsOpen);

View File

@@ -36,7 +36,7 @@ public static class AdminEndpoints
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
return await service.LinkSuggestionsAsync(player, request); return await service.LinkSuggestionsAsync(player, request);
}); });
@@ -45,7 +45,7 @@ public static class AdminEndpoints
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
return await service.UnlinkSuggestionsAsync(player, request); return await service.UnlinkSuggestionsAsync(player, request);
}); });

View File

@@ -51,13 +51,12 @@ public static class AuthEndpoints
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player); await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
return Results.Ok(new return Results.Ok(new AuthSessionResponse(
{
player.Id, player.Id,
player.Username, player.Username,
player.DisplayName, player.DisplayName,
player.IsAdmin player.IsAdmin
}); ));
}); });
group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db) => group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db) =>
@@ -79,13 +78,12 @@ public static class AuthEndpoints
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player); await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
return Results.Ok(new return Results.Ok(new AuthSessionResponse(
{
player.Id, player.Id,
player.Username, player.Username,
player.DisplayName, player.DisplayName,
player.IsAdmin player.IsAdmin
}); ));
}); });
group.MapPost("/logout", async (HttpContext ctx) => group.MapPost("/logout", async (HttpContext ctx) =>

View File

@@ -16,7 +16,7 @@ public static class ResultsEndpoints
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
return await service.GetResultsAsync(player); return await service.GetResultsAsync(player);
}); });

View File

@@ -1,5 +1,6 @@
using GameList.Data; using GameList.Data;
using GameList.Domain; using GameList.Domain;
using GameList.Contracts;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace GameList.Endpoints; namespace GameList.Endpoints;
@@ -14,21 +15,20 @@ public static class StateEndpoints
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var state = await db.AppState.AsNoTracking().FirstAsync(); var state = await db.AppState.AsNoTracking().FirstAsync();
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen); var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
var summary = new var summary = new StateSummaryResponse(
{ phase,
CurrentPhase = phase,
player.VotesFinal, player.VotesFinal,
player.HasJoker, player.HasJoker,
state.ResultsOpen, state.ResultsOpen,
state.UpdatedAt, state.UpdatedAt,
Players = await db.Players.CountAsync(), await db.Players.CountAsync(),
Suggestions = await db.Suggestions.CountAsync(), await db.Suggestions.CountAsync(),
Votes = await db.Votes.CountAsync() await db.Votes.CountAsync()
}; );
return Results.Ok(summary); return Results.Ok(summary);
}); });
@@ -36,27 +36,26 @@ public static class StateEndpoints
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var state = await db.AppState.AsNoTracking().FirstAsync(); var state = await db.AppState.AsNoTracking().FirstAsync();
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen); var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
return Results.Ok(new return Results.Ok(new MeResponse(
{
player.Id, player.Id,
player.DisplayName,
player.Username, player.Username,
player.DisplayName,
player.IsAdmin, player.IsAdmin,
CurrentPhase = phase, phase,
player.VotesFinal, player.VotesFinal,
player.HasJoker player.HasJoker
}); ));
}); });
group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db) => group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var appState = await db.AppState.FirstAsync(); var appState = await db.AppState.FirstAsync();
var reconciled = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen); var reconciled = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
@@ -72,18 +71,14 @@ public static class StateEndpoints
player.CurrentPhase = next; player.CurrentPhase = next;
player.VotesFinal = false; // moving forward clears any prior finalize player.VotesFinal = false; // moving forward clears any prior finalize
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
{
player.CurrentPhase,
appState.ResultsOpen
});
}); });
group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db) => group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db); var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
if (!isAdmin) if (!isAdmin)
@@ -96,11 +91,7 @@ public static class StateEndpoints
player.CurrentPhase = PrevPhase(player.CurrentPhase); player.CurrentPhase = PrevPhase(player.CurrentPhase);
player.VotesFinal = false; player.VotesFinal = false;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
{
player.CurrentPhase,
appState.ResultsOpen
});
}); });
} }

View File

@@ -15,7 +15,7 @@ public static class SuggestEndpoints
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
return await service.GetMineAsync(player); return await service.GetMineAsync(player);
}); });
@@ -24,7 +24,7 @@ public static class SuggestEndpoints
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
return await service.CreateAsync(player, request); return await service.CreateAsync(player, request);
}).AddEndpointFilter(new PhaseOrJokerFilter()); }).AddEndpointFilter(new PhaseOrJokerFilter());
@@ -33,7 +33,7 @@ public static class SuggestEndpoints
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db); var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
return await service.DeleteAsync(player, isAdmin, id); return await service.DeleteAsync(player, isAdmin, id);
@@ -43,7 +43,7 @@ public static class SuggestEndpoints
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var isAdmin = player.IsAdmin; var isAdmin = player.IsAdmin;
return await service.UpdateAsync(player, isAdmin, id, request); return await service.UpdateAsync(player, isAdmin, id, request);
@@ -53,7 +53,7 @@ public static class SuggestEndpoints
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
return await service.GetAllAsync(player); return await service.GetAllAsync(player);
}); });

View File

@@ -15,7 +15,7 @@ public static class VoteEndpoints
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
return await service.GetMineAsync(player); return await service.GetMineAsync(player);
}); });
@@ -24,7 +24,7 @@ public static class VoteEndpoints
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
return await service.UpsertAsync(player, request); return await service.UpsertAsync(player, request);
}); });
@@ -32,7 +32,7 @@ public static class VoteEndpoints
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
return await service.SetFinalizeAsync(player, request); return await service.SetFinalizeAsync(player, request);
}); });

View File

@@ -161,6 +161,10 @@ public class AuthTests
var resp = await player.GetAsync("/api/admin/vote-status"); var resp = await player.GetAsync("/api/admin/vote-status");
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Unauthorized", json.GetProperty("title").GetString());
Assert.Equal("Unauthorized", json.GetProperty("detail").GetString());
Assert.Equal("Unauthorized", json.GetProperty("error").GetString());
} }
[Fact] [Fact]

View File

@@ -212,7 +212,7 @@ public class StateTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var anon = factory.CreateClient(); var anon = factory.CreateClient();
var unauthorized = await anon.GetAsync("/api/state"); var unauthorized = await anon.GetAsync("/api/state");
Assert.NotEqual(HttpStatusCode.OK, unauthorized.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode);
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("counting"); await client.RegisterAsync("counting");
@@ -224,6 +224,27 @@ public class StateTests
Assert.True(suggestions.GetInt32() >= 1); Assert.True(suggestions.GetInt32() >= 1);
} }
[Fact]
public async Task State_endpoint_with_stale_cookie_returns_unauthorized_and_clears_cookie()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("stale");
await factory.WithDbContextAsync(async db =>
{
var player = await db.Players.FirstAsync();
db.Players.Remove(player);
await db.SaveChangesAsync();
});
var resp = await client.GetAsync("/api/state");
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies));
Assert.Contains(cookies, c => c.Contains("player=", StringComparison.OrdinalIgnoreCase));
}
[Fact] [Fact]
public async Task Health_endpoint_ok() public async Task Health_endpoint_ok()
{ {

View File

@@ -12,7 +12,7 @@ public class AdminOnlyFilter : IEndpointFilter
var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db);
if (player?.IsAdmin != true) if (player?.IsAdmin != true)
{ {
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
} }
return await next(context); return await next(context);

View File

@@ -16,7 +16,7 @@ public class PhaseOrJokerFilter : IEndpointFilter
var db = httpContext.RequestServices.GetRequiredService<AppDbContext>(); var db = httpContext.RequestServices.GetRequiredService<AppDbContext>();
var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
var allow = phase == Phase.Suggest || (phase == Phase.Vote && player.HasJoker); var allow = phase == Phase.Suggest || (phase == Phase.Vote && player.HasJoker);

View File

@@ -12,7 +12,7 @@ public class PhaseRequirementFilter(Phase required, bool allowAdminOverride = fa
var db = httpContext.RequestServices.GetRequiredService<AppDbContext>(); var db = httpContext.RequestServices.GetRequiredService<AppDbContext>();
var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db);
if (player is null) if (player is null)
return Results.Unauthorized(); return EndpointHelpers.UnauthorizedError();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
if (phase != required && !(allowAdminOverride && player.IsAdmin)) if (phase != required && !(allowAdminOverride && player.IsAdmin))

View File

@@ -10,7 +10,7 @@ Progress update (as of February 7, 2026):
- Completed: build/test guardrails added (`.github/workflows/ci.yml`) and root ownership/setup docs added (`README.md:1`). - Completed: build/test guardrails added (`.github/workflows/ci.yml`) and root ownership/setup docs added (`README.md:1`).
- Completed: backend validators centralized for suggestions and auth (`Endpoints/SuggestionValidator.cs:7`, `Endpoints/AuthValidator.cs:11`). - Completed: backend validators centralized for suggestions and auth (`Endpoints/SuggestionValidator.cs:7`, `Endpoints/AuthValidator.cs:11`).
- Completed: request safety hardened for redirects and forwarded headers (`Program.cs:44`, `Program.cs:108`, `Endpoints/EndpointHelpers.cs:105`, `GameList.Tests/HelperTests.cs:121`, `GameList.Tests/HelperTests.cs:219`). - Completed: request safety hardened for redirects and forwarded headers (`Program.cs:44`, `Program.cs:108`, `Endpoints/EndpointHelpers.cs:105`, `GameList.Tests/HelperTests.cs:121`, `GameList.Tests/HelperTests.cs:219`).
- In progress: API/response contract standardization now includes typed response DTOs, shared `ProblemDetails` helpers, and client parsing compatibility (`Contracts/Responses.cs:5`, `Endpoints/EndpointHelpers.cs:90`, `wwwroot/js/api.js:25`). - Completed: API/response contract standardization now includes typed auth/state/workflow DTOs and shared `ProblemDetails` envelopes across endpoint/filter paths (`Contracts/Responses.cs:58`, `Endpoints/AuthEndpoints.cs:54`, `Endpoints/StateEndpoints.cs:22`, `Infrastructure/AdminOnlyFilter.cs:15`, `Endpoints/EndpointHelpers.cs:90`, `wwwroot/js/api.js:25`, `GameList.Tests/AuthTests.cs:164`).
Top 5 maintainability risks (priority order): Top 5 maintainability risks (priority order):
@@ -20,9 +20,9 @@ Top 5 maintainability risks (priority order):
- Impact: hard-to-debug regressions and fragile refactors in UI workflows. - Impact: hard-to-debug regressions and fragile refactors in UI workflows.
2. Rule duplication still present between backend/frontend validations (High) 2. Rule duplication still present between backend/frontend validations (High)
- Suggestion validation is centralized on the backend (`Endpoints/SuggestionWorkflowService.cs:39`, `Endpoints/SuggestionWorkflowService.cs:115`, `Endpoints/SuggestionValidator.cs:7`) but frontend still duplicates parts (`wwwroot/js/ui.js:648`, `wwwroot/js/ui.js:1019`). - Suggestion and auth validation are centralized on the backend (`Endpoints/SuggestionValidator.cs:7`, `Endpoints/AuthValidator.cs:11`, `Endpoints/AuthEndpoints.cs:18`, `Endpoints/AuthEndpoints.cs:65`).
- Auth validation is centralized on the backend (`Endpoints/AuthEndpoints.cs:18`, `Endpoints/AuthEndpoints.cs:65`, `Endpoints/AuthValidator.cs:11`) while frontend length checks remain duplicated (`wwwroot/app.js:92`, `wwwroot/app.js:121`). - Frontend now keeps UX-only prechecks (required credentials and min/max player input affordances) instead of server-side policy duplication (`wwwroot/app.js:92`, `wwwroot/app.js:119`, `wwwroot/js/ui.js:645`).
- Impact: inconsistent behavior and repeated fixes across server/client. - Impact: lower drift risk, though UI-side range hints still require occasional sync with server limits.
3. High-change, high-complexity frontend hotspots (High) 3. High-change, high-complexity frontend hotspots (High)
- Git churn: `wwwroot/app.js` (76 changes), `wwwroot/js/ui.js` (55), `wwwroot/js/i18n.js` (50). - Git churn: `wwwroot/app.js` (76 changes), `wwwroot/js/ui.js` (55), `wwwroot/js/i18n.js` (50).
@@ -31,8 +31,9 @@ Top 5 maintainability risks (priority order):
- Impact: every UI change risks regressions outside its feature area. - Impact: every UI change risks regressions outside its feature area.
4. Endpoint contract consistency and error shaping are still uneven (High) 4. Endpoint contract consistency and error shaping are still uneven (High)
- Service-layer extraction is now in place for suggestions, votes, admin, and results (`Endpoints/SuggestionWorkflowService.cs:8`, `Endpoints/VoteWorkflowService.cs:8`, `Endpoints/AdminWorkflowService.cs:8`, `Endpoints/ResultsWorkflowService.cs:8`), and typed response DTO adoption is underway (`Contracts/Responses.cs:5`, `Contracts/Responses.cs:35`), but not all auth/filter paths emit uniform error envelopes. - Service-layer extraction is in place for suggestions, votes, admin, and results (`Endpoints/SuggestionWorkflowService.cs:8`, `Endpoints/VoteWorkflowService.cs:8`, `Endpoints/AdminWorkflowService.cs:8`, `Endpoints/ResultsWorkflowService.cs:8`), and typed response DTOs now cover auth/state/workflow payloads (`Contracts/Responses.cs:5`, `Contracts/Responses.cs:58`, `Endpoints/AuthEndpoints.cs:54`, `Endpoints/StateEndpoints.cs:22`).
- Impact: API evolution and client compatibility changes are still high-friction. - Endpoint/filter unauthorized paths now use uniform `ProblemDetails` envelopes (`Infrastructure/AdminOnlyFilter.cs:15`, `Infrastructure/PhaseRequirementFilter.cs:15`, `Infrastructure/PhaseOrJokerFilter.cs:19`, `Endpoints/SuggestEndpoints.cs:18`, `Endpoints/VoteEndpoints.cs:18`, `Endpoints/ResultsEndpoints.cs:19`).
- Impact: API evolution risk is reduced; remaining inconsistency is framework-generated 401 challenge bodies for fully unauthenticated requests.
5. Static-analysis and frontend lint guardrails remain incomplete (Medium) 5. Static-analysis and frontend lint guardrails remain incomplete (Medium)
- Build/test CI exists (`.github/workflows/ci.yml`) and project content rules are fixed (`GameList.csproj:17`-`GameList.csproj:21`), but analyzers/lint/format gates are still absent. - Build/test CI exists (`.github/workflows/ci.yml`) and project content rules are fixed (`GameList.csproj:17`-`GameList.csproj:21`), but analyzers/lint/format gates are still absent.
@@ -100,11 +101,11 @@ Worst coupling points:
- Effort / Risk: `M / Med`. - Effort / Risk: `M / Med`.
- Dependencies (if any): none. - Dependencies (if any): none.
[P0][Partial] Centralize validation rules to stop backend/frontend drift [P0][Done] Centralize validation rules to stop backend/frontend drift
- Problem: Severity `High`, Category `Complexity/Duplication`. Validation rules are duplicated in multiple backend endpoints and frontend forms. Impact: inconsistent behavior and repeated fixes. - Problem: Severity `High`, Category `Complexity/Duplication`. Validation rules were duplicated in multiple backend endpoints and frontend forms, increasing drift risk.
- Evidence: backend centralized in `Endpoints/SuggestionWorkflowService.cs:39`, `Endpoints/SuggestionWorkflowService.cs:115`, `Endpoints/SuggestionValidator.cs:7`, `Endpoints/AuthEndpoints.cs:18`, `Endpoints/AuthEndpoints.cs:65`, `Endpoints/AuthValidator.cs:11`; frontend duplicates remain in `wwwroot/js/ui.js:648`, `wwwroot/js/ui.js:1019`, `wwwroot/app.js:92`. - Evidence: backend validators are centralized (`Endpoints/SuggestionValidator.cs:7`, `Endpoints/AuthValidator.cs:11`, `Endpoints/SuggestionWorkflowService.cs:39`, `Endpoints/SuggestionWorkflowService.cs:115`, `Endpoints/AuthEndpoints.cs:18`, `Endpoints/AuthEndpoints.cs:65`); frontend server-policy duplicates removed for auth length and image host/url security checks (`wwwroot/app.js:92`, `wwwroot/app.js:119`, `wwwroot/js/ui.js:645`).
- Recommendation: Extract backend validators (e.g., `SuggestionValidator`, `AuthValidator`) and reuse in create/update paths; simplify frontend to UX-only prechecks and rely on server responses for source-of-truth. - Recommendation: Keep backend validators as the source of truth; limit frontend checks to UX hints and required-field guidance.
- Acceptance criteria (testable): create/update share one backend validator path; tests cover validator once and both endpoints; frontend no longer re-implements server-only security rules. - Acceptance criteria (testable): create/update share one backend validator path; tests cover validator-backed routes; frontend no longer re-implements server-only security rules.
- Effort / Risk: `M / Med`. - Effort / Risk: `M / Med`.
- Dependencies (if any): none. - Dependencies (if any): none.
@@ -142,7 +143,7 @@ Worst coupling points:
[P1] Remove legacy/dead paths to reduce cognitive load [P1] Remove legacy/dead paths to reduce cognitive load
- Problem: Severity `Medium`, Category `Other`. Legacy `Reveal` phase and dead UI hooks remain in active code, increasing confusion. - Problem: Severity `Medium`, Category `Other`. Legacy `Reveal` phase and dead UI hooks remain in active code, increasing confusion.
- Evidence: `Domain/Phase.cs:6`, `Endpoints/StateEndpoints.cs:107`, `wwwroot/js/data.js:30`, `wwwroot/js/ui.js:156`, `wwwroot/js/ui.js:1191`. - Evidence: `Domain/Phase.cs:6`, `Endpoints/StateEndpoints.cs:99`, `wwwroot/js/data.js:30`, `wwwroot/js/ui.js:156`, `wwwroot/js/ui.js:1149`.
- Recommendation: Remove obsolete phase enum/value handling and dead UI references (`all-suggestions`, `nav-vote-next`). - Recommendation: Remove obsolete phase enum/value handling and dead UI references (`all-suggestions`, `nav-vote-next`).
- Acceptance criteria (testable): no references to removed phase/UI ids remain; tests validate expected phase transitions only (`Suggest`, `Vote`, `Results`). - Acceptance criteria (testable): no references to removed phase/UI ids remain; tests validate expected phase transitions only (`Suggest`, `Vote`, `Results`).
- Effort / Risk: `S / Low`. - Effort / Risk: `S / Low`.
@@ -164,11 +165,11 @@ Worst coupling points:
- Effort / Risk: `M / Low`. - Effort / Risk: `M / Low`.
- Dependencies (if any): P0 redirect-hardening task. - Dependencies (if any): P0 redirect-hardening task.
[P1][In Progress] Standardize API response contracts and error envelopes [P1][Done] Standardize API response contracts and error envelopes
- Problem: Severity `Medium`, Category `API/Contracts`. Success payloads are being typed and service-level errors now use a shared `ProblemDetails` helper, but some auth/filter paths still return bare status codes. - Problem: Severity `Medium`, Category `API/Contracts`. Success payloads and error envelopes were inconsistent across endpoint, service, and filter layers.
- Evidence: typed response DTOs added in `Contracts/Responses.cs:5` and used in service workflows (`Endpoints/SuggestionWorkflowService.cs:83`, `Endpoints/VoteWorkflowService.cs:83`, `Endpoints/AdminWorkflowService.cs:44`, `Endpoints/ResultsWorkflowService.cs:63`); shared error envelope helper added in `Endpoints/EndpointHelpers.cs:90` and used across workflows (`Endpoints/AdminWorkflowService.cs:98`, `Endpoints/SuggestionWorkflowService.cs:43`, `Endpoints/VoteWorkflowService.cs:32`, `Endpoints/ResultsWorkflowService.cs:14`); global exception handler now emits `ProblemDetails` with `error` compatibility field in `Infrastructure/PlayerIdentityExtensions.cs:48`; frontend parser supports `error`, `detail`, and `title` in `wwwroot/js/api.js:25`. - Evidence: typed response DTOs now cover workflow + auth + state paths (`Contracts/Responses.cs:5`, `Contracts/Responses.cs:58`, `Endpoints/AuthEndpoints.cs:54`, `Endpoints/StateEndpoints.cs:22`, `Endpoints/SuggestionWorkflowService.cs:83`, `Endpoints/VoteWorkflowService.cs:83`, `Endpoints/AdminWorkflowService.cs:44`, `Endpoints/ResultsWorkflowService.cs:63`); shared `ProblemDetails` helper is used across services and endpoint filters (`Endpoints/EndpointHelpers.cs:90`, `Endpoints/SuggestEndpoints.cs:18`, `Endpoints/VoteEndpoints.cs:18`, `Infrastructure/AdminOnlyFilter.cs:15`, `Infrastructure/PhaseRequirementFilter.cs:15`, `Infrastructure/PhaseOrJokerFilter.cs:19`); global exception handling and client parsing remain compatible via `error`/`detail`/`title` (`Infrastructure/PlayerIdentityExtensions.cs:48`, `wwwroot/js/api.js:25`, `GameList.Tests/AuthTests.cs:164`).
- Recommendation: finish migrating remaining auth/filter unauthorized paths to consistent problem payloads while keeping backward-compatible `error` fields during transition; continue replacing anonymous success payloads with DTOs. - Recommendation: retain this contract as baseline and keep backward-compatible `error` extension in future error-shape changes.
- Acceptance criteria (testable): all API endpoints return typed success DTOs; all non-2xx responses include `ProblemDetails` fields and remain consumable by current frontend. - Acceptance criteria (testable): endpoint/service/filter responses use typed success payloads and standardized `ProblemDetails` error envelopes, with current frontend parsing unchanged.
- Effort / Risk: `M / Med`. - Effort / Risk: `M / Med`.
- Dependencies (if any): none. - Dependencies (if any): none.

View File

@@ -89,7 +89,6 @@ function setupHandlers() {
e.preventDefault(); e.preventDefault();
const username = $("login-username").value.trim(); const username = $("login-username").value.trim();
const password = $("login-password").value; const password = $("login-password").value;
if (username.length > 24) return toast("Username must be 24 characters or fewer.", true);
if (!username || !password) return toast(t("auth.needCredentials"), true); if (!username || !password) return toast(t("auth.needCredentials"), true);
if (!hasConsent() && !$("login-consent")?.checked) return toast(t("auth.cookieRequired"), true); if (!hasConsent() && !$("login-consent")?.checked) return toast(t("auth.cookieRequired"), true);
try { try {
@@ -117,8 +116,6 @@ function setupHandlers() {
const displayName = $("register-displayName").value.trim(); const displayName = $("register-displayName").value.trim();
const adminKey = $("register-adminkey").value.trim(); const adminKey = $("register-adminkey").value.trim();
if (!displayName) return toast(t("toast.displayNameRequired") || "Display name is required.", true); if (!displayName) return toast(t("toast.displayNameRequired") || "Display name is required.", true);
if (username.length > 24) return toast("Username must be 24 characters or fewer.", true);
if (displayName.length > 16) return toast("Display name must be 16 characters or fewer.", true);
if (!username || !password) return toast(t("auth.needCredentials"), true); if (!username || !password) return toast(t("auth.needCredentials"), true);
if (!hasConsent() && !$("register-consent")?.checked) return toast(t("auth.cookieRequired"), true); if (!hasConsent() && !$("register-consent")?.checked) return toast(t("auth.cookieRequired"), true);
try { try {

View File

@@ -630,8 +630,6 @@ function openSuggestionModal({ title, submitLabel, initial = {}, onSubmit, lockT
const errorBox = form.querySelector('[data-error="players"]'); const errorBox = form.querySelector('[data-error="players"]');
const minInput = form.querySelector('input[name="minPlayers"]'); const minInput = form.querySelector('input[name="minPlayers"]');
const maxInput = form.querySelector('input[name="maxPlayers"]'); const maxInput = form.querySelector('input[name="maxPlayers"]');
const screenshotError = form.querySelector('[data-error="screenshot"]');
const screenshotInput = form.querySelector('input[name="screenshotUrl"]');
const markError = (msg) => { const markError = (msg) => {
if (errorBox) { if (errorBox) {
errorBox.textContent = msg; errorBox.textContent = msg;
@@ -640,7 +638,6 @@ function openSuggestionModal({ title, submitLabel, initial = {}, onSubmit, lockT
}; };
const clearError = () => { const clearError = () => {
if (errorBox) errorBox.classList.add("hidden"); if (errorBox) errorBox.classList.add("hidden");
if (screenshotError) screenshotError.classList.add("hidden");
}; };
clearError(); clearError();
const min = data.minPlayers; const min = data.minPlayers;
@@ -657,15 +654,6 @@ function openSuggestionModal({ title, submitLabel, initial = {}, onSubmit, lockT
markError(t("form.playersInvalid")); markError(t("form.playersInvalid"));
return; return;
} }
if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) {
if (screenshotError) {
screenshotError.textContent = t("form.screenshotInvalid");
screenshotError.classList.remove("hidden");
}
screenshotInput?.classList.add("input-error");
return;
}
screenshotInput?.classList.remove("input-error");
if (!data.name?.trim()) return toast(t("toast.nameRequired"), true); if (!data.name?.trim()) return toast(t("toast.nameRequired"), true);
try { try {
await onSubmit(data, close, submitBtn); await onSubmit(data, close, submitBtn);
@@ -1016,36 +1004,6 @@ function openDeleteConfirmModal(s) {
document.body.appendChild(overlay); document.body.appendChild(overlay);
} }
function isValidImageUrl(url) {
if (!url) return true;
try {
const u = new URL(url);
const allowed = ["http:", "https:"];
if (!allowed.includes(u.protocol)) return false;
const host = u.hostname.toLowerCase();
const bannedHosts = [
"bit.ly",
"tinyurl.com",
"t.co",
"goo.gl",
"ow.ly",
"is.gd",
"buff.ly",
"rebrand.ly",
"steamcommunity.com",
"store.steampowered.com",
];
if (bannedHosts.some((h) => host === h)) return false;
if (host === "imgur.com" && !u.pathname.startsWith("/a/") && !u.pathname.startsWith("/gallery/")) return false;
const path = u.pathname.toLowerCase();
return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif"].some((ext) =>
path.endsWith(ext),
);
} catch {
return false;
}
}
function linkRootId(s) { function linkRootId(s) {
return s?.parentSuggestionId ?? s?.id; return s?.parentSuggestionId ?? s?.id;
} }