Merge page blocks a shared provider before consent; requested-undo notice, no passkey promise, every access prunes bearers

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-14 12:19:44 +00:00
commit 63b9596ff1
6 changed files with 48 additions and 8 deletions

View file

@ -65,6 +65,10 @@ public static class MergePreview
_ => "These accounts cannot be merged now."
};
/// <summary>The block for two accounts that both hold a login of one provider, which Heimdall will not combine. Providers only: no account is named.</summary>
public static string ProviderConflict(IEnumerable<string> providerNames) =>
$"Both accounts hold a {string.Join(" and ", providerNames)} login, and Heimdall will not combine two logins of one provider. Unlink one of them first.";
/// <summary>What every merge says, whatever the plan: what it cannot carry over, what it asks again, and how long it can be undone.</summary>
public static IReadOnlyList<string> Statements { get; } =
[
@ -74,7 +78,7 @@ public static class MergePreview
"Roles and admin rights of the other account end; a steward must grant them again.",
"You will have one membership and one vote per motion: where both accounts voted on an open motion, the kept account's vote counts and the other is set aside. Nothing is added together.",
"You can undo this for 7 days, from Merge status. After that it is final.",
"If you proved an account with ordinary sign-ins rather than a passkey, the merge is approved with ordinary sign-ins, which is the weaker proof, and a notice stays on your account until the 7 days end."
"The merge is approved with ordinary sign-ins, which is the weaker proof, and a notice stays on your account until the 7 days end."
];
private static string Count(int n, string noun) => n == 1 ? $"1 {noun}" : $"{n} {noun}s";

View file

@ -37,6 +37,9 @@ public abstract record PeekResult
/// <summary>Logins whose provider has no live proof, by provider name only.</summary>
public sealed record LoginsUnproved(IReadOnlyList<string> Providers) : PeekResult;
/// <summary>Both accounts hold a login of these providers, a pair Heimdall always refuses; by provider name only.</summary>
public sealed record ProviderConflict(IReadOnlyList<string> Providers) : PeekResult;
}
public abstract record UndoPeekResult
@ -58,7 +61,7 @@ public abstract record UndoPeekResult
/// the member proves again. It decides nothing about the merge: the saga owns consent, the move and the lifecycle, and Heimdall judges
/// each proof's tier and freshness. <see cref="ProofLifetime"/> is only a margin under Heimdall's 300 s weak window, so a stale proof
/// is not sent to fail a merge. Provider is the only proof bookkeeping Bifrost has (the redeem names the provider, not the identity),
/// so a login list holding two logins of one provider is refused. Peek never consumes: the caller <see cref="Clear"/>s once the saga
/// so a login list holding two logins of one provider is refused, and two sides holding a login of one provider are a conflict Peek names before consent. Every access drops expired bearers. Peek never consumes: the caller <see cref="Clear"/>s once the saga
/// has taken the proofs, so a refusal that wrote nothing does not cost a re-proof.
/// </summary>
public sealed class MergeProofs(TimeProvider timeProvider)
@ -136,6 +139,12 @@ public sealed class MergeProofs(TimeProvider timeProvider)
return new PeekResult.Expired();
}
var shared = survivor.Logins.Select(x => x.Provider).Intersect(folded.Logins.Select(x => x.Provider), StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();
if (shared.Length > 0)
{
return new PeekResult.ProviderConflict(shared);
}
var unproved = UnprovedProviders(folded, folded.Logins.Select(x => x.Provider));
return unproved.Count > 0
? new PeekResult.LoginsUnproved(unproved)
@ -172,6 +181,7 @@ public sealed class MergeProofs(TimeProvider timeProvider)
{
lock (_gate)
{
Prune(timeProvider.GetUtcNow());
_held.Remove(actorId);
}
}

View file

@ -101,6 +101,8 @@ public static class MergeWording
public static string SurvivorNotice(int days) => $"A merge into your account can still be undone: {DaysLeft(days)}. Review it on Merge status.";
public static string UndoRequestedNotice() => "A merge into your account is being undone: Heimdall is giving the logins back. Review it on Merge status.";
public static string FrozenNotice() => "A merge is in progress; you cannot act until it settles.";
public static string FoldedNotice(DateTimeOffset mergedOn) =>

View file

@ -144,6 +144,9 @@ public sealed class MergeAccountsModel(
BlockWords = [.. plan.Blocks.Select(MergePreview.Block)];
switch (proofs.Peek(me, survivorSide))
{
case PeekResult.ProviderConflict conflict:
BlockWords = [.. BlockWords, MergePreview.ProviderConflict(conflict.Providers.Select(ProviderName))];
break;
case PeekResult.Ready when plan.Blocks.Count == 0:
Stage = MergeStage.Confirm;
break;
@ -184,7 +187,13 @@ public sealed class MergeAccountsModel(
var me = (await actors.GetAsync(cancellationToken)).UserAccount!.Id;
var held = proofs.Snapshot(me);
if (held.Self is not { } self || held.Other is not { } other || proofs.Peek(me, survivorSide) is not PeekResult.Ready ready)
var peeked = proofs.Peek(me, survivorSide);
if (peeked is PeekResult.ProviderConflict)
{
return Refused(keep, "blocked");
}
if (held.Self is not { } self || held.Other is not { } other || peeked is not PeekResult.Ready ready)
{
return Refused(keep, "proofs_needed");
}

View file

@ -43,13 +43,15 @@ public sealed class MergeNotice(ICurrentBifrostActorAccessor actors, BifrostDbCo
return null;
}
var graceEnds = await dbContext.AccountMerges.AsNoTracking()
var open = await dbContext.AccountMerges.AsNoTracking()
.Where(x => x.SurvivorUserAccountId == account.Id
&& (x.State == AccountMergeState.Committed || x.State == AccountMergeState.UndoRequested)
&& x.GraceEndsUtc > now)
.OrderBy(x => x.GraceEndsUtc)
.Select(x => x.GraceEndsUtc)
.Select(x => new { x.State, x.GraceEndsUtc })
.FirstOrDefaultAsync(cancellationToken);
return graceEnds is { } end ? MergeWording.SurvivorNotice(MergeWording.DaysUntil(end, now)) : null;
return open is null ? null
: open.State == AccountMergeState.UndoRequested ? MergeWording.UndoRequestedNotice()
: MergeWording.SurvivorNotice(MergeWording.DaysUntil(open.GraceEndsUtc!.Value, now));
}
}

View file

@ -96,13 +96,13 @@ public sealed class MergeProofsTests
{
var discord = Login("discord");
var patreon = Login("patreon");
PutSelf("discord", Login("discord"));
PutSelf("twitch", Login("twitch"));
PutOther("discord", discord, patreon);
_clock.Advance(TimeSpan.FromSeconds(200));
PutOther("patreon", discord, patreon);
_clock.Advance(TimeSpan.FromSeconds(100)); // the discord proof is 300 s old, the patreon one 100 s
var self = PutSelf("discord", Login("discord")); // keeps the self side alive
var self = PutSelf("twitch", Login("twitch")); // keeps the self side alive
var unproved = Assert.IsType<PeekResult.LoginsUnproved>(_proofs.Peek(_actor, MergeSide.Self));
Assert.Equal(PutOutcome.Stored, self);
@ -213,4 +213,17 @@ public sealed class MergeProofsTests
Assert.Equal(0, HeldProofs());
}
[Fact]
public void Two_sides_holding_a_login_of_one_provider_are_a_conflict_named_by_provider_only_in_both_directions()
{
PutSelf("discord", Login("discord"), Login("twitch"));
PutOther("patreon", Login("patreon"), Login("discord"), Login("twitch"));
foreach (var survivor in new[] { MergeSide.Self, MergeSide.Other })
{
var conflict = Assert.IsType<PeekResult.ProviderConflict>(_proofs.Peek(_actor, survivor));
Assert.Equal(["discord", "twitch"], conflict.Providers);
}
}
}