Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions migrations/0013_claim_intent.sql
Original file line number Diff line number Diff line change
@@ -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.';
38 changes: 38 additions & 0 deletions src/MUI.Catalog/Claims.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,36 @@ public enum ClaimChannel
ConnectScreen,
}

/// <summary>
/// Whether a claimant is joining a game's owners or taking it over (spec §8.4, §8.5).
/// </summary>
/// <remarks>
/// <para>
/// <b>Declared, never inferred.</b> §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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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 <em>other</em> claims, not how hard this one was to make.
/// </para>
/// </remarks>
public enum ClaimIntent
{
/// <summary>Become one of the game's owners, alongside whoever else has proved it.</summary>
Join,

/// <summary>Take the game over. On verification, every other verified claim is revoked.</summary>
Assume,
}

/// <summary>
/// A claim on a game by an account: pending while <see cref="ClaimedAt"/> is null, verified after.
/// </summary>
Expand Down Expand Up @@ -44,6 +74,9 @@ public sealed record GameClaim

public required string Token { get; init; }

/// <summary>Whether verifying this joins the game's owners or displaces them (spec §8.4).</summary>
public ClaimIntent Intent { get; init; } = ClaimIntent.Join;

public required DateTimeOffset IssuedAt { get; init; }

/// <summary>
Expand Down Expand Up @@ -206,6 +239,11 @@ public enum ClaimVerdict

/// <summary>A token we issued, matching a claim that has expired or been revoked.</summary>
Stale,

/// <summary>
/// A counter-claim completed: this account now owns the game and the others were revoked (§8.4).
/// </summary>
Assumed,
}

/// <summary>
Expand Down
75 changes: 74 additions & 1 deletion src/MUI.Catalog/Persistence/ClaimService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public sealed class ClaimService(
public async Task<GameClaim> IssueAsync(
Guid gameId,
Guid userId,
ClaimIntent intent = ClaimIntent.Join,
CancellationToken cancellationToken = default)
{
var now = time.GetUtcNow();
Expand All @@ -63,6 +64,7 @@ public async Task<GameClaim> IssueAsync(
GameId = gameId,
UserId = userId,
Token = ClaimToken.Mint(),
Intent = intent,
IssuedAt = now,
ExpiresAt = now + ClaimToken.PendingLifetime,
};
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
/// </remarks>
/// <summary>
/// An owner giving up a game they hold.
/// </summary>
/// <remarks>
/// Scoped to the account, because <see cref="RevokeAsync"/> 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.
/// </remarks>
public async Task<bool> 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;
}

/// <summary>Every account that has proved control of a game, newest first (spec §8.5).</summary>
public async Task<IReadOnlyList<GameClaim>> OwnersAsync(
Guid gameId,
CancellationToken cancellationToken = default) =>
[.. (await claims.ForGameAsync(gameId, cancellationToken)).Where(claim => claim.IsVerified)];

/// <summary>One claim's audit log, oldest first (spec §8.5).</summary>
public Task<IReadOnlyList<ClaimEvent>> HistoryAsync(
Guid claimId,
CancellationToken cancellationToken = default) =>
claims.EventsAsync(claimId, cancellationToken);

public async Task RevokeAsync(Guid claimId, string reason, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
Expand Down
10 changes: 7 additions & 3 deletions src/MUI.Catalog/Persistence/NpgsqlClaimStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
""",
Expand Down Expand Up @@ -182,6 +182,7 @@ public async Task<IReadOnlyList<ClaimEvent>> EventsAsync(
claim.GameId,
claim.UserId,
claim.Token,
Intent = SqlEnums.ToDb(claim.Intent),
claim.IssuedAt,
claim.ExpiresAt,
claim.ClaimedAt,
Expand All @@ -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; }
Expand All @@ -229,6 +232,7 @@ private sealed class Row
GameId = GameId,
UserId = UserId,
Token = Token,
Intent = SqlEnums.ToClaimIntent(Intent),
IssuedAt = IssuedAt,
ExpiresAt = ExpiresAt,
ClaimedAt = ClaimedAt,
Expand Down
15 changes: 15 additions & 0 deletions src/MUI.Catalog/Persistence/SqlEnums.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ public static class SqlEnums
_ => throw Unread(value, nameof(FieldSource)),
};

/// <summary>Whether a claim joins a game's owners or displaces them (spec §8.4).</summary>
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)),
};

/// <summary>
/// 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.
Expand Down
78 changes: 78 additions & 0 deletions src/MUI.Web/Accounts/OwnershipWrites.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Identity;

using MUI.Catalog;
using MUI.Catalog.Persistence;

namespace MUI.Web.Accounts;

/// <summary>
/// The two ownership decisions a person makes with a form (spec §8.4, §8.5).
/// </summary>
/// <remarks>
/// Both are plain posts and redirects, like the rest of the account surface, and both delegate every
/// decision to <see cref="ClaimService"/> — 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.
/// </remarks>
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<MuiUser> 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<MuiUser> 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();
}
}
3 changes: 3 additions & 0 deletions src/MUI.Web/Accounts/Passkeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/// <summary>What the page posts back after the authenticator has answered.</summary>
Expand Down
Loading