diff --git a/migrations/0013_claim_intent.sql b/migrations/0013_claim_intent.sql
new file mode 100644
index 00000000..0c455bd3
--- /dev/null
+++ b/migrations/0013_claim_intent.sql
@@ -0,0 +1,30 @@
+-- spec §8.4, §8.5 — what a second person publishing a token on the same server MEANS.
+--
+-- §8.5 says a game may have several owners, each having verified a token of their own. §8.4 says a
+-- counter-claim — a different account proving control now — is the correct handling of a game
+-- changing hands. Both are true, they are opposite outcomes, and NOTHING IN A PROBE CAN TELL THEM
+-- APART: a co-founder joining and a new operator taking over publish the identical line in the
+-- identical config file, and the crawler reads the identical string.
+--
+-- So the difference is not inferred, it is DECLARED, and it is declared before the token is
+-- published rather than after it verifies. The claimant says which they are doing at the moment they
+-- ask for a token, on a page that explains both, and the answer is stored here beside the token it
+-- belongs to. A design that guessed — "a claim on an already-claimed game must be a takeover" —
+-- would silently unclaim a co-founder's partner the first time two people ran one game; guessing the
+-- other way would leave a departed operator holding a listing they no longer run.
+--
+-- Neither is more trusted than the other. Both publish a token on the server, which is the whole
+-- test (§8.1), so an account choosing 'assume' has proved exactly what an account choosing 'join'
+-- proved. The choice decides what happens to the OTHER claims, not how hard this one was to make.
+ALTER TABLE game_claim
+ ADD COLUMN intent text NOT NULL DEFAULT 'join';
+
+ALTER TABLE game_claim
+ ADD CONSTRAINT game_claim_intent_vocabulary CHECK (intent IN ('join', 'assume'));
+
+-- Every claim that existed before this column did was a first claim on an unclaimed game, so 'join'
+-- is the honest backfill: none of them displaced anybody, and the DEFAULT records that rather than
+-- inventing an intent nobody expressed.
+COMMENT ON COLUMN game_claim.intent IS
+ 'join: become one of the game''s owners. assume: take it over, revoking the others on '
+ 'verification (§8.4''s counter-claim). Declared when the token is issued, never inferred.';
diff --git a/src/MUI.Catalog/Claims.cs b/src/MUI.Catalog/Claims.cs
index bf1e2a85..01a24b12 100644
--- a/src/MUI.Catalog/Claims.cs
+++ b/src/MUI.Catalog/Claims.cs
@@ -16,6 +16,36 @@ public enum ClaimChannel
ConnectScreen,
}
+///
+/// Whether a claimant is joining a game's owners or taking it over (spec §8.4, §8.5).
+///
+///
+///
+/// Declared, never inferred. §8.5 allows a game several owners, each having verified a token
+/// of their own; §8.4 makes a counter-claim the way a game changes hands. Both are real, they are
+/// opposite outcomes, and nothing in a probe can tell them apart — a co-founder joining and a new
+/// operator taking over publish the identical line in the identical config file. So the claimant says
+/// which, when they ask for the token, on a page that explains both.
+///
+///
+/// Guessing was the alternative and it is wrong in both directions. "A claim on an already-claimed
+/// game is a takeover" silently unclaims a partner the first time two people run one game; the
+/// opposite leaves a departed operator holding a listing they no longer run.
+///
+///
+/// Neither is the more trusted. Both publish a token on the server, which is the whole test — so the
+/// choice decides what happens to the other claims, not how hard this one was to make.
+///
+///
+public enum ClaimIntent
+{
+ /// Become one of the game's owners, alongside whoever else has proved it.
+ Join,
+
+ /// Take the game over. On verification, every other verified claim is revoked.
+ Assume,
+}
+
///
/// A claim on a game by an account: pending while is null, verified after.
///
@@ -44,6 +74,9 @@ public sealed record GameClaim
public required string Token { get; init; }
+ /// Whether verifying this joins the game's owners or displaces them (spec §8.4).
+ public ClaimIntent Intent { get; init; } = ClaimIntent.Join;
+
public required DateTimeOffset IssuedAt { get; init; }
///
@@ -206,6 +239,11 @@ public enum ClaimVerdict
/// A token we issued, matching a claim that has expired or been revoked.
Stale,
+
+ ///
+ /// A counter-claim completed: this account now owns the game and the others were revoked (§8.4).
+ ///
+ Assumed,
}
///
diff --git a/src/MUI.Catalog/Persistence/ClaimService.cs b/src/MUI.Catalog/Persistence/ClaimService.cs
index c4f8396c..a7babf89 100644
--- a/src/MUI.Catalog/Persistence/ClaimService.cs
+++ b/src/MUI.Catalog/Persistence/ClaimService.cs
@@ -47,6 +47,7 @@ public sealed class ClaimService(
public async Task IssueAsync(
Guid gameId,
Guid userId,
+ ClaimIntent intent = ClaimIntent.Join,
CancellationToken cancellationToken = default)
{
var now = time.GetUtcNow();
@@ -63,6 +64,7 @@ public async Task IssueAsync(
GameId = gameId,
UserId = userId,
Token = ClaimToken.Mint(),
+ Intent = intent,
IssuedAt = now,
ExpiresAt = now + ClaimToken.PendingLifetime,
};
@@ -124,7 +126,40 @@ await claims.RecordEventAsync(
// reachable before now.
await games.SetClaimedAsync(gameId, true, cancellationToken);
- return ClaimVerdict.Verified;
+ if (pending.Intent is not ClaimIntent.Assume)
+ {
+ return ClaimVerdict.Verified;
+ }
+
+ // §8.4's counter-claim, which is how a game changes hands. The displaced owners are
+ // revoked here and nowhere else: this is the ONE revocation nobody typed, and it is
+ // sound because the account that caused it published a token on the same server the
+ // others did. It proves control now, which is the only thing ownership here ever meant.
+ //
+ // The game stays claimed throughout — SetClaimedAsync was called above — so a takeover
+ // never flickers the listing badge off and on.
+ var displaced = (await claims.ForGameAsync(gameId, cancellationToken))
+ .Where(other => other.Id != pending.Id && other.IsVerified)
+ .ToList();
+
+ foreach (var other in displaced)
+ {
+ await claims.UpdateAsync(
+ other with
+ {
+ RevokedAt = now,
+ RevokedReason = "counter-claim: another account proved control of this game",
+ },
+ cancellationToken);
+
+ // On the LOSING claim, because that is whose record changed. An owner reading their
+ // own history has to be able to see what happened to them and when.
+ await claims.RecordEventAsync(
+ new ClaimEvent(other.Id, now, ClaimEventKind.CounterClaimed),
+ cancellationToken);
+ }
+
+ return displaced.Count > 0 ? ClaimVerdict.Assumed : ClaimVerdict.Verified;
}
var onGame = await claims.ForGameAsync(gameId, cancellationToken);
@@ -209,6 +244,44 @@ await claims.RecordEventAsync(
/// claimed only when no verified claim is left, because §8.5 allows several owners and one
/// walking away does not unclaim the game for the others.
///
+ ///
+ /// An owner giving up a game they hold.
+ ///
+ ///
+ /// Scoped to the account, because takes a claim id and a claim id is
+ /// not a credential — anybody who learns one could otherwise unclaim somebody else's game. The
+ /// caller is the account, so the check belongs here rather than in whichever page happens to
+ /// call it.
+ ///
+ public async Task ResignAsync(
+ Guid claimId,
+ Guid userId,
+ CancellationToken cancellationToken = default)
+ {
+ if (await claims.FindAsync(claimId, cancellationToken) is not { } claim
+ || claim.UserId != userId
+ || !claim.IsVerified)
+ {
+ return false;
+ }
+
+ await RevokeAsync(claimId, "the owner gave up this claim", cancellationToken);
+
+ return true;
+ }
+
+ /// Every account that has proved control of a game, newest first (spec §8.5).
+ public async Task> OwnersAsync(
+ Guid gameId,
+ CancellationToken cancellationToken = default) =>
+ [.. (await claims.ForGameAsync(gameId, cancellationToken)).Where(claim => claim.IsVerified)];
+
+ /// One claim's audit log, oldest first (spec §8.5).
+ public Task> HistoryAsync(
+ Guid claimId,
+ CancellationToken cancellationToken = default) =>
+ claims.EventsAsync(claimId, cancellationToken);
+
public async Task RevokeAsync(Guid claimId, string reason, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
diff --git a/src/MUI.Catalog/Persistence/NpgsqlClaimStore.cs b/src/MUI.Catalog/Persistence/NpgsqlClaimStore.cs
index d38a7364..3984f318 100644
--- a/src/MUI.Catalog/Persistence/NpgsqlClaimStore.cs
+++ b/src/MUI.Catalog/Persistence/NpgsqlClaimStore.cs
@@ -17,7 +17,7 @@ namespace MUI.Catalog.Persistence;
public sealed class NpgsqlClaimStore(NpgsqlDataSource source) : IClaimStore
{
private const string Columns = """
- id AS Id, game_id AS GameId, user_id AS UserId, token AS Token,
+ id AS Id, game_id AS GameId, user_id AS UserId, token AS Token, intent AS Intent,
issued_at AS IssuedAt, expires_at AS ExpiresAt, claimed_at AS ClaimedAt,
beacon_last_seen_at AS BeaconLastSeenAt, verified_via AS VerifiedVia,
revoked_at AS RevokedAt, revoked_reason AS RevokedReason, last_checked_at AS LastCheckedAt
@@ -105,11 +105,11 @@ public async Task InsertAsync(GameClaim claim, CancellationToken cancellationTok
await connection.ExecuteAsync(new CommandDefinition(
"""
INSERT INTO game_claim (
- id, game_id, user_id, token, issued_at, expires_at,
+ id, game_id, user_id, token, intent, issued_at, expires_at,
claimed_at, beacon_last_seen_at, verified_via,
revoked_at, revoked_reason, last_checked_at)
VALUES (
- @Id, @GameId, @UserId, @Token, @IssuedAt, @ExpiresAt,
+ @Id, @GameId, @UserId, @Token, @Intent, @IssuedAt, @ExpiresAt,
@ClaimedAt, @BeaconLastSeenAt, @VerifiedVia,
@RevokedAt, @RevokedReason, @LastCheckedAt)
""",
@@ -182,6 +182,7 @@ public async Task> EventsAsync(
claim.GameId,
claim.UserId,
claim.Token,
+ Intent = SqlEnums.ToDb(claim.Intent),
claim.IssuedAt,
claim.ExpiresAt,
claim.ClaimedAt,
@@ -207,6 +208,8 @@ private sealed class Row
public string Token { get; init; } = string.Empty;
+ public string Intent { get; init; } = "join";
+
public DateTimeOffset IssuedAt { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
@@ -229,6 +232,7 @@ private sealed class Row
GameId = GameId,
UserId = UserId,
Token = Token,
+ Intent = SqlEnums.ToClaimIntent(Intent),
IssuedAt = IssuedAt,
ExpiresAt = ExpiresAt,
ClaimedAt = ClaimedAt,
diff --git a/src/MUI.Catalog/Persistence/SqlEnums.cs b/src/MUI.Catalog/Persistence/SqlEnums.cs
index 898c0691..fd2be9c1 100644
--- a/src/MUI.Catalog/Persistence/SqlEnums.cs
+++ b/src/MUI.Catalog/Persistence/SqlEnums.cs
@@ -33,6 +33,21 @@ public static class SqlEnums
_ => throw Unread(value, nameof(FieldSource)),
};
+ /// Whether a claim joins a game's owners or displaces them (spec §8.4).
+ public static string ToDb(ClaimIntent intent) => intent switch
+ {
+ ClaimIntent.Join => "join",
+ ClaimIntent.Assume => "assume",
+ _ => throw Unmapped(intent),
+ };
+
+ public static ClaimIntent ToClaimIntent(string value) => value switch
+ {
+ "join" => ClaimIntent.Join,
+ "assume" => ClaimIntent.Assume,
+ _ => throw Unread(value, nameof(ClaimIntent)),
+ };
+
///
/// The channel a claim token was read from (spec §8.3). DNS is absent from the enum, not merely
/// unmapped here — a TXT record proves control of a hostname, and a hostname is not a game.
diff --git a/src/MUI.Web/Accounts/OwnershipWrites.cs b/src/MUI.Web/Accounts/OwnershipWrites.cs
new file mode 100644
index 00000000..e5a53268
--- /dev/null
+++ b/src/MUI.Web/Accounts/OwnershipWrites.cs
@@ -0,0 +1,78 @@
+using Microsoft.AspNetCore.Identity;
+
+using MUI.Catalog;
+using MUI.Catalog.Persistence;
+
+namespace MUI.Web.Accounts;
+
+///
+/// The two ownership decisions a person makes with a form (spec §8.4, §8.5).
+///
+///
+/// Both are plain posts and redirects, like the rest of the account surface, and both delegate every
+/// decision to — including who is allowed to make it. A claim id is not a
+/// credential and travels in URLs and logs, so the account has to be checked against the claim
+/// rather than assumed from the fact that somebody knew its id.
+///
+public static class OwnershipWrites
+{
+ public static void MapMuiOwnership(this WebApplication app)
+ {
+ ArgumentNullException.ThrowIfNull(app);
+
+ // Asking for a token on a game somebody already owns, having said which of the two things
+ // §8.5 allows you are doing. The choice is made HERE, before the token exists, because it
+ // is stored on the claim the token belongs to — a token published as a co-owner must not be
+ // settleable as a takeover.
+ app.MapPost("/g/{slug}/claim/start", async (
+ HttpContext context,
+ UserManager users,
+ IGameQueries queries,
+ ClaimService claims,
+ string slug,
+ IFormCollection form) =>
+ {
+ if (await users.GetUserAsync(context.User) is not { } user
+ || await queries.FindAsync(slug) is not { } page)
+ {
+ return Results.Redirect($"/g/{slug}/claim");
+ }
+
+ var intent = string.Equals(form["intent"], "assume", StringComparison.Ordinal)
+ ? ClaimIntent.Assume
+ : ClaimIntent.Join;
+
+ await claims.IssueAsync(page.Summary.Id, user.Id, intent);
+
+ return Results.Redirect($"/g/{slug}/claim");
+ }).RequireAuthorization();
+
+ // Giving up a game. Explicit, which §8.4 requires of every revocation that is not a
+ // counter-claim: a claim never lapses on its own and absence never revokes.
+ app.MapPost("/account/claims/{claimId:guid}/resign", async (
+ HttpContext context,
+ UserManager users,
+ ClaimService claims,
+ Guid claimId,
+ IFormCollection form) =>
+ {
+ if (await users.GetUserAsync(context.User) is not { } user)
+ {
+ return Results.StatusCode(StatusCodes.Status403Forbidden);
+ }
+
+ // A typed confirmation rather than a second page. Resigning is irreversible in the sense
+ // that getting back in means publishing a fresh token — recoverable, but not by pressing
+ // undo — and a bare button beside a game's name is one misclick from a listing an
+ // operator no longer owns.
+ if (!string.Equals(form["confirm"], "resign", StringComparison.Ordinal))
+ {
+ return Results.Redirect($"{Passkeys.DashboardPath}?resign={claimId}");
+ }
+
+ return await claims.ResignAsync(claimId, user.Id)
+ ? Results.Redirect($"{Passkeys.DashboardPath}?resigned=1")
+ : Results.StatusCode(StatusCodes.Status403Forbidden);
+ }).RequireAuthorization();
+ }
+}
diff --git a/src/MUI.Web/Accounts/Passkeys.cs b/src/MUI.Web/Accounts/Passkeys.cs
index f029b86f..cfc09b69 100644
--- a/src/MUI.Web/Accounts/Passkeys.cs
+++ b/src/MUI.Web/Accounts/Passkeys.cs
@@ -277,6 +277,9 @@ await signIn.MakePasskeyRequestOptionsAsync(user: null),
// §8.5's enrichment and §11's suppression, which are the only writes a claim grants.
app.MapMuiOwnerWrites();
+
+ // §8.4's counter-claim and §8.5's several owners, as the two forms that reach them.
+ app.MapMuiOwnership();
}
/// What the page posts back after the authenticator has answered.
diff --git a/src/MUI.Web/Components/Pages/Account.razor b/src/MUI.Web/Components/Pages/Account.razor
index bf204cd6..075426ee 100644
--- a/src/MUI.Web/Components/Pages/Account.razor
+++ b/src/MUI.Web/Components/Pages/Account.razor
@@ -52,7 +52,19 @@ else
}
- @if (Saved is not null)
+ @*
+ One banner slot, and the three things a POST to this page can come back saying. An
+ else-if chain rather than three independent blocks: a redirect carries exactly one
+ outcome, and two banners at once would be two answers to one action.
+ *@
+ @if (Resigned)
+ {
+
+ Given up. The record of it is kept, and you can prove control again
+ any time by publishing a fresh token.
+
+ }
+ else if (Saved is not null)
{
Saved. @Outcome
}
@@ -91,8 +103,24 @@ else
@* §8.5's scorecard. Owner-only, so it is reachable from here and nowhere. *@
check your MSSP
+
+ @if (CoOwners(claim) is { Count: > 0 } others)
+ {
+ @* §8.5 — a game may have several owners, and each of them should know. *@
+
+ Also owned by @string.Join(", ", others.Select(Name)) — each having
+ verified a token of their own.
+
+ }
+
+ @*
+ Name is prefixed, and that is not decoration: a component parameter of type
+ string written as Name="game.Name" is a LITERAL, so every owner was shown
+ "What only you can tell us about game.Name". Non-string parameters beside it
+ are parsed as expressions either way, which is what hid it.
+ *@
@@ -115,6 +143,39 @@ else
your own.
+
+
+ history
+
+ @foreach (var entry in History(claim))
+ {
+ -
+ @entry.At.ToString("yyyy-MM-dd HH:mm")
+ @Word(entry.Kind)@(entry.Detail is { Length: > 0 } d ? $" · {d}" : string.Empty)
+
+ }
+
+
+
+ @*
+ Explicit, and typed. §8.4 lets a claim end only by an owner saying so or by a
+ counter-claim — never by lapsing — and a bare button beside a game's name is
+ one misclick from a listing somebody no longer owns. Last in the block, after
+ everything an owner came here to do.
+ *@
+
+ give up this claim
+
+
}
}
@@ -211,6 +272,25 @@ else
[SupplyParameterFromQuery(Name = "because")]
private string? Because { get; set; }
+ /// Every verified claim on each of this account's games, so co-owners can be named.
+ private Dictionary> Owners { get; } = [];
+
+ /// Each of this account's claims' audit log (spec §8.5).
+ private Dictionary> Events { get; } = [];
+
+ /// Display names for the accounts that co-own something here.
+ private Dictionary Names { get; } = [];
+
+ [SupplyParameterFromQuery(Name = "resigned")]
+ private string? ResignedFlag { get; set; }
+
+ [SupplyParameterFromQuery(Name = "resign")]
+ private string? ResignFlag { get; set; }
+
+ private bool Resigned => ResignedFlag is not null;
+
+ private Guid? Confirming => Guid.TryParse(ResignFlag, out var id) ? id : null;
+
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
@@ -303,6 +383,23 @@ else
{
Owned[game.Id] = await enrichment.DeclaredAsync(game.Id);
}
+
+ if (Services.GetService() is { } service)
+ {
+ Owners[claim.Id] = await service.OwnersAsync(game.Id);
+ Events[claim.Id] = await service.HistoryAsync(claim.Id);
+
+ foreach (var owner in Owners[claim.Id].Where(o => o.UserId != User.Id))
+ {
+ // A co-owner's display name, which they chose and which appears only to the
+ // people they share a game with. Never an address and never an identity
+ // claim (§8.2).
+ Names.TryAdd(
+ owner.UserId,
+ (await Users.FindByIdAsync(owner.UserId.ToString()))?.DisplayName
+ ?? "another account");
+ }
+ }
}
else if (claim.IsPending(now))
{
@@ -320,6 +417,39 @@ else
/// do with the owner, and alarming them about it would be the interface arguing for a rule the
/// system does not have.
///
+ private IReadOnlyList CoOwners(GameClaim mine) =>
+ Owners.TryGetValue(mine.Id, out var all)
+ ? [.. all.Where(other => other.UserId != mine.UserId)]
+ : [];
+
+ private IReadOnlyList History(GameClaim mine) =>
+ Events.TryGetValue(mine.Id, out var events) ? events : [];
+
+ private string Name(GameClaim claim) =>
+ Names.TryGetValue(claim.UserId, out var name) ? name : "another account";
+
+ ///
+ /// One audit entry in words. The vocabulary is the enum's, spelled for a person.
+ ///
+ ///
+ /// BeaconMissing reads as an observation and not as a warning: a probe not reading the
+ /// token happens for reasons that have nothing to do with the owner, and absence never revokes
+ /// (§8.4). Alarming somebody about it would be the interface arguing for a rule the system does
+ /// not have.
+ ///
+ private static string Word(ClaimEventKind kind) => kind switch
+ {
+ ClaimEventKind.Issued => "token issued",
+ ClaimEventKind.Reissued => "token issued again",
+ ClaimEventKind.Verified => "verified — we read your token",
+ ClaimEventKind.BeaconSeen => "token still published",
+ ClaimEventKind.BeaconMissing => "token not read this time",
+ ClaimEventKind.Revoked => "claim given up",
+ ClaimEventKind.Expired => "token expired unused",
+ ClaimEventKind.CounterClaimed => "another account proved control and took the game over",
+ _ => "check requested",
+ };
+
private static string BeaconNote(GameClaim claim) =>
claim.BeaconLastSeenAt is { } seen
? $", token last seen {seen:d MMM yyyy}"
diff --git a/src/MUI.Web/Components/Pages/Claim.razor b/src/MUI.Web/Components/Pages/Claim.razor
index 6d1feecf..0ba4691b 100644
--- a/src/MUI.Web/Components/Pages/Claim.razor
+++ b/src/MUI.Web/Components/Pages/Claim.razor
@@ -44,6 +44,44 @@ else if (User is null)
Sign in or create an account
}
+else if (Pending is null)
+{
+ @*
+ The game already has owners, so arriving here is not by itself the ask: §8.5 allows several
+ owners and §8.4 makes a counter-claim how a game changes hands, and NOTHING IN A PROBE CAN
+ TELL THEM APART — both publish the identical line in the identical config file. So the
+ choice is made before the token exists, by the person who knows which they mean.
+ *@
+
+ Claim @Game.Name
+
+
+ This game already has @(Owners.Count == 1 ? "an owner" : $"{Owners.Count} owners") who
+ proved control of the server. You can prove it too — the test is the same either way —
+ but we need to know what you mean by it, because we cannot tell from the token.
+
+
+
+
+
+
+}
else
{
@@ -71,6 +109,15 @@ else
whole test.
+ @if (Pending.Intent is ClaimIntent.Assume)
+ {
+
+ This is a transfer. When we read this token, the
+ @(Owners.Count == 1 ? "current owner's claim" : "current owners' claims") on this
+ game are revoked and it becomes yours.
+
+ }
+
@Pending.Token
Either of these will do
@@ -135,6 +182,9 @@ else
private GameClaim? Pending { get; set; }
+ /// Whoever has already proved control of this game (spec §8.5).
+ private IReadOnlyList Owners { get; set; } = [];
+
private bool CanCheck => Pending is not null && Claims!.MayRecheck(Pending);
///
@@ -169,7 +219,25 @@ else
User = await users.GetUserAsync(principal);
}
- if (User is not null)
+ if (User is null)
+ {
+ return;
+ }
+
+ Owners = await Claims.OwnersAsync(Game.Id);
+
+ var mine = (await Services.GetRequiredService().ForUserAsync(User.Id))
+ .Where(claim => claim.GameId == Game.Id)
+ .ToList();
+
+ Pending = mine.FirstOrDefault(claim => claim.IsVerified)
+ ?? mine.FirstOrDefault(claim => claim.IsPending(Clock.GetUtcNow()));
+
+ // Minting on view is right for a game nobody owns — arriving here IS the ask, and
+ // IssueAsync returns an existing token rather than replacing one already pasted into a
+ // config file. It is wrong for a game that has owners, where the ask has two meanings and
+ // only the claimant knows which: that case falls through to the choice above.
+ if (Pending is null && Owners.Count == 0)
{
Pending = await Claims.IssueAsync(Game.Id, User.Id);
}
diff --git a/src/MUI.Web/wwwroot/app.css b/src/MUI.Web/wwwroot/app.css
index 9398f4a1..482a0df4 100644
--- a/src/MUI.Web/wwwroot/app.css
+++ b/src/MUI.Web/wwwroot/app.css
@@ -1117,3 +1117,23 @@ details.publish pre {
white-space: pre-wrap;
word-break: break-all;
}
+
+/* ── ownership: co-owners, the audit log, and giving up a claim ────────────
+ §8.5's several owners and §8.4's explicit revocation. The history is a
+ details block because it is the sort of thing somebody reads once, when
+ something has happened that they want explained.
+
+ The resign form is deliberately unstyled as a danger: no red, no warning
+ triangle. Giving up a claim is recoverable — publish a fresh token — and
+ dressing it as destruction would overstate what it does. The typed
+ confirmation is the guard, not the colour. */
+
+details.history, details.resign { margin-top: 6px; }
+details.history > summary, details.resign > summary { cursor: pointer; font-size: 12px; }
+
+ol.events { list-style: none; margin: 6px 0 0; padding: 0; font-size: 12px; }
+ol.events li { padding: 2px 0; color: var(--dim); }
+ol.events .faint { margin-right: 8px; }
+
+details.resign form { margin-top: 6px; }
+details.resign input[type="text"] { display: block; margin: 6px 0; max-width: 16ch; }
diff --git a/tests/MUI.Catalog.Tests/Persistence/OwnershipPostgresTests.cs b/tests/MUI.Catalog.Tests/Persistence/OwnershipPostgresTests.cs
new file mode 100644
index 00000000..b112beb4
--- /dev/null
+++ b/tests/MUI.Catalog.Tests/Persistence/OwnershipPostgresTests.cs
@@ -0,0 +1,354 @@
+using Dapper;
+
+using MUI.Catalog.Persistence;
+using MUI.Catalog.Tests.Persistence.Support;
+
+using Npgsql;
+
+namespace MUI.Catalog.Tests.Persistence;
+
+///
+/// Several owners, a game changing hands, and the log that says which happened (spec §8.4, §8.5).
+///
+///
+/// The whole of this file turns on one thing: a co-founder joining and a new operator taking over
+/// publish the identical line in the identical config file, so the difference cannot be measured and
+/// must be declared. These assert that it is — and that neither outcome can be reached by accident.
+///
+public class OwnershipPostgresTests
+{
+ private static readonly DateTimeOffset Now = Seed.Now;
+
+ /// §8.5 — a game may have several owners, each having verified a token of their own.
+ [Test]
+ public async Task TwoAccountsMayBothOwnOneGame()
+ {
+ await using var db = await PostgresFixture.MigratedAsync();
+ var game = await Seed.GameAsync(db);
+ var service = Service(db);
+ var one = await UserAsync(db, "one");
+ var two = await UserAsync(db, "two");
+
+ var first = await service.IssueAsync(game, one);
+ await service.OfferBeaconAsync(game, first.Token, ClaimChannel.Mssp);
+
+ var second = await service.IssueAsync(game, two);
+ var verdict = await service.OfferBeaconAsync(game, second.Token, ClaimChannel.ConnectScreen);
+
+ // Joining is the default, so the first owner is untouched.
+ await Assert.That(verdict).IsEqualTo(ClaimVerdict.Verified);
+ await Assert.That((await service.OwnersAsync(game)).Count).IsEqualTo(2);
+ await Assert.That(await IsClaimedAsync(db, game)).IsTrue();
+ }
+
+ ///
+ /// §8.4 — a counter-claim is how a game changes hands, and it says so before it is published.
+ ///
+ [Test]
+ public async Task ACounterClaimTakesTheGameOverAndRevokesTheOthers()
+ {
+ await using var db = await PostgresFixture.MigratedAsync();
+ var game = await Seed.GameAsync(db);
+ var service = Service(db);
+ var departing = await UserAsync(db, "departing");
+ var arriving = await UserAsync(db, "arriving");
+
+ var old = await service.IssueAsync(game, departing);
+ await service.OfferBeaconAsync(game, old.Token, ClaimChannel.Mssp);
+
+ var takeover = await service.IssueAsync(game, arriving, ClaimIntent.Assume);
+ var verdict = await service.OfferBeaconAsync(game, takeover.Token, ClaimChannel.Mssp);
+
+ await Assert.That(verdict).IsEqualTo(ClaimVerdict.Assumed);
+
+ var owners = await service.OwnersAsync(game);
+
+ await Assert.That(owners.Count).IsEqualTo(1);
+ await Assert.That(owners.Single().UserId).IsEqualTo(arriving);
+
+ // The game never stops being claimed on the way through, so the listing badge does not
+ // flicker off and on while a handover completes.
+ await Assert.That(await IsClaimedAsync(db, game)).IsTrue();
+ }
+
+ ///
+ /// The displaced owner's own log says what happened to them, and when.
+ ///
+ ///
+ /// The event goes on the losing claim rather than the winning one, because that is whose record
+ /// changed. An owner who finds their claim gone must be able to read why from their own history
+ /// rather than by inferring it from somebody else's.
+ ///
+ [Test]
+ public async Task TheDisplacedOwnerCanReadWhatHappenedInTheirOwnHistory()
+ {
+ await using var db = await PostgresFixture.MigratedAsync();
+ var game = await Seed.GameAsync(db);
+ var service = Service(db);
+ var departing = await UserAsync(db, "departing");
+ var arriving = await UserAsync(db, "arriving");
+
+ var old = await service.IssueAsync(game, departing);
+ await service.OfferBeaconAsync(game, old.Token, ClaimChannel.Mssp);
+
+ var takeover = await service.IssueAsync(game, arriving, ClaimIntent.Assume);
+ await service.OfferBeaconAsync(game, takeover.Token, ClaimChannel.Mssp);
+
+ var history = await service.HistoryAsync(old.Id);
+
+ await Assert.That(history.Select(e => e.Kind)).Contains(ClaimEventKind.CounterClaimed);
+
+ var revoked = (await new NpgsqlClaimStore(db.DataSource).ForGameAsync(game))
+ .Single(c => c.Id == old.Id);
+
+ await Assert.That(revoked.RevokedAt).IsNotNull();
+ await Assert.That(revoked.RevokedReason).Contains("counter-claim");
+ }
+
+ ///
+ /// A takeover that displaces nobody is an ordinary first claim, and is not reported as a seizure.
+ ///
+ [Test]
+ public async Task AssumingAnUnclaimedGameIsJustAClaim()
+ {
+ await using var db = await PostgresFixture.MigratedAsync();
+ var game = await Seed.GameAsync(db);
+ var service = Service(db);
+ var user = await UserAsync(db, "first");
+
+ var claim = await service.IssueAsync(game, user, ClaimIntent.Assume);
+ var verdict = await service.OfferBeaconAsync(game, claim.Token, ClaimChannel.Mssp);
+
+ await Assert.That(verdict).IsEqualTo(ClaimVerdict.Verified);
+ await Assert.That((await service.OwnersAsync(game)).Count).IsEqualTo(1);
+ }
+
+ ///
+ /// Intent is recorded when the token is issued, so it cannot be changed after it is published.
+ ///
+ ///
+ /// The claimant declares it on the page that explains both, and the row carries it from that
+ /// moment. A design that read intent at verification time would let an account publish a token
+ /// as a co-owner and settle it as a takeover.
+ ///
+ [Test]
+ public async Task IntentIsStoredWithTheTokenAndSurvivesARoundTrip()
+ {
+ await using var db = await PostgresFixture.MigratedAsync();
+ var game = await Seed.GameAsync(db);
+ var service = Service(db);
+ var user = await UserAsync(db, "owner");
+
+ var claim = await service.IssueAsync(game, user, ClaimIntent.Assume);
+ var stored = (await new NpgsqlClaimStore(db.DataSource).ForGameAsync(game)).Single();
+
+ await Assert.That(stored.Intent).IsEqualTo(ClaimIntent.Assume);
+ await Assert.That(claim.Intent).IsEqualTo(ClaimIntent.Assume);
+ }
+
+ /// The schema refuses an intent nothing maps, as it does every other vocabulary.
+ [Test]
+ public async Task TheSchemaRefusesAnIntentWeDoNotKnow()
+ {
+ await using var db = await PostgresFixture.MigratedAsync();
+ var game = await Seed.GameAsync(db);
+ var user = await UserAsync(db, "owner");
+
+ await using var connection = await db.DataSource.OpenConnectionAsync();
+
+ await Assert.That(async () => await connection.ExecuteAsync(
+ """
+ INSERT INTO game_claim (id, game_id, user_id, token, intent, issued_at, expires_at)
+ VALUES (@id, @game, @user, 'muidx-aaaaaaaaaaaaaaaaaaaa', 'seize', @now, @later)
+ """,
+ new { id = Guid.CreateVersion7(), game, user, now = Now, later = Now.AddDays(30) }))
+ .Throws();
+ }
+
+ /// A claim that existed before intent did was a first claim, and reads as one.
+ ///
+ /// The column's DEFAULT is the backfill: none of those claims displaced anybody, so recording
+ /// them as joins states what happened rather than inventing an intent nobody expressed.
+ ///
+ [Test]
+ public async Task AClaimWrittenWithoutAnIntentIsAJoin()
+ {
+ await using var db = await PostgresFixture.MigratedAsync();
+ var game = await Seed.GameAsync(db);
+ var user = await UserAsync(db, "owner");
+
+ await using var connection = await db.DataSource.OpenConnectionAsync();
+
+ await connection.ExecuteAsync(
+ """
+ INSERT INTO game_claim (id, game_id, user_id, token, issued_at, expires_at)
+ VALUES (@id, @game, @user, 'muidx-aaaaaaaaaaaaaaaaaaaa', @now, @later)
+ """,
+ new { id = Guid.CreateVersion7(), game, user, now = Now, later = Now.AddDays(30) });
+
+ var stored = (await new NpgsqlClaimStore(db.DataSource).ForGameAsync(game)).Single();
+
+ await Assert.That(stored.Intent).IsEqualTo(ClaimIntent.Join);
+ }
+
+ /// An owner may give up a game, and the others keep theirs (§8.5).
+ [Test]
+ public async Task AnOwnerMayResignWithoutUnclaimingTheGameForTheRest()
+ {
+ await using var db = await PostgresFixture.MigratedAsync();
+ var game = await Seed.GameAsync(db);
+ var service = Service(db);
+ var leaving = await UserAsync(db, "leaving");
+ var staying = await UserAsync(db, "staying");
+
+ var theirs = await service.IssueAsync(game, leaving);
+ await service.OfferBeaconAsync(game, theirs.Token, ClaimChannel.Mssp);
+ var others = await service.IssueAsync(game, staying);
+ await service.OfferBeaconAsync(game, others.Token, ClaimChannel.Mssp);
+
+ await Assert.That(await service.ResignAsync(theirs.Id, leaving)).IsTrue();
+
+ await Assert.That((await service.OwnersAsync(game)).Single().UserId).IsEqualTo(staying);
+ await Assert.That(await IsClaimedAsync(db, game)).IsTrue();
+ }
+
+ ///
+ /// A claim id is not a credential, so resigning is scoped to the account that holds the claim.
+ ///
+ ///
+ /// RevokeAsync takes a claim id and nothing else, which is right for the service and wrong
+ /// for a caller reached from a form. Anybody who learned an id could otherwise unclaim somebody
+ /// else's game, and ids travel — they are in URLs, in logs and in this test.
+ ///
+ [Test]
+ public async Task NobodyCanResignSomebodyElsesClaim()
+ {
+ await using var db = await PostgresFixture.MigratedAsync();
+ var game = await Seed.GameAsync(db);
+ var service = Service(db);
+ var owner = await UserAsync(db, "owner");
+ var stranger = await UserAsync(db, "stranger");
+
+ var theirs = await service.IssueAsync(game, owner);
+ await service.OfferBeaconAsync(game, theirs.Token, ClaimChannel.Mssp);
+
+ await Assert.That(await service.ResignAsync(theirs.Id, stranger)).IsFalse();
+ await Assert.That((await service.OwnersAsync(game)).Count).IsEqualTo(1);
+ }
+
+ /// The last owner leaving unclaims the game, and the badge goes with it.
+ [Test]
+ public async Task TheLastOwnerLeavingUnclaimsTheGame()
+ {
+ await using var db = await PostgresFixture.MigratedAsync();
+ var game = await Seed.GameAsync(db);
+ var service = Service(db);
+ var owner = await UserAsync(db, "owner");
+
+ var theirs = await service.IssueAsync(game, owner);
+ await service.OfferBeaconAsync(game, theirs.Token, ClaimChannel.Mssp);
+
+ await service.ResignAsync(theirs.Id, owner);
+
+ await Assert.That(await service.OwnersAsync(game)).IsEmpty();
+ await Assert.That(await IsClaimedAsync(db, game)).IsFalse();
+
+ // Nothing is deleted: the claim is still there, revoked, with its reason and its history.
+ var stored = (await new NpgsqlClaimStore(db.DataSource).ForGameAsync(game)).Single();
+ await Assert.That(stored.RevokedAt).IsNotNull();
+ await Assert.That((await service.HistoryAsync(theirs.Id)).Select(e => e.Kind))
+ .Contains(ClaimEventKind.Revoked);
+ }
+
+ ///
+ /// The audit log is the whole story of a claim, in order (§8.5).
+ ///
+ [Test]
+ public async Task TheHistoryReadsAsWhatActuallyHappened()
+ {
+ await using var db = await PostgresFixture.MigratedAsync();
+ var game = await Seed.GameAsync(db);
+ var service = Service(db);
+ var owner = await UserAsync(db, "owner");
+
+ var claim = await service.IssueAsync(game, owner);
+ await service.OfferBeaconAsync(game, claim.Token, ClaimChannel.Mssp);
+ await service.OfferBeaconAsync(game, claim.Token, ClaimChannel.Mssp);
+ await service.ResignAsync(claim.Id, owner);
+
+ var kinds = (await service.HistoryAsync(claim.Id)).Select(e => e.Kind).ToList();
+
+ await Assert.That(kinds[0]).IsEqualTo(ClaimEventKind.Issued);
+ await Assert.That(kinds).Contains(ClaimEventKind.Verified);
+ await Assert.That(kinds).Contains(ClaimEventKind.BeaconSeen);
+ await Assert.That(kinds[^1]).IsEqualTo(ClaimEventKind.Revoked);
+ }
+
+ ///
+ /// A revoked claim's token stops working, so a takeover cannot be undone by the old beacon.
+ ///
+ ///
+ /// The displaced operator's token is very likely still sitting in the config file — that is the
+ /// normal state of a handover — and every probe reads it. It must settle nothing: presence
+ /// establishes and absence never revokes (§8.4), but a revoked claim is not a claim.
+ ///
+ [Test]
+ public async Task ADisplacedOwnersTokenStillOnTheServerSettlesNothing()
+ {
+ await using var db = await PostgresFixture.MigratedAsync();
+ var game = await Seed.GameAsync(db);
+ var service = Service(db);
+ var departing = await UserAsync(db, "departing");
+ var arriving = await UserAsync(db, "arriving");
+
+ var old = await service.IssueAsync(game, departing);
+ await service.OfferBeaconAsync(game, old.Token, ClaimChannel.Mssp);
+
+ var takeover = await service.IssueAsync(game, arriving, ClaimIntent.Assume);
+ await service.OfferBeaconAsync(game, takeover.Token, ClaimChannel.Mssp);
+
+ // The next probe still reads the old token off the connect screen.
+ var verdict = await service.OfferBeaconAsync(game, old.Token, ClaimChannel.ConnectScreen);
+
+ await Assert.That(verdict).IsEqualTo(ClaimVerdict.Stale);
+ await Assert.That((await service.OwnersAsync(game)).Single().UserId).IsEqualTo(arriving);
+ }
+
+ private static ClaimService Service(TestDatabase db) =>
+ new(
+ new NpgsqlClaimStore(db.DataSource),
+ new NpgsqlGameStore(db.DataSource),
+ TimeProvider.System);
+
+ private static async Task UserAsync(TestDatabase db, string name)
+ {
+ var id = Guid.CreateVersion7();
+
+ await using var connection = await db.DataSource.OpenConnectionAsync();
+
+ await connection.ExecuteAsync(
+ """
+ INSERT INTO app_user (id, display_name, normalised_name, security_stamp,
+ concurrency_stamp, created_at)
+ VALUES (@id, @name, @normalised, @stamp, @stamp, @now)
+ """,
+ new
+ {
+ id,
+ name,
+ normalised = name.ToUpperInvariant(),
+ stamp = Guid.NewGuid().ToString(),
+ now = Now,
+ });
+
+ return id;
+ }
+
+ private static async Task IsClaimedAsync(TestDatabase db, Guid game)
+ {
+ await using var connection = await db.DataSource.OpenConnectionAsync();
+
+ return await connection.ExecuteScalarAsync(
+ "SELECT is_claimed FROM game WHERE id = @game", new { game });
+ }
+}
diff --git a/tests/MUI.Catalog.Tests/Persistence/OwnershipSchemaTests.cs b/tests/MUI.Catalog.Tests/Persistence/OwnershipSchemaTests.cs
index 43f68c54..1b673cbf 100644
--- a/tests/MUI.Catalog.Tests/Persistence/OwnershipSchemaTests.cs
+++ b/tests/MUI.Catalog.Tests/Persistence/OwnershipSchemaTests.cs
@@ -152,6 +152,54 @@ await connection.ExecuteAsync(
}
}
+ ///
+ /// Both directions of intent (spec §8.4): the schema refuses what we cannot write, and
+ /// accepts everything we can.
+ ///
+ ///
+ /// The second half matters more than it looks. A CHECK that refuses a value the code
+ /// really produces does not fail here — it fails the first time somebody tries to take over a
+ /// game, in production, at the end of a flow they have already published a token for.
+ ///
+ [Test]
+ public async Task TheSchemaAndTheCodeAgreeAboutWhatAClaimCanMean()
+ {
+ await using var db = await PostgresFixture.MigratedAsync();
+ var game = await Seed.GameAsync(db);
+ var user = await AccountAsync(db, "owner");
+
+ await using var connection = await db.DataSource.OpenConnectionAsync();
+
+ // One account per intent, because a partial unique index allows an account only one pending
+ // claim per game — which is §8.1's own rule and not something to work around here.
+ foreach (var intent in Enum.GetValues())
+ {
+ await connection.ExecuteAsync(
+ """
+ INSERT INTO game_claim (id, game_id, user_id, token, intent, issued_at, expires_at)
+ VALUES (@id, @game, @user, @token, @intent, @now, @later)
+ """,
+ new
+ {
+ id = Guid.CreateVersion7(),
+ game,
+ user = await AccountAsync(db, $"claimant-{intent}"),
+ token = "muidx-" + intent.ToString().ToLowerInvariant().PadRight(20, 'a'),
+ intent = SqlEnums.ToDb(intent),
+ now = Now,
+ later = Now.AddDays(30),
+ });
+ }
+
+ await Assert.That(async () => await connection.ExecuteAsync(
+ """
+ INSERT INTO game_claim (id, game_id, user_id, token, intent, issued_at, expires_at)
+ VALUES (@id, @game, @user, 'muidx-bbbbbbbbbbbbbbbbbbbb', 'seize', @now, @later)
+ """,
+ new { id = Guid.CreateVersion7(), game, user, now = Now, later = Now.AddDays(30) }))
+ .Throws();
+ }
+
private static Task InsertClaimAsync(NpgsqlConnection connection, Guid game, Guid user, string token) =>
connection.ExecuteAsync(
"""
diff --git a/tests/MUI.Web.Tests/AccountSurfaceTests.cs b/tests/MUI.Web.Tests/AccountSurfaceTests.cs
new file mode 100644
index 00000000..4b420b64
--- /dev/null
+++ b/tests/MUI.Web.Tests/AccountSurfaceTests.cs
@@ -0,0 +1,642 @@
+using System.Security.Claims;
+using System.Text.RegularExpressions;
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Forms;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+using MUI.Catalog;
+using MUI.Catalog.Persistence;
+using MUI.Web;
+using MUI.Web.Accounts;
+using MUI.Web.Components.Pages;
+using MUI.Web.Fixtures;
+
+namespace MUI.Web.Tests;
+
+///
+/// The owner dashboard, rendered in each state an operator can actually be in.
+///
+///
+///
+/// Five features' worth of markup landed in this one page across a merge chain — the owner panel,
+/// the MSSP scorecard link, the badge snippet, the co-owner line, the history block, the resign
+/// form and the status banner — and nothing rendered it. A component that only ever compiled is a
+/// component nobody has looked at.
+///
+///
+/// On authentication. The signed-out and no-database states go through
+/// end to end, because nothing stands between a visitor and those. The signed-in states cannot:
+/// §8.2 makes passkeys the only way in, so a loopback host has no way to produce an authenticated
+/// session without an authenticator. They are rendered at component level with an
+/// cascaded in, which is what the framework would have supplied — the
+/// page's own guard on Identity.IsAuthenticated still runs, and the claims it filters are
+/// real records in a real . What is stood in for is the user store
+/// and the credential ceremony, neither of which is the authorisation under test.
+///
+///
+public class AccountSurfaceTests
+{
+ private static readonly DateTimeOffset Now = FixtureGameQueries.Now;
+
+ /// A game from the fixture, so the page's own lookup by id has something to find.
+ private static readonly Guid Ashen = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000007");
+
+ private static readonly Guid Mush = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001");
+
+ // ── the states an operator is in ──────────────────────────────────────────
+
+ /// A site with no database says so, rather than offering half a claim flow.
+ ///
+ /// Through the real host: this is the state a reader of the demo site is in, and it is reached
+ /// by the ordinary pipeline rather than by a component rendered out of context.
+ ///
+ [Test]
+ public async Task WithNoDatabaseThePageSaysAccountsNeedOne()
+ {
+ await using var host = await SiteHost.StartAsync();
+
+ var body = Render.Words(await host.Client.GetStringAsync("/account"));
+
+ await Assert.That(body).Contains("Accounts need a database");
+ await Assert.That(body).DoesNotContain("Sign in");
+ await Assert.That(body).DoesNotContain("give up this claim");
+ }
+
+ /// A signed-out visitor is offered the way in and nothing else.
+ [Test]
+ public async Task ASignedOutVisitorIsOfferedSignInAndNoWriteSurface()
+ {
+ var markup = await World.New().Anonymous().RenderAsync();
+
+ await Assert.That(markup).Contains("/account/sign-in");
+ await Assert.That(markup).DoesNotContain("
+ [Test]
+ public async Task AnAccountWithNoClaimsIsPointedAtTheListing()
+ {
+ var markup = await World.New().SignedIn().RenderAsync();
+ var words = Render.Words(markup);
+
+ await Assert.That(words).Contains("You have not claimed anything yet");
+ await Assert.That(words).Contains("/games");
+ await Assert.That(words).DoesNotContain("Waiting on a token");
+ await Assert.That(markup).DoesNotContain("resign");
+ }
+
+ /// A pending claim is a link back to the page with the token on it.
+ [Test]
+ public async Task APendingClaimLinksToItsToken()
+ {
+ var markup = await World.New().SignedIn().Pending(Mush).RenderAsync();
+ var words = Render.Words(markup);
+
+ await Assert.That(words).Contains("Waiting on a token");
+ await Assert.That(markup).Contains("/g/m-u-s-h/claim");
+
+ // Pending is not owning: none of the owner surfaces appear for it.
+ await Assert.That(words).DoesNotContain("Claimed");
+ await Assert.That(markup).DoesNotContain("badge.svg");
+ }
+
+ ///
+ /// A verified claim brings every surface a claim grants, in one block.
+ ///
+ ///
+ /// The whole point of the merge chain, asserted in one place: §8.5's enrichment fields, its MSSP
+ /// scorecard, its owner-published badge, the audit log and §8.4's explicit resignation. Each
+ /// arrived on a different branch and none of them was ever rendered beside the others.
+ ///
+ [Test]
+ public async Task AVerifiedClaimShowsEverythingAClaimGrants()
+ {
+ var markup = await World.New().SignedIn().Verified(Ashen).RenderAsync();
+ var words = Render.Words(markup);
+
+ await Assert.That(words).Contains("Claimed");
+ await Assert.That(markup).Contains("/g/ashen-court");
+
+ // §8.5's enrichment, through OwnerPanel.
+ await Assert.That(markup).Contains($"{OwnerWrites.FieldPrefix}FANDOM");
+ await Assert.That(markup).Contains($"/account/games/{Ashen}/enrichment");
+
+ // §11's suppression.
+ await Assert.That(markup).Contains($"/account/games/{Ashen}/connect-screen");
+
+ // §8.5's scorecard, its badge, its audit log, and §8.4's resignation.
+ await Assert.That(markup).Contains("/g/ashen-court/mssp");
+ await Assert.That(markup).Contains("/g/ashen-court/badge.svg");
+ await Assert.That(markup).Contains("/g/ashen-court/badge.json");
+ await Assert.That(words).Contains("history");
+ await Assert.That(markup).Contains("/resign");
+ }
+
+ ///
+ /// The badge snippet is the exact line to paste, and it names this game.
+ ///
+ ///
+ /// It is markup inside markup, so the thing to check is that it survived escaping as something a
+ /// person can copy rather than as rendered HTML.
+ ///
+ [Test]
+ public async Task TheBadgeSnippetIsCopyableRatherThanRendered()
+ {
+ var markup = await World.New().SignedIn().Verified(Ashen).RenderAsync();
+
+ // Entity-encoded, quotes included. Read off the rendered bytes rather than off an
+ // assertion message: a failure report that HTML-decodes what it shows makes a correctly
+ // escaped snippet look like markup that got away.
+ await Assert.That(markup).Contains("<img src="/g/ashen-court/badge.svg"");
+ await Assert.That(markup).DoesNotContain("
Several games each get their own block, and none is opened by default.
+ ///
+ /// An operator holds a handful of games, and the page collapses them for that reason — a page of
+ /// five open forms is a page nobody reads. One game is the exception and stays open.
+ ///
+ [Test]
+ public async Task SeveralGamesAreEachTheirOwnCollapsedBlock()
+ {
+ var one = await World.New().SignedIn().Verified(Ashen).RenderAsync();
+ var two = await World.New().SignedIn().Verified(Ashen).Verified(Mush).RenderAsync();
+
+ await Assert.That(one).Contains("§8.5 — a game may have several owners, and each of them should know.
+ [Test]
+ public async Task ACoOwnedGameNamesTheOtherOwners()
+ {
+ var markup = await World.New().SignedIn().Verified(Ashen).CoOwnedBy("thistle").RenderAsync();
+ var words = Render.Words(markup);
+
+ await Assert.That(words).Contains("Also owned by thistle");
+ await Assert.That(words).Contains("verified a token of their own");
+
+ // The resign copy is the one that differs when somebody else holds the game too.
+ await Assert.That(words).Contains("the game stays claimed if anybody else owns it");
+ }
+
+ /// A sole owner sees no co-owner line at all.
+ [Test]
+ public async Task ASoleOwnerIsNotToldAboutOwnersWhoDoNotExist()
+ {
+ var words = Render.Words(await World.New().SignedIn().Verified(Ashen).RenderAsync());
+
+ await Assert.That(words).DoesNotContain("Also owned by");
+ }
+
+ // ── the status banner ─────────────────────────────────────────────────────
+
+ ///
+ /// Each outcome reports itself, and an else-if chain reports exactly one.
+ ///
+ ///
+ /// This page has already told an operator the wrong thing once: hiding a connect screen came
+ /// back saying the page "now shows it as owner-declared", which is the enrichment sentence. The
+ /// chain grew a third arm afterwards, and a chain is the shape that silently reports the wrong
+ /// branch — so every arm is driven here, by the querystring the redirect actually carries.
+ ///
+ [Test]
+ [Arguments("?saved={game}&did=fields", "now shows it as owner-declared")]
+ [Arguments("?saved={game}&did=screen-hidden", "stopped republishing")]
+ [Arguments("?saved={game}&did=screen-shown", "connect screen is on its page again")]
+ [Arguments("?resigned=1", "Given up.")]
+ [Arguments("?refused=CODEBASE&because=NotEnrichable", "CODEBASE was not changed")]
+ public async Task EveryOutcomeReportsTheActionThatHappened(string query, string expected)
+ {
+ var words = Render.Words(await World.New()
+ .SignedIn()
+ .Verified(Ashen)
+ .RenderAsync(query.Replace("{game}", Ashen.ToString(), StringComparison.Ordinal)));
+
+ await Assert.That(words).Contains(expected);
+ }
+
+ ///
+ /// Two outcomes at once is one banner, and it is the one the chain says it is.
+ ///
+ ///
+ /// A redirect carries exactly one outcome, so this combination cannot arise from the endpoints —
+ /// which is precisely why it is worth pinning. If somebody later reorders the arms, a page that
+ /// silently reported the other action would look identical to one that worked.
+ ///
+ [Test]
+ public async Task ACraftedUrlCarryingTwoOutcomesStillReportsOnlyOne()
+ {
+ var words = Render.Words(await World.New()
+ .SignedIn()
+ .Verified(Ashen)
+ .RenderAsync($"?resigned=1&saved={Ashen}&did=fields&refused=CODEBASE"));
+
+ await Assert.That(words).Contains("Given up.");
+ await Assert.That(words).DoesNotContain("now shows it as owner-declared");
+ await Assert.That(words).DoesNotContain("was not changed");
+ }
+
+ /// An enrichment refusal names the field and says which rule refused it.
+ [Test]
+ public async Task ARefusalOverLengthSaysSoRatherThanBlamingTheField()
+ {
+ var words = Render.Words(await World.New()
+ .SignedIn()
+ .Verified(Ashen)
+ .RenderAsync("?refused=FANDOM&because=TooLong"));
+
+ await Assert.That(words).Contains("FANDOM was not changed");
+ await Assert.That(words).Contains($"{OwnerEnrichment.MaxValueLength} characters");
+ await Assert.That(words).DoesNotContain("That field is measured");
+ }
+
+ // ── the routes the page posts to ──────────────────────────────────────────
+
+ ///
+ /// Every form on this page posts to a route the site actually maps.
+ ///
+ ///
+ /// A form whose action nobody maps fails at the browser, not at build — it is a 404 an operator
+ /// meets after typing into a box, and no compiler, no renderer and no unit test would say a word
+ /// about it. Five features' worth of forms arrived here across a merge chain, each mapped in a
+ /// different file, and this is the only check that they all still line up.
+ ///
+ [Test]
+ public async Task EveryFormOnThePagePostsToARouteTheSiteMaps()
+ {
+ var markup = await World.New().SignedIn().Verified(Ashen).RenderAsync();
+
+ var actions = Regex
+ .Matches(markup, "