diff --git a/src/Bifrost.Web/Features/Membership/MergePreview.cs b/src/Bifrost.Web/Features/Membership/MergePreview.cs
index 93177fd..02811ee 100644
--- a/src/Bifrost.Web/Features/Membership/MergePreview.cs
+++ b/src/Bifrost.Web/Features/Membership/MergePreview.cs
@@ -65,6 +65,10 @@ public static class MergePreview
_ => "These accounts cannot be merged now."
};
+ /// The block for two accounts that both hold a login of one provider, which Heimdall will not combine. Providers only: no account is named.
+ public static string ProviderConflict(IEnumerable 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.";
+
/// What every merge says, whatever the plan: what it cannot carry over, what it asks again, and how long it can be undone.
public static IReadOnlyList 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";
diff --git a/src/Bifrost.Web/Features/Membership/MergeProofs.cs b/src/Bifrost.Web/Features/Membership/MergeProofs.cs
index a022b63..2faefbf 100644
--- a/src/Bifrost.Web/Features/Membership/MergeProofs.cs
+++ b/src/Bifrost.Web/Features/Membership/MergeProofs.cs
@@ -37,6 +37,9 @@ public abstract record PeekResult
/// Logins whose provider has no live proof, by provider name only.
public sealed record LoginsUnproved(IReadOnlyList Providers) : PeekResult;
+
+ /// Both accounts hold a login of these providers, a pair Heimdall always refuses; by provider name only.
+ public sealed record ProviderConflict(IReadOnlyList 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. 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 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 s once the saga
/// has taken the proofs, so a refusal that wrote nothing does not cost a re-proof.
///
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);
}
}
diff --git a/src/Bifrost.Web/Features/Membership/MergeWording.cs b/src/Bifrost.Web/Features/Membership/MergeWording.cs
index ce9a365..f51e060 100644
--- a/src/Bifrost.Web/Features/Membership/MergeWording.cs
+++ b/src/Bifrost.Web/Features/Membership/MergeWording.cs
@@ -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) =>
diff --git a/src/Bifrost.Web/Pages/Membership/MergeAccounts.cshtml.cs b/src/Bifrost.Web/Pages/Membership/MergeAccounts.cshtml.cs
index 06087ab..1b2e0c1 100644
--- a/src/Bifrost.Web/Pages/Membership/MergeAccounts.cshtml.cs
+++ b/src/Bifrost.Web/Pages/Membership/MergeAccounts.cshtml.cs
@@ -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");
}
diff --git a/src/Bifrost.Web/ViewComponents/MergeNotice.cs b/src/Bifrost.Web/ViewComponents/MergeNotice.cs
index c820a24..5a3c779 100644
--- a/src/Bifrost.Web/ViewComponents/MergeNotice.cs
+++ b/src/Bifrost.Web/ViewComponents/MergeNotice.cs
@@ -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));
}
}
diff --git a/tests/Bifrost.Web.Tests/MergeProofsTests.cs b/tests/Bifrost.Web.Tests/MergeProofsTests.cs
index f02d685..8982384 100644
--- a/tests/Bifrost.Web.Tests/MergeProofsTests.cs
+++ b/tests/Bifrost.Web.Tests/MergeProofsTests.cs
@@ -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(_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(_proofs.Peek(_actor, survivor));
+ Assert.Equal(["discord", "twitch"], conflict.Providers);
+ }
+ }
}