From 63b9596ff1a6259cf306e413cf3e0671ead3431b Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 12:19:44 +0000 Subject: [PATCH] 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 --- .../Features/Membership/MergePreview.cs | 6 +++++- .../Features/Membership/MergeProofs.cs | 12 +++++++++++- .../Features/Membership/MergeWording.cs | 2 ++ .../Pages/Membership/MergeAccounts.cshtml.cs | 11 ++++++++++- src/Bifrost.Web/ViewComponents/MergeNotice.cs | 8 +++++--- tests/Bifrost.Web.Tests/MergeProofsTests.cs | 17 +++++++++++++++-- 6 files changed, 48 insertions(+), 8 deletions(-) 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); + } + } }