Harden owner and suggestion invariants for concurrent writes

This commit is contained in:
2026-02-08 21:37:46 +01:00
parent 569cea161f
commit fe6a9d5da4
13 changed files with 472 additions and 22 deletions

3
API.md
View File

@@ -11,6 +11,7 @@ POST /api/auth/logout
Display names are set during registration and are immutable afterward. Display names are set during registration and are immutable afterward.
Passwords must be 8-128 chars and contain uppercase, lowercase and number. Passwords must be 8-128 chars and contain uppercase, lowercase and number.
The first account created with a valid `adminKey` becomes both `IsAdmin=true` and `IsOwner=true`. The first account created with a valid `adminKey` becomes both `IsAdmin=true` and `IsOwner=true`.
Owner bootstrap is also enforced by a database uniqueness constraint (`IsOwner=true` can only exist once), so concurrent owner registration races fail safely with `400`.
## State (requires auth) ## State (requires auth)
GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes) GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes)
@@ -26,11 +27,13 @@ POST /api/suggestions — create (name required ≤100; max 5 per player; valida
PUT /api/suggestions/{id} — update (non-admin: own suggestion; title locked after Suggest) PUT /api/suggestions/{id} — update (non-admin: own suggestion; title locked after Suggest)
DELETE /api/suggestions/{id} — delete (non-admin only in Suggest; admin any time) DELETE /api/suggestions/{id} — delete (non-admin only in Suggest; admin any time)
GET /api/suggestions/all — all suggestions (from Vote onward), includes author, link metadata GET /api/suggestions/all — all suggestions (from Vote onward), includes author, link metadata
Suggestion limit is enforced in both app logic and DB trigger; concurrent writes that exceed limit return `400`.
## Votes (requires auth + Vote phase) ## Votes (requires auth + Vote phase)
GET /api/votes/mine GET /api/votes/mine
POST /api/votes — upsert vote; if suggestion is in a linked group, applies the same score to all linked siblings POST /api/votes — upsert vote; if suggestion is in a linked group, applies the same score to all linked siblings
POST /api/votes/finalize — `{ final: bool }` toggles callers finalized status (blocks further vote edits when true) POST /api/votes/finalize — `{ final: bool }` toggles callers finalized status (blocks further vote edits when true)
Vote upsert includes conflict handling for concurrent writes against the unique `(PlayerId, SuggestionId)` index.
## Results (requires auth + Results phase + resultsOpen) ## Results (requires auth + Results phase + resultsOpen)
GET /api/results — leaderboard with totals, counts, averages, callers vote, media/links, link metadata GET /api/results — leaderboard with totals, counts, averages, callers vote, media/links, link metadata

View File

@@ -23,6 +23,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
builder.Property(p => p.PasswordSalt).IsRequired(); builder.Property(p => p.PasswordSalt).IsRequired();
builder.Property(p => p.IsAdmin).HasDefaultValue(false); builder.Property(p => p.IsAdmin).HasDefaultValue(false);
builder.Property(p => p.IsOwner).HasDefaultValue(false); builder.Property(p => p.IsOwner).HasDefaultValue(false);
builder.HasIndex(p => p.IsOwner).HasFilter($"{nameof(Player.IsOwner)} = 1").IsUnique();
builder.Property(p => p.HasJoker).HasDefaultValue(false); builder.Property(p => p.HasJoker).HasDefaultValue(false);
builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest); builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest);
builder.Property(p => p.VotesFinal).HasDefaultValue(false); builder.Property(p => p.VotesFinal).HasDefaultValue(false);

View File

@@ -0,0 +1,255 @@
// <auto-generated />
using System;
using GameList.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace GameList.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260208203323_HardenOwnerAndSuggestionInvariants")]
partial class HardenOwnerAndSuggestionInvariants
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("GameList.Domain.AppState", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("ResultsOpen")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AppState");
b.HasData(
new
{
Id = 1,
ResultsOpen = false,
UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))
});
});
modelBuilder.Entity("GameList.Domain.Player", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrentPhase")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("DisplayName")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<bool>("HasJoker")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<bool>("IsAdmin")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<bool>("IsOwner")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<string>("NormalizedUsername")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("TEXT");
b.Property<byte[]>("PasswordHash")
.IsRequired()
.HasColumnType("BLOB");
b.Property<byte[]>("PasswordSalt")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("TEXT");
b.Property<bool>("VotesFinal")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.HasKey("Id");
b.HasIndex("IsOwner")
.IsUnique()
.HasFilter("IsOwner = 1");
b.HasIndex("NormalizedUsername")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("GameUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int?>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<int?>("MinPlayers")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int?>("ParentSuggestionId")
.HasColumnType("INTEGER");
b.Property<Guid>("PlayerId")
.HasColumnType("TEXT");
b.Property<string>("ScreenshotUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("YoutubeUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ParentSuggestionId");
b.HasIndex("PlayerId");
b.ToTable("Suggestions");
});
modelBuilder.Entity("GameList.Domain.Vote", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("PlayerId")
.HasColumnType("TEXT");
b.Property<int>("Score")
.HasColumnType("INTEGER");
b.Property<int>("SuggestionId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SuggestionId");
b.HasIndex("PlayerId", "SuggestionId")
.IsUnique();
b.ToTable("Votes");
});
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{
b.HasOne("GameList.Domain.Suggestion", "ParentSuggestion")
.WithMany("LinkedSuggestions")
.HasForeignKey("ParentSuggestionId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("GameList.Domain.Player", "Player")
.WithMany("Suggestions")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentSuggestion");
b.Navigation("Player");
});
modelBuilder.Entity("GameList.Domain.Vote", b =>
{
b.HasOne("GameList.Domain.Player", "Player")
.WithMany("Votes")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("GameList.Domain.Suggestion", "Suggestion")
.WithMany("Votes")
.HasForeignKey("SuggestionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
b.Navigation("Suggestion");
});
modelBuilder.Entity("GameList.Domain.Player", b =>
{
b.Navigation("Suggestions");
b.Navigation("Votes");
});
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{
b.Navigation("LinkedSuggestions");
b.Navigation("Votes");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GameList.Data.Migrations
{
/// <inheritdoc />
public partial class HardenOwnerAndSuggestionInvariants : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Players_IsOwner",
table: "Players",
column: "IsOwner",
unique: true,
filter: "IsOwner = 1");
migrationBuilder.Sql(
"""
CREATE TRIGGER IF NOT EXISTS TR_Suggestions_MaxFivePerPlayer
BEFORE INSERT ON Suggestions
WHEN
(SELECT COUNT(1) FROM Suggestions WHERE PlayerId = NEW.PlayerId) >= 5
AND (
COALESCE((SELECT HasJoker FROM Players WHERE Id = NEW.PlayerId), 0) = 0
OR COALESCE((SELECT CurrentPhase FROM Players WHERE Id = NEW.PlayerId), 0) != 2
)
BEGIN
SELECT RAISE(ABORT, 'suggestion_limit_exceeded');
END;
"""
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DROP TRIGGER IF EXISTS TR_Suggestions_MaxFivePerPlayer;");
migrationBuilder.DropIndex(
name: "IX_Players_IsOwner",
table: "Players");
}
}
}

View File

@@ -103,6 +103,10 @@ namespace GameList.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("IsOwner")
.IsUnique()
.HasFilter("IsOwner = 1");
b.HasIndex("NormalizedUsername") b.HasIndex("NormalizedUsername")
.IsUnique(); .IsUnique();

View File

@@ -68,7 +68,19 @@ public static class AuthEndpoints
}; };
db.Players.Add(player); db.Players.Add(player);
try
{
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (isOwner && EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SingleOwnerIndexName))
{
authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "bootstrap-admin-race");
return EndpointHelpers.BadRequestError("Admin registration via admin key is disabled once an owner account exists.");
}
catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, "IX_Players_NormalizedUsername"))
{
return EndpointHelpers.ConflictError("Username already taken.");
}
if (isAdmin) if (isAdmin)
authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername); authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername);

View File

@@ -1,5 +1,6 @@
using GameList.Data; using GameList.Data;
using GameList.Domain; using GameList.Domain;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
@@ -9,6 +10,9 @@ namespace GameList.Endpoints;
internal static class EndpointHelpers internal static class EndpointHelpers
{ {
public const string SingleOwnerIndexName = "IX_Players_IsOwner";
public const string SuggestionLimitTriggerError = "suggestion_limit_exceeded";
public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db) public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
{ {
if (ctx.User.Identity?.IsAuthenticated != true) if (ctx.User.Identity?.IsAuthenticated != true)
@@ -108,6 +112,20 @@ internal static class EndpointHelpers
public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail); public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail);
public static bool IsSqliteConstraintViolation(DbUpdateException ex)
{
return ex.InnerException is SqliteException sqliteEx
&& sqliteEx.SqliteErrorCode == 19;
}
public static bool IsSqliteConstraintViolation(DbUpdateException ex, string containsMessage)
{
if (!IsSqliteConstraintViolation(ex))
return false;
return ex.InnerException?.Message.Contains(containsMessage, StringComparison.OrdinalIgnoreCase) == true;
}
private static IResult Problem(int statusCode, string title, string detail) private static IResult Problem(int statusCode, string title, string detail)
{ {
return Results.Problem( return Results.Problem(

View File

@@ -60,7 +60,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
if (string.IsNullOrWhiteSpace(playerState.DisplayName)) if (string.IsNullOrWhiteSpace(playerState.DisplayName))
return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions."); return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions.");
var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == playerId); var existingCount = await db.Suggestions.AsNoTracking().CountAsync(s => s.PlayerId == playerId);
if (!usingJoker && existingCount >= 5) if (!usingJoker && existingCount >= 5)
return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit."); return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
@@ -81,6 +81,10 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
db.Suggestions.Add(suggestion); db.Suggestions.Add(suggestion);
try
{
await db.SaveChangesAsync();
if (usingJoker) if (usingJoker)
{ {
await db.Players await db.Players
@@ -89,8 +93,12 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
} }
await db.SaveChangesAsync();
await tx.CommitAsync(); await tx.CommitAsync();
}
catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SuggestionLimitTriggerError))
{
return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
}
return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id)); return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id));
} }

View File

@@ -2,6 +2,7 @@ using GameList.Contracts;
using GameList.Data; using GameList.Data;
using GameList.Domain; using GameList.Domain;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace GameList.Endpoints; namespace GameList.Endpoints;
@@ -71,6 +72,8 @@ internal sealed class VoteWorkflowService(AppDbContext db)
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId)) .Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
.ToListAsync(); .ToListAsync();
for (var attempt = 0; attempt < 2; attempt++)
{
foreach (var linkedSuggestionId in linkedIds) foreach (var linkedSuggestionId in linkedIds)
{ {
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId); var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
@@ -89,9 +92,27 @@ internal sealed class VoteWorkflowService(AppDbContext db)
} }
} }
try
{
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new VoteUpsertResponse(linkedIds, score)); return Results.Ok(new VoteUpsertResponse(linkedIds, score));
} }
catch (DbUpdateException ex) when (attempt == 0 && EndpointHelpers.IsSqliteConstraintViolation(ex))
{
DetachAddedVotes(db.ChangeTracker.Entries<Vote>());
await db.Votes
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
.ExecuteUpdateAsync(v => v.SetProperty(x => x.Score, score));
existingVotes = await db.Votes
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
.ToListAsync();
}
}
return EndpointHelpers.ConflictError("Vote update conflict. Please retry.");
}
public async Task<IResult> SetFinalizeAsync(Guid playerId, bool final) public async Task<IResult> SetFinalizeAsync(Guid playerId, bool final)
{ {
@@ -105,4 +126,13 @@ internal sealed class VoteWorkflowService(AppDbContext db)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new VoteFinalizeResponse(player.VotesFinal)); return Results.Ok(new VoteFinalizeResponse(player.VotesFinal));
} }
private static void DetachAddedVotes(IEnumerable<EntityEntry<Vote>> voteEntries)
{
foreach (var entry in voteEntries)
{
if (entry.State == EntityState.Added)
entry.State = EntityState.Detached;
}
}
} }

View File

@@ -1,6 +1,8 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using GameList.Data;
using GameList.Domain;
using GameList.Infrastructure; using GameList.Infrastructure;
using GameList.Tests.Support; using GameList.Tests.Support;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -247,4 +249,32 @@ public class AuthTests
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains("player"))); Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains("player")));
} }
[Fact]
public async Task Owner_uniqueness_is_enforced_by_database_constraint()
{
await using var factory = new TestWebApplicationFactory();
var ownerClient = factory.CreateClientWithCookies();
await ownerClient.RegisterAsync("owner1", admin: true);
var thrown = await Assert.ThrowsAsync<DbUpdateException>(() => factory.WithDbContextAsync(async db =>
{
var (hash, salt) = PasswordHasher.HashPassword("Pass123!");
db.Players.Add(new Player
{
Id = Guid.NewGuid(),
Username = "owner2",
NormalizedUsername = "owner2",
PasswordHash = hash,
PasswordSalt = salt,
DisplayName = "Owner2",
IsOwner = true,
IsAdmin = true
});
await db.SaveChangesAsync();
}));
Assert.Contains("Players.IsOwner", thrown.InnerException?.Message ?? thrown.Message, StringComparison.OrdinalIgnoreCase);
}
} }

View File

@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using GameList.Domain;
using GameList.Tests.Support; using GameList.Tests.Support;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -626,4 +627,41 @@ public class SuggestionTests
Assert.False(db.Votes.Any(v => v.SuggestionId == id)); Assert.False(db.Votes.Any(v => v.SuggestionId == id));
}); });
} }
[Fact]
public async Task Suggestion_limit_is_enforced_by_database_trigger_without_joker()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("dbcap");
var playerId = await factory.WithDbContextAsync(async db => await db.Players.Select(p => p.Id).SingleAsync());
await factory.WithDbContextAsync(async db =>
{
for (var i = 0; i < 5; i++)
{
db.Suggestions.Add(new Suggestion
{
PlayerId = playerId,
Name = $"Seed {i}"
});
}
await db.SaveChangesAsync();
});
var thrown = await Assert.ThrowsAsync<DbUpdateException>(() => factory.WithDbContextAsync(async db =>
{
db.Suggestions.Add(new Suggestion
{
PlayerId = playerId,
Name = "Blocked by trigger"
});
await db.SaveChangesAsync();
}));
Assert.Contains("suggestion_limit_exceeded", thrown.InnerException?.Message ?? thrown.Message, StringComparison.OrdinalIgnoreCase);
}
} }

View File

@@ -25,6 +25,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
- Authentication: username/password with HttpOnly `player` cookie. - Authentication: username/password with HttpOnly `player` cookie.
- Admin authorization: authenticated account with `IsAdmin=true`. - Admin authorization: authenticated account with `IsAdmin=true`.
- Owner model: first valid admin-key registration becomes `owner`; admins can grant/revoke admin role for non-owner accounts. - Owner model: first valid admin-key registration becomes `owner`; admins can grant/revoke admin role for non-owner accounts.
- Core invariants are DB-enforced: single owner account and non-joker suggestion cap.
- Gameplay phases: `Suggest`, `Vote`, `Results`. - Gameplay phases: `Suggest`, `Vote`, `Results`.
- Storage: SQLite database under `App_Data/gamelist.db`. - Storage: SQLite database under `App_Data/gamelist.db`.
- Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement. - Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement.

View File

@@ -34,6 +34,7 @@ stateDiagram-v2
- Register success (player, admin key path) issues cookie, trims fields, stores normalized username, hashes password. - Register success (player, admin key path) issues cookie, trims fields, stores normalized username, hashes password.
- Register rejects missing/long username, weak password policy violations, missing display name, duplicate username, bad admin key, >24 chars username, >16 display name. - Register rejects missing/long username, weak password policy violations, missing display name, duplicate username, bad admin key, >24 chars username, >16 display name.
- Bootstrap-admin key path only works until the owner account exists; bootstrap admin is marked as owner. - Bootstrap-admin key path only works until the owner account exists; bootstrap admin is marked as owner.
- Database uniqueness guard enforces single owner row (`IsOwner=true`) even if writes bypass endpoint-level checks.
- `/api/auth/options` reports owner presence for registration UI behavior. - `/api/auth/options` reports owner presence for registration UI behavior.
- Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits. - Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits.
- Logout clears cookie. - Logout clears cookie.
@@ -50,6 +51,7 @@ stateDiagram-v2
### 3) Suggestions ### 3) Suggestions
- GET /mine returns only callers suggestions ordered by CreatedAt. - GET /mine returns only callers suggestions ordered by CreatedAt.
- POST /: success with valid data; enforces ≤5 per player; trims optional fields; requires display name; rejects bad image URL/ext, unreachable image (mocked), invalid game/youtube URLs, invalid player counts, missing name/too long. - POST /: success with valid data; enforces ≤5 per player; trims optional fields; requires display name; rejects bad image URL/ext, unreachable image (mocked), invalid game/youtube URLs, invalid player counts, missing name/too long.
- DB trigger also enforces suggestion cap for non-joker inserts, protecting against concurrent over-limit writes.
- Joker path: when phase=Vote and HasJoker=true allows creation, consumes joker, resets VotesFinal for all players. - Joker path: when phase=Vote and HasJoker=true allows creation, consumes joker, resets VotesFinal for all players.
- Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete. - Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete.
- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; validation mirrors create. - PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; validation mirrors create.
@@ -60,6 +62,7 @@ stateDiagram-v2
- GET /mine: only in Vote, returns player votes; unauthorized/phase mismatch handled. - GET /mine: only in Vote, returns player votes; unauthorized/phase mismatch handled.
- POST /: creates or updates vote; rejects score outside 010; rejects when VotesFinal=true; enforces display name requirement and phase gating. - POST /: creates or updates vote; rejects score outside 010; rejects when VotesFinal=true; enforces display name requirement and phase gating.
- Linked votes: when suggestions are linked, a single post updates all linked IDs; invalid suggestionId returns 400; linking root detection works for nested links. - Linked votes: when suggestions are linked, a single post updates all linked IDs; invalid suggestionId returns 400; linking root detection works for nested links.
- Concurrent vote upserts are handled with retry logic around unique-key conflicts to avoid server errors.
- Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote. - Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote.
### 5) Results ### 5) Results