MergeStatus page: the member's merges and undo by fresh proof of every moved login

Test files: the bearer list is kept apart from the surface it is checked against; wording tests use the Razor encoder.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-14 10:17:13 +00:00
commit 5ddfca9b55
4 changed files with 302 additions and 6 deletions

View file

@ -81,6 +81,9 @@ public static class MergeWording
? $"Approved with a passkey; you have until {Date(graceEnds)} to undo."
: $"Approved with ordinary sign-ins; you have until {Date(graceEnds)} to undo.";
/// <summary>Whole days from <paramref name="now"/> to <paramref name="end"/>, rounded up, at least one while the window is open.</summary>
public static int DaysUntil(DateTimeOffset end, DateTimeOffset now) => Math.Max(1, (int)Math.Ceiling((end - now).TotalDays));
public static string DaysLeft(int days) => days == 1 ? "1 day left" : $"{days} days left";
public static string SurvivorNotice(int days) => $"A merge into your account can still be undone: {DaysLeft(days)}. Review it on Merge status.";

View file

@ -1,2 +1,100 @@
@page
@model Bifrost.Web.Pages.Membership.MergeStatusModel
@{
ViewData["Title"] = "Merge status";
}
<section class="panel">
<span class="eyebrow">Account merge</span>
<h1>Merge status</h1>
<p class="panel-intro">
A merge folds one account into another. The account it kept can undo it for seven days, with a fresh sign-in for
every login that moved.
</p>
@if (Model.ResultMessage is not null)
{
<div class="callout">@Model.ResultMessage</div>
}
@if (Model.UndoMessage is not null)
{
<div class="callout">@Model.UndoMessage</div>
}
@if (Model.Merges.Count == 0)
{
<p class="muted">There is no merge to show for this account.</p>
}
@foreach (var merge in Model.Merges)
{
<article class="status-banner">
<strong>@merge.State</strong>
@if (merge.Why is not null)
{
<p class="muted">@merge.Why</p>
}
@if (merge.Outcome is not null)
{
<p class="muted">@merge.Outcome</p>
}
<ul>
@if (merge.Confirmed is not null)
{
<li>Confirmed @merge.Confirmed</li>
}
@if (merge.Committed is not null)
{
<li>Merged @merge.Committed</li>
}
@if (merge.GraceEnds is not null)
{
<li>Undo window ends @merge.GraceEnds</li>
}
@if (merge.Final is not null)
{
<li>Final @merge.Final</li>
}
@if (merge.SurvivorProofs.Count > 0)
{
<li>Kept account proved with: @string.Join(", ", merge.SurvivorProofs)</li>
}
@if (merge.FoldedProofs.Count > 0)
{
<li>Folded account proved with: @string.Join(", ", merge.FoldedProofs)</li>
}
</ul>
@if (merge.DaysLeft is not null)
{
<p><span class="pill warning">@merge.DaysLeft</span></p>
}
@if (merge.TierNote is not null)
{
<p class="muted">@merge.TierNote</p>
}
@if (merge.CanUndo)
{
@if (merge.Ready)
{
<form method="post" asp-page-handler="Undo">
<input type="hidden" name="mergeId" value="@merge.Id" />
<button class="button" type="submit">Undo this merge</button>
</form>
}
else
{
<p class="muted">To undo, sign in again through each login that moved. The sign-ins are held in memory for a few minutes.</p>
@foreach (var provider in merge.NeedProof)
{
<form method="post" asp-page-handler="ProveForUndo">
<input type="hidden" name="via" value="@provider" />
<button class="button secondary" type="submit">Sign in with @Bifrost.Web.Pages.Membership.LinkedAccountsModel.ProviderName(provider)</button>
</form>
}
}
}
</article>
}
</section>

View file

@ -1,15 +1,206 @@
using System.Security.Claims;
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Pages.Membership;
/// <summary>Skeleton: the member's own merge page. Filled in by the cut's next commits.</summary>
/// <summary>
/// The member's own merge page: the merges that kept this account (up to five) and the one its frozen or folded standing names, in
/// <see cref="MergeWording"/>'s closed words, and the undo. It reads merge rows and writes none: undo is the saga's
/// <see cref="AccountMergeSaga.RequestUndoAsync"/> followed by its own <see cref="AccountMergeSaga.AdvanceAsync"/>, with fresh proofs
/// of every moved login held by <see cref="MergeProofs"/>. A person who signed in through a login that moved is the survivor here, so
/// there is no separate folded-side undo. Nothing the query, the form or the store holds is shown except as a closed code's words, and
/// no account name, id, email or login of either side is rendered.
/// </summary>
[Authorize]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public sealed class MergeStatusModel : PageModel
public sealed class MergeStatusModel(
ICurrentBifrostActorAccessor actors,
BifrostDbContext dbContext,
AccountMergeSaga saga,
MergeProofs proofs,
HeimdallAuthService heimdallAuthService,
IOptions<BifrostHostOptions> hostOptions,
TimeProvider timeProvider,
ILogger<MergeStatusModel> logger) : PageModel
{
public void OnGet()
private const int MaxListed = 5;
private const string UndoParameter = "undo";
private const string LoginsUnproved = "logins_unproved";
public sealed record MergeRow(
Guid Id,
string State,
string? Why,
string? Outcome,
string? Confirmed,
string? Committed,
string? GraceEnds,
string? Final,
string? DaysLeft,
string? TierNote,
IReadOnlyList<string> SurvivorProofs,
IReadOnlyList<string> FoldedProofs,
bool CanUndo,
bool Ready,
IReadOnlyList<string> NeedProof);
public IReadOnlyList<MergeRow> Merges { get; private set; } = [];
public string? ResultMessage { get; private set; }
public string? UndoMessage { get; private set; }
public static string ProviderName(string provider) =>
HeimdallAuthService.LinkableProviders.Contains(provider) ? LinkedAccountsModel.ProviderName(provider) : provider == "passkey" ? "Passkey" : "another provider";
public async Task OnGetAsync(CancellationToken cancellationToken)
{
ResultMessage = HeimdallLinkOutcomes.Wording(Request.Query[HeimdallLinkOutcomes.ResultParameter].ToString());
UndoMessage = MergeWording.Undo(Request.Query[UndoParameter].ToString()) is { Length: > 0 } words ? words : null;
var actor = await actors.GetAsync(cancellationToken);
var me = actor.UserAccount?.Id;
var rows = me is { } id
? await dbContext.AccountMerges.AsNoTracking().Where(x => x.SurvivorUserAccountId == id).OrderByDescending(x => x.CreatedUtc).Take(MaxListed).ToListAsync(cancellationToken)
: [];
if (actor.Merge is { } standing && rows.All(x => x.Id != standing.MergeId)
&& await dbContext.AccountMerges.AsNoTracking().SingleOrDefaultAsync(x => x.Id == standing.MergeId, cancellationToken) is { } named)
{
rows.Add(named);
}
Merges = [.. rows.Select(x => Row(x, me))];
}
/// <summary>Starts a fresh sign-in of this account through <paramref name="via"/>, held in memory until the undo takes it.</summary>
public async Task<IActionResult> OnPostProveForUndoAsync(string? via, CancellationToken cancellationToken)
{
if (User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is not { Length: > 0 } accountId)
{
return Result(HeimdallLinkOutcome.Error);
}
try
{
var started = await heimdallAuthService.StartMergeProveSelfAsync(
accountId, via, HeimdallLinkReturn, hostOptions.Value.PublicBaseUrl, Request.Cookies, cancellationToken);
Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options());
return Redirect(started.AuthorizationUrl.ToString());
}
catch (HeimdallAuthException error)
{
logger.LogWarning("Merge undo proof start refused: {Reason}", error.Message);
return Result(HeimdallLinkOutcome.Error);
}
}
/// <summary>Asks the saga to undo one merge with the proofs this member holds, then runs its step function once, as the sweeper would.</summary>
public async Task<IActionResult> OnPostUndoAsync(string? mergeId, CancellationToken cancellationToken)
{
var actor = await actors.GetAsync(cancellationToken);
if (actor.UserAccount is not { } me || !Guid.TryParse(mergeId, out var id)
|| await dbContext.AccountMerges.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id, cancellationToken) is not { } merge
|| merge.SurvivorUserAccountId != me.Id)
{
return Undone("not_survivor");
}
if (proofs.PeekUndo(me.Id, MovedIds(merge)) is not UndoPeekResult.Ready { Proofs.Count: > 0 } ready)
{
return Undone(LoginsUnproved);
}
var outcome = await saga.RequestUndoAsync(id, me.Id, ready.Proofs, cancellationToken);
if (outcome is UndoOutcome.Requested)
{
proofs.Clear(me.Id);
try
{
await saga.AdvanceAsync(id, cancellationToken);
}
catch (Exception error) when (error is not OperationCanceledException)
{
// The saga's stored state is the truth and the sweeper resumes it.
logger.LogWarning("Account merge {MergeId} inline advance failed: {Error}", id, error.GetType().Name);
}
}
return Undone(MergeWording.UndoCode(outcome));
}
private const string HeimdallLinkReturn = "/Membership/MergeStatus";
private MergeRow Row(AccountMerge merge, Guid? me)
{
var now = timeProvider.GetUtcNow();
var open = merge.State == AccountMergeState.Committed && merge.GraceEndsUtc is { } end && now < end;
var canUndo = open && me == merge.SurvivorUserAccountId;
var ready = false;
IReadOnlyList<string> need = [];
if (canUndo)
{
switch (proofs.PeekUndo(me!.Value, MovedIds(merge)))
{
case UndoPeekResult.Ready { Proofs.Count: > 0 }:
ready = true;
break;
case UndoPeekResult.LoginsUnproved { Providers.Count: > 0 } unproved:
need = unproved.Providers;
break;
default:
need = HeimdallAuthService.LinkableProviders;
break;
}
}
var inGrace = merge.State is AccountMergeState.Committed or AccountMergeState.UndoRequested && merge.GraceEndsUtc > now;
return new MergeRow(
merge.Id,
MergeWording.State(merge.State),
merge.State == AccountMergeState.Failed ? MergeWording.Failure(merge.FailureCode) : null,
merge.State is AccountMergeState.Committed or AccountMergeState.Failed or AccountMergeState.Confirmed or AccountMergeState.HeimdallMoved
or AccountMergeState.UndoRequested or AccountMergeState.HeimdallReversed
? MergeWording.Outcome(merge.LastOutcome)
: null,
merge.ConfirmedUtc is { } confirmed ? MergeWording.Date(confirmed) : null,
merge.CommittedUtc is { } committed ? MergeWording.Date(committed) : null,
merge.GraceEndsUtc is { } graceEnds ? MergeWording.Date(graceEnds) : null,
merge.FinalizedUtc is { } final ? MergeWording.Date(final) : null,
inGrace && merge.GraceEndsUtc is { } until ? MergeWording.DaysLeft(MergeWording.DaysUntil(until, now)) : null,
inGrace && merge.GraceEndsUtc is { } by ? MergeWording.TierNote(merge.ProofFromTier, by) : null,
merge.SurvivorProofProvider.Length > 0 ? [ProviderName(merge.SurvivorProofProvider)] : [],
[.. merge.FoldedProofProviders.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(ProviderName)],
canUndo,
ready,
need);
}
private static IReadOnlyCollection<string> MovedIds(AccountMerge merge)
{
try
{
return merge.MovedIdentityIds is { } json ? JsonSerializer.Deserialize<string[]>(json) ?? [] : [];
}
catch (JsonException)
{
return [];
}
}
private IActionResult Undone(string code) =>
RedirectToPage(null, new Dictionary<string, string?> { [UndoParameter] = code });
private IActionResult Result(HeimdallLinkOutcome outcome) =>
RedirectToPage(null, new Dictionary<string, string?> { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(outcome) });
}

View file

@ -38,6 +38,9 @@ public sealed class MergeUndoPageTests
/// <summary>Everything a response put on the wire: bodies, Location, Set-Cookie. The privacy test reads it at the end.</summary>
public List<string> Surface { get; } = [];
/// <summary>Every bearer the test redeemed; none may appear on the surface.</summary>
public List<string> Bearers { get; } = [];
public void Dispose() => Rig.Dispose();
public async Task<string> SignInThrough(string provider) => await Host.SignInSessionAsync(OwnerOf(Rig, Folded.Logins[provider]), provider);
@ -82,7 +85,7 @@ public sealed class MergeUndoPageTests
var bearer = Host.Token(Survivor.HeimdallId);
Rig.Moves.Issue(Survivor.HeimdallId, Folded.Logins[provider], bearer: bearer);
Surface.Add("bearer-under-test " + bearer);
Bearers.Add(bearer);
var done = await Host.PostCompleteAsync(
new Dictionary<string, string> { ["attempt"] = attempt.Nonce, ["code"] = Host.CodeFor(attempt, Survivor.HeimdallId, bearer, provider: provider) },
$"{Bifrost.Web.Features.Shared.BifrostCookies.Session}={session}; {attempt.CookieName}={attempt.Cookie}");
@ -241,11 +244,12 @@ public sealed class MergeUndoPageTests
secrets.AddRange(w.Survivor.Logins.Values);
secrets.AddRange(w.Folded.Logins.Values);
secrets.AddRange(w.Surface.Where(x => x.StartsWith("bearer-under-test ")).Select(x => x["bearer-under-test ".Length..]));
secrets.AddRange(w.Bearers);
Assert.Equal(2, w.Bearers.Count);
Assert.All(secrets, x => Assert.DoesNotContain(x, everything));
Assert.DoesNotContain("PUID-CANARY", everything);
Assert.DoesNotContain("username-canary", everything);
}
private static string Enc(string text) => WebUtility.HtmlEncode(text);
private static string Enc(string text) => System.Text.Encodings.Web.HtmlEncoder.Default.Encode(text);
}