From fbf6e43955dbd1efaa1fe0b7c162a1ab6c68e53f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 09:00:23 +0200 Subject: [PATCH 01/40] Concept vote rate limits: cast and mint budgets per account or network prefix One FixedWindowBudget (injectable clock) owned by ConceptVoteRateLimits; the vote page 429s with Retry-After before any row or cookie. Prefix lengths and budgets are validated in every environment. Pins the empty Origin branch of CastRequestGuard. Unverified; not yet built. Co-Authored-By: Claude Opus 5.5 --- .../Configuration/ConceptVoteOptions.cs | 14 + .../ConceptVotes/ConceptVoteRateLimits.cs | 28 ++ .../Features/Shared/FixedWindowBudget.cs | 78 ++++ .../Shared/StartupConfigurationValidator.cs | 45 +- src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs | 44 +- src/Bifrost.Web/Program.cs | 1 + .../Bifrost.Web.Tests/ConceptVotePageTests.cs | 58 +-- .../ConceptVoteRateLimitTests.cs | 392 ++++++++++++++++++ .../FixedWindowBudgetTests.cs | 93 +++++ tests/Bifrost.Web.Tests/HostTrustTests.cs | 56 ++- .../Support/CapturingLoggerProvider.cs | 45 ++ .../Support/TestAuthHandler.cs | 9 +- .../Support/TestWebApplicationFactory.cs | 2 + 13 files changed, 795 insertions(+), 70 deletions(-) create mode 100644 src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs create mode 100644 src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs create mode 100644 tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs create mode 100644 tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs create mode 100644 tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs diff --git a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs index a5e0781..41a866e 100644 --- a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs +++ b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs @@ -20,6 +20,20 @@ public sealed class ConceptVoteOptions // default weighed in the note, not a standard. Tune on observed abuse. public int IPv6PrefixLength { get; init; } = 56; + // Vote budgets (Features/ConceptVotes/ConceptVoteRateLimits). The research note + // (docs/research/anonymous-vote-stuffing-prior-art.md section 3) gives no numbers, so these are first values to tune on + // observed abuse. Cast: one visitor reading through the Mill can vote on a few dozen concepts in a sitting, so 30 per + // 10 minutes per account or network prefix does not trouble a person and caps a script at 180 an hour. + public int CastPermits { get; init; } = 30; + + public TimeSpan CastWindow { get; init; } = TimeSpan.FromMinutes(10); + + // Mint: a new device cookie is a new voter identity, the cheapest stuffing lever, and a household has few devices, + // so 5 new devices per hour per network prefix. + public int MintPermits { get; init; } = 5; + + public TimeSpan MintWindow { get; init; } = TimeSpan.FromHours(1); + // The origins whose pages may read the public tally export from a browser (CORS). Each entry is exactly // https://host[:non-default-port], lower case, no path; empty allows none. StartupConfigurationValidator // refuses anything else outside Development and Testing, because "*" would allow every origin. diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs new file mode 100644 index 0000000..aac9802 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs @@ -0,0 +1,28 @@ +using System.Net; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Shared; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.ConceptVotes; + +// The one owner of the vote budgets. Cast is spent by every cast and retract; mint is spent additionally by a cast that +// is about to mint a device cookie. A denial returns how long to wait and writes nothing. The partition is the account +// when the voter is signed in, otherwise the network prefix (NetworkPrefix.Of) of the address the forwarded-headers +// middleware left on the connection. No header and no client input ever becomes a key. +public sealed class ConceptVoteRateLimits(IOptions options, TimeProvider clock) +{ + private readonly ConceptVoteOptions opts = options.Value; + private readonly FixedWindowBudget cast = new(options.Value.CastPermits, options.Value.CastWindow, clock); + private readonly FixedWindowBudget mint = new(options.Value.MintPermits, options.Value.MintWindow, clock); + + public TimeSpan? DenyCast(IPAddress? client, VoterIdentity? known) => + cast.TryAcquire(known?.UserAccountId is { } account ? "account:" + account.ToString("N") : NetKey(client), out var wait) + ? null + : wait; + + public TimeSpan? DenyMint(IPAddress? client) => + mint.TryAcquire(NetKey(client), out var wait) ? null : wait; + + private string NetKey(IPAddress? client) => + "net:" + (client is null ? "unknown" : NetworkPrefix.Of(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength)); +} diff --git a/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs new file mode 100644 index 0000000..abcaa0f --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs @@ -0,0 +1,78 @@ +namespace Bifrost.Web.Features.Shared; + +// The one rate-limit mechanism: a fixed-window permit budget per caller-chosen partition key, read against an injected +// clock. Keys are opaque and are never stored beyond their window: an entry whose window has ended is dropped by the +// next sweep, so the table is bounded by the distinct keys seen in one window. System.Threading.RateLimiting is not +// used because its limiters time themselves on Stopwatch and timers, which a test cannot move. +public sealed class FixedWindowBudget +{ + private readonly int permits; + private readonly TimeSpan window; + private readonly TimeProvider clock; + private readonly Dictionary entries = new(StringComparer.Ordinal); + private readonly object gate = new(); + private DateTimeOffset nextSweep = DateTimeOffset.MinValue; + + public FixedWindowBudget(int permits, TimeSpan window, TimeProvider clock) + { + ArgumentOutOfRangeException.ThrowIfLessThan(permits, 1); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(window, TimeSpan.Zero); + this.permits = permits; + this.window = window; + this.clock = clock; + } + + // How many keys are held now: bounded by the distinct keys seen in one window, not by every key ever seen. + public int TrackedKeys + { + get + { + lock (gate) + { + return entries.Count; + } + } + } + + // Takes one permit for the key. When none is left, returns false and how long until its window ends. + public bool TryAcquire(string key, out TimeSpan retryAfter) + { + var now = clock.GetUtcNow(); + lock (gate) + { + if (now >= nextSweep) + { + foreach (var expired in entries.Where(x => x.Value.Start + window <= now).Select(x => x.Key).ToList()) + { + entries.Remove(expired); + } + + nextSweep = now + window; + } + + if (!entries.TryGetValue(key, out var entry) || entry.Start + window <= now) + { + entries[key] = new Entry(now); + retryAfter = TimeSpan.Zero; + return true; + } + + if (entry.Count < permits) + { + entry.Count++; + retryAfter = TimeSpan.Zero; + return true; + } + + retryAfter = entry.Start + window - now; + return false; + } + } + + private sealed class Entry(DateTimeOffset start) + { + public DateTimeOffset Start { get; } = start; + + public int Count { get; set; } = 1; + } +} diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index c511413..676e628 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -16,12 +16,35 @@ public sealed class StartupConfigurationValidator( { public void Validate() { - if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing")) + var failures = new List(); + + // Checked in every environment: a bad value here would otherwise pass startup and throw on every cast. + var conceptVotes = conceptVoteOptions.Value; + if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits)) { - return; + failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}."); } - var failures = new List(); + if (!NetworkPrefix.IsValidLength(conceptVotes.IPv6PrefixLength, NetworkPrefix.IPv6Bits)) + { + failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}."); + } + + if (conceptVotes.CastPermits < 1 || conceptVotes.CastWindow <= TimeSpan.Zero) + { + failures.Add("ConceptVotes:CastPermits must be at least 1 and ConceptVotes:CastWindow must be positive."); + } + + if (conceptVotes.MintPermits < 1 || conceptVotes.MintWindow <= TimeSpan.Zero) + { + failures.Add("ConceptVotes:MintPermits must be at least 1 and ConceptVotes:MintWindow must be positive."); + } + + if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing")) + { + Refuse(failures); + return; + } if (!gitHubOAuthOptions.Value.IsConfigured) { @@ -73,7 +96,6 @@ public sealed class StartupConfigurationValidator( failures.Add("DataProtection:KeysPath must be configured for production."); } - var conceptVotes = conceptVoteOptions.Value; if (string.IsNullOrWhiteSpace(conceptVotes.NetworkTagSecret) || conceptVotes.NetworkTagSecret.Length < ConceptVoteOptions.MinNetworkTagSecretLength) { @@ -81,21 +103,16 @@ public sealed class StartupConfigurationValidator( $"ConceptVotes:NetworkTagSecret must be configured with at least {ConceptVoteOptions.MinNetworkTagSecretLength} characters for production."); } - if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits)) - { - failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}."); - } - - if (!NetworkPrefix.IsValidLength(conceptVotes.IPv6PrefixLength, NetworkPrefix.IPv6Bits)) - { - failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}."); - } - if (!conceptVotes.AllowedOrigins.All(IsExactHttpsOrigin)) { failures.Add("ConceptVotes:AllowedOrigins entries must each be exactly https://host with no path, wildcard or trailing slash."); } + Refuse(failures); + } + + private static void Refuse(List failures) + { if (failures.Count > 0) { throw new InvalidOperationException( diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index 525811d..5e46e1d 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -17,6 +17,7 @@ public sealed class VoteModel( ICurrentBifrostActorAccessor actorAccessor, ConceptAppraisalService appraisals, VoterKeyResolver deviceCookie, + ConceptVoteRateLimits limits, IOptions options, IOptions host) : PageModel { @@ -61,7 +62,7 @@ public sealed class VoteModel( public async Task OnGetAsync(string collection, string key, CancellationToken cancellationToken) { var actor = await actorAccessor.GetAsync(cancellationToken); - var voter = ResolveVoter(actor, mint: false); + var voter = ResolveVoter(actor); SignedIn = voter?.UserAccountId is not null; return await LoadAsync(collection, key, voter, cancellationToken) ? Page() : NotFound(); } @@ -75,12 +76,17 @@ public sealed class VoteModel( public async Task OnPostRetractAsync(string collection, string key, CancellationToken cancellationToken) { var actor = await actorAccessor.GetAsync(cancellationToken); + var voter = ResolveVoter(actor); + if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter)) is { } throttled) + { + return throttled; + } + if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) { return NotFound(); } - var voter = ResolveVoter(actor, mint: false); await appraisals.RetractAsync(collection, key, voter, cancellationToken); return RedirectToPage(new { collection, key }); } @@ -92,12 +98,28 @@ public sealed class VoteModel( CancellationToken cancellationToken) { var actor = await actorAccessor.GetAsync(cancellationToken); + var client = HttpContext.Connection.RemoteIpAddress; + var voter = ResolveVoter(actor); + if (Throttled(limits.DenyCast(client, voter)) is { } throttled) + { + return throttled; + } + if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) { return NotFound(); } - var voter = ResolveVoter(actor, mint: true); + if (voter is null) + { + if (Throttled(limits.DenyMint(client)) is { } mintThrottled) + { + return mintThrottled; + } + + voter = VoterIdentity.For(actor, deviceCookie.Mint(HttpContext)); + } + var result = await appraisals.CastAsync( collection, key, voter, stance, HttpContext.Connection.RemoteIpAddress, cancellationToken); return result is CastResult.UnknownSubject or CastResult.RetiredSubject @@ -106,16 +128,20 @@ public sealed class VoteModel( } // Accounts key to the account and never touch the cookie. An anonymous visitor keys to a device cookie that - // unprotects to a well-formed id; a cast without one mints it, and no other path does. - private VoterIdentity? ResolveVoter(CurrentBifrostActor actor, bool mint) + // unprotects to a well-formed id; a cast without one mints it (CastAsync, after the mint budget), and no other path does. + private VoterIdentity? ResolveVoter(CurrentBifrostActor actor) => + VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext)); + + // A denied budget is a 429 that says when to retry; it writes no row and sets no cookie. + private IActionResult? Throttled(TimeSpan? retryAfter) { - var voter = VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext)); - if (voter is null && mint) + if (retryAfter is not { } wait) { - voter = VoterIdentity.For(actor, deviceCookie.Mint(HttpContext)); + return null; } - return voter; + Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); + return new StatusCodeResult(StatusCodes.Status429TooManyRequests); } private async Task LoadAsync(string collection, string key, VoterIdentity? voter, CancellationToken cancellationToken) diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 94276e1..99ecd84 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -119,6 +119,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index 1870b6f..b31c1c0 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -183,6 +183,22 @@ public sealed partial class ConceptVotePageTests : IClassFixture _lines = []; - - public IReadOnlyList Lines - { - get - { - lock (_lines) - { - return _lines.ToArray(); - } - } - } - - public ILogger CreateLogger(string categoryName) => new Capture(this); - - public void Dispose() - { - } - - private sealed class Capture(CapturingLoggerProvider owner) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - lock (owner._lines) - { - owner._lines.Add(formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception)); - } - } - } - } } diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs new file mode 100644 index 0000000..7165973 --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -0,0 +1,392 @@ +using System.Net; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Bifrost.Web.Tests; + +// The vote budgets through the full pipeline: a loopback peer (the proxy) forwards the client address in +// X-Forwarded-For, the clock is the test's own, and Cast is 3 per 10 minutes, Mint 2 per hour. +public sealed class ConceptVoteRateLimitTests : IClassFixture +{ + private const string Site = "https://localhost"; + private const string CookieName = "bifrost_voter"; + + private readonly LimitedFactory _factory; + + public ConceptVoteRateLimitTests(LimitedFactory factory) => _factory = factory; + + [Fact] + public async Task Burst_from_one_prefix_gets_429_while_another_prefix_is_unaffected() + { + var collection = await NewSubject(); + var device = new Client(_factory, "203.0.113.10"); + foreach (var handler in new[] { "Up", "Down", "Up" }) + { + using var ok = await device.Post(collection, handler); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await device.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + + var other = new Client(_factory, "203.0.113.11"); + using var unaffected = await other.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, unaffected.StatusCode); + } + + [Fact] + public async Task Retract_spends_the_cast_budget() + { + var collection = await NewSubject(); + var device = new Client(_factory, "203.0.113.12"); + await device.Post(collection, "Up"); + await device.Post(collection, "Retract"); + await device.Post(collection, "Retract"); + + using var denied = await device.Post(collection, "Retract"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public async Task Addresses_in_one_ipv6_prefix_share_a_bucket_and_another_prefix_does_not() + { + var collection = await NewSubject(); + var first = new Client(_factory, "2001:db8:1:100::1"); + var sameBlock = new Client(_factory, "2001:db8:1:1ff::2"); + await first.Post(collection, "Up"); + await first.Post(collection, "Down"); + using var third = await sameBlock.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, third.StatusCode); + + using var denied = await sameBlock.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + + var elsewhere = new Client(_factory, "2001:db8:1:200::1"); + using var unaffected = await elsewhere.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, unaffected.StatusCode); + } + + [Fact] + public async Task The_window_ends_exactly_at_its_length_and_retry_after_counts_down_to_it() + { + var collection = await NewSubject(); + var device = new Client(_factory, "203.0.113.13"); + for (var i = 0; i < 3; i++) + { + await device.Post(collection, "Up"); + } + + using (var first = await device.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.TooManyRequests, first.StatusCode); + Assert.Equal("600", RetryAfter(first)); + } + + _factory.Clock.Advance(TimeSpan.FromSeconds(100)); + using (var later = await device.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.TooManyRequests, later.StatusCode); + Assert.Equal("500", RetryAfter(later)); + } + + _factory.Clock.Advance(TimeSpan.FromSeconds(499)); + using (var last = await device.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.TooManyRequests, last.StatusCode); + Assert.Equal("1", RetryAfter(last)); + } + + _factory.Clock.Advance(TimeSpan.FromSeconds(1)); + using var reopened = await device.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, reopened.StatusCode); + } + + [Fact] + public async Task Forged_forwarded_header_from_non_loopback_peer_does_not_move_the_bucket() + { + var collection = await NewSubject(); + var device = new Client(_factory, "198.51.100.9") { Peer = "198.51.100.9" }; + for (var i = 0; i < 3; i++) + { + using var ok = await device.Post(collection, "Up", forwardedFor: $"192.0.2.{i + 1}"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await device.Post(collection, "Up", forwardedFor: "192.0.2.200"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public async Task Signed_in_accounts_are_limited_per_account() + { + var collection = await NewSubject(); + var alice = new Client(_factory, "203.0.113.20") { Account = ("7001", "alice-limits") }; + var aliceElsewhere = new Client(_factory, "203.0.113.21") { Account = ("7001", "alice-limits") }; + var bob = new Client(_factory, "203.0.113.20") { Account = ("7002", "bob-limits") }; + var visitor = new Client(_factory, "203.0.113.20"); + + await alice.Post(collection, "Up"); + await alice.Post(collection, "Down"); + await aliceElsewhere.Post(collection, "Up"); + using var denied = await aliceElsewhere.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + + var aliceAgain = new Client(_factory, "203.0.113.22") { Account = ("7001", "alice-limits") }; + using var sameAccountNewPrefix = await aliceAgain.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, sameAccountNewPrefix.StatusCode); + + using var bobOk = await bob.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, bobOk.StatusCode); + using var visitorOk = await visitor.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, visitorOk.StatusCode); + } + + [Fact] + public async Task Mint_budget_is_spent_only_by_minting_casts() + { + var collection = await NewSubject(); + const string prefix = "203.0.113.30"; + var first = new Client(_factory, prefix); + using (var mint = await first.Post(collection, "Up")) + { + Assert.NotNull(SetCookie(mint)); + } + + // Recasts by the existing device spend cast permits only; they would exhaust a budget of 2 if they spent mint. + using (var recast = await first.Post(collection, "Down")) + { + Assert.Equal(HttpStatusCode.Redirect, recast.StatusCode); + Assert.Null(SetCookie(recast)); + } + + using (var recastAgain = await first.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.Redirect, recastAgain.StatusCode); + } + + _factory.Clock.Advance(TimeSpan.FromMinutes(10)); + var second = new Client(_factory, prefix); + using (var secondMint = await second.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.Redirect, secondMint.StatusCode); + Assert.NotNull(SetCookie(secondMint)); + } + + var rowsBefore = (await Rows(collection)).Count; + var third = new Client(_factory, prefix); + using var denied = await third.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + Assert.Null(SetCookie(denied)); + Assert.Equal(rowsBefore, (await Rows(collection)).Count); + + // An hour after the first mint the budget is whole again. + _factory.Clock.Advance(TimeSpan.FromMinutes(50)); + using var reopened = await third.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, reopened.StatusCode); + } + + [Fact] + public async Task Anonymous_cast_under_the_limit_succeeds() + { + var collection = await NewSubject(); + var visitor = new Client(_factory, "203.0.113.40"); + + using var response = await visitor.Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + var row = Assert.Single(await Rows(collection)); + Assert.Equal(VoterClass.Anonymous, row.VoterClass); + Assert.Equal(ConceptStance.Up, row.Stance); + } + + [Fact] + public async Task Rejection_writes_nothing_says_when_to_retry_and_echoes_no_address() + { + var logs = new CapturingLoggerProvider(); + var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs)))); + var collection = await NewSubject(); + var visitor = new Client(factory, "203.0.113.77"); + for (var i = 0; i < 3; i++) + { + await visitor.Post(collection, i == 1 ? "Down" : "Up"); + } + + var before = Assert.Single(await Rows(collection)); + using var denied = await visitor.Post(collection, "Down"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + Assert.NotNull(RetryAfter(denied)); + Assert.Null(SetCookie(denied)); + var after = Assert.Single(await Rows(collection)); + Assert.Equal(before.Stance, after.Stance); + var seen = await denied.Content.ReadAsStringAsync() + + string.Join("\n", denied.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value))); + Assert.DoesNotContain("203.0.113", seen); + Assert.DoesNotContain(logs.Lines, line => line.Contains("203.0.113", StringComparison.Ordinal)); + } + + [Fact] + public async Task Reads_are_not_under_the_vote_budgets() + { + var collection = await NewSubject(); + var visitor = new Client(_factory, "203.0.113.50"); + for (var i = 0; i < 4; i++) + { + await visitor.Post(collection, "Up"); + } + + using var page = await visitor.Get($"/mill/{collection}/alpha"); + using var export = await visitor.Get($"/public/concepts/{collection}/tallies"); + + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + Assert.Equal(HttpStatusCode.OK, export.StatusCode); + } + + private static string? RetryAfter(HttpResponseMessage response) => + response.Headers.TryGetValues("Retry-After", out var values) ? values.Single() : null; + + private static string? SetCookie(HttpResponseMessage response) => + response.Headers.TryGetValues("Set-Cookie", out var values) + ? values.FirstOrDefault(x => x.StartsWith(CookieName + "=", StringComparison.Ordinal)) + : null; + + private async Task NewSubject() + { + var collection = "c" + Guid.NewGuid().ToString("N"); + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject + { + Collection = collection, + Key = "alpha", + Title = "alpha", + RegisteredAtUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + return collection; + } + + private async Task> Rows(string collection) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.ConceptAppraisals.AsNoTracking() + .Where(x => x.Subject!.Collection == collection) + .ToListAsync(); + } + + // One browser: a client address that reaches the app through the proxy, a cookie jar, optionally an account. + private sealed class Client(WebApplicationFactory factory, string address) + { + private readonly HttpClient _http = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false, + BaseAddress = new Uri(Site) + }); + + private string? _cookie; + + public string Peer { get; init; } = "127.0.0.1"; + + public (string Id, string Login)? Account { get; init; } + + public Task Post(string collection, string handler, string? forwardedFor = null) => + Send( + new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/alpha?handler={handler}") + { + Content = new FormUrlEncodedContent([]) + }, + forwardedFor); + + public Task Get(string path) => Send(new HttpRequestMessage(HttpMethod.Get, path), null); + + private async Task Send(HttpRequestMessage request, string? forwardedFor) + { + request.Headers.TryAddWithoutValidation("Origin", Site); + request.Headers.TryAddWithoutValidation("X-Forwarded-For", forwardedFor ?? address); + request.Headers.TryAddWithoutValidation("X-Test-Peer", Peer); + if (Account is { } account) + { + request.Headers.TryAddWithoutValidation("X-Test-GitHub-Id", account.Id); + request.Headers.TryAddWithoutValidation("X-Test-GitHub-Login", account.Login); + } + else + { + request.Headers.TryAddWithoutValidation("X-Test-Anonymous", "true"); + } + + if (_cookie is not null) + { + request.Headers.TryAddWithoutValidation("Cookie", _cookie); + } + + var response = await _http.SendAsync(request); + if (SetCookie(response) is { } set) + { + _cookie = set.Split(';')[0]; + } + + return response; + } + } + + public sealed class LimitedFactory : TestWebApplicationFactory + { + public TestClock Clock { get; } = new(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:CastPermits"] = "3", + ["ConceptVotes:CastWindow"] = "00:10:00", + ["ConceptVotes:MintPermits"] = "2", + ["ConceptVotes:MintWindow"] = "01:00:00", + ["ConceptVotes:NetworkTagSecret"] = "rate-limit-test-tag-secret-0123456789" + })); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(Clock); + services.AddTransient(); + }); + } + } + + public sealed class TestClock : TimeProvider + { + private DateTimeOffset _now = DateTimeOffset.UtcNow; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; + } + + // The proxy's connection: TestServer has no socket, so the peer address is set ahead of the app pipeline. + private sealed class PeerFilter : IStartupFilter + { + public Action Configure(Action next) => app => + { + app.Use((context, nextMiddleware) => + { + context.Connection.RemoteIpAddress = + IPAddress.Parse(context.Request.Headers["X-Test-Peer"].FirstOrDefault() ?? "127.0.0.1"); + return nextMiddleware(context); + }); + next(app); + }; + } +} diff --git a/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs new file mode 100644 index 0000000..0343c7f --- /dev/null +++ b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs @@ -0,0 +1,93 @@ +using Bifrost.Web.Features.Shared; + +namespace Bifrost.Web.Tests; + +public sealed class FixedWindowBudgetTests +{ + private static readonly TimeSpan Window = TimeSpan.FromSeconds(60); + + [Fact] + public void Permits_are_spent_per_key_and_the_next_is_denied_with_the_time_left() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, clock); + + Assert.True(budget.TryAcquire("a", out _)); + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.True(budget.TryAcquire("a", out _)); + Assert.False(budget.TryAcquire("a", out var wait)); + + Assert.Equal(TimeSpan.FromSeconds(50), wait); + Assert.True(budget.TryAcquire("b", out _)); + } + + [Fact] + public void A_window_opens_at_its_first_permit_and_closes_exactly_one_window_later() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, clock); + Assert.True(budget.TryAcquire("a", out _)); + + clock.Advance(Window - TimeSpan.FromTicks(1)); + Assert.False(budget.TryAcquire("a", out var wait)); + Assert.Equal(TimeSpan.FromTicks(1), wait); + + clock.Advance(TimeSpan.FromTicks(1)); + Assert.True(budget.TryAcquire("a", out _)); + Assert.False(budget.TryAcquire("a", out wait)); + Assert.Equal(Window, wait); + } + + [Fact] + public void A_denied_attempt_does_not_extend_the_window() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, clock); + budget.TryAcquire("a", out _); + for (var i = 0; i < 5; i++) + { + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.False(budget.TryAcquire("a", out _)); + } + + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.True(budget.TryAcquire("a", out _)); + } + + [Fact] + public void Expired_windows_are_dropped_so_the_table_does_not_grow_with_every_key_ever_seen() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, clock); + for (var i = 0; i < 100; i++) + { + budget.TryAcquire("k" + i, out _); + } + + Assert.Equal(100, budget.TrackedKeys); + clock.Advance(Window); + budget.TryAcquire("fresh", out _); + + Assert.Equal(1, budget.TrackedKeys); + } + + [Theory] + [InlineData(0, 60)] + [InlineData(-1, 60)] + [InlineData(1, 0)] + [InlineData(1, -5)] + public void A_budget_that_cannot_be_enforced_is_refused(int permits, int seconds) + { + Assert.ThrowsAny( + () => new FixedWindowBudget(permits, TimeSpan.FromSeconds(seconds), new Clock())); + } + + private sealed class Clock : TimeProvider + { + private DateTimeOffset _now = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; + } +} diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 7d55439..959c883 100644 --- a/tests/Bifrost.Web.Tests/HostTrustTests.cs +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -185,6 +185,50 @@ public sealed class HostTrustTests : IClassFixture Validate(keysPath: "/keys", ipv4Length: 0, ipv6Length: 0); } + [Theory] + [InlineData("Development")] + [InlineData("Testing")] + [InlineData("Production")] + public void Every_environment_refuses_prefix_lengths_outside_the_address_family(string environment) + { + var v4 = Assert.Throws( + () => Validate(keysPath: "/keys", environment: environment, ipv4Length: 33)); + Assert.Contains("ConceptVotes:IPv4PrefixLength", v4.Message); + Assert.DoesNotContain("IPv6PrefixLength", v4.Message); + + var v6 = Assert.Throws( + () => Validate(keysPath: "/keys", environment: environment, ipv6Length: 129)); + Assert.Contains("ConceptVotes:IPv6PrefixLength", v6.Message); + Assert.DoesNotContain("IPv4PrefixLength", v6.Message); + + Validate(keysPath: "/keys", environment: environment, ipv4Length: 0, ipv6Length: 0); + } + + [Theory] + [InlineData("Development", 0, 1, 5, 1, "ConceptVotes:CastPermits")] + [InlineData("Testing", 30, 0, 5, 1, "ConceptVotes:CastPermits")] + [InlineData("Production", 30, 1, 0, 1, "ConceptVotes:MintPermits")] + [InlineData("Production", 30, 1, 5, 0, "ConceptVotes:MintPermits")] + public void Every_environment_refuses_a_vote_budget_that_cannot_be_enforced( + string environment, + int castPermits, + int castMinutes, + int mintPermits, + int mintMinutes, + string key) + { + var refusal = Assert.Throws(() => Validate( + keysPath: "/keys", + environment: environment, + castPermits: castPermits, + castWindow: TimeSpan.FromMinutes(castMinutes), + mintPermits: mintPermits, + mintWindow: TimeSpan.FromMinutes(mintMinutes))); + + Assert.Contains(key, refusal.Message); + Validate(keysPath: "/keys", environment: environment); + } + [Theory] [InlineData("*")] [InlineData("")] @@ -267,7 +311,11 @@ public sealed class HostTrustTests : IClassFixture string? tagSecret = ValidTagSecret, int ipv4Length = 32, int ipv6Length = 56, - string[]? allowedOrigins = null) + string[]? allowedOrigins = null, + int castPermits = 30, + TimeSpan? castWindow = null, + int mintPermits = 5, + TimeSpan? mintWindow = null) { var values = new Dictionary { @@ -295,7 +343,11 @@ public sealed class HostTrustTests : IClassFixture NetworkTagSecret = tagSecret ?? string.Empty, IPv4PrefixLength = ipv4Length, IPv6PrefixLength = ipv6Length, - AllowedOrigins = allowedOrigins ?? [] + AllowedOrigins = allowedOrigins ?? [], + CastPermits = castPermits, + CastWindow = castWindow ?? TimeSpan.FromMinutes(10), + MintPermits = mintPermits, + MintWindow = mintWindow ?? TimeSpan.FromHours(1) })).Validate(); } diff --git a/tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs b/tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs new file mode 100644 index 0000000..ea7558b --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Logging; + +namespace Bifrost.Web.Tests.Support; + +public sealed class CapturingLoggerProvider : ILoggerProvider +{ + private readonly List _lines = []; + + public IReadOnlyList Lines + { + get + { + lock (_lines) + { + return _lines.ToArray(); + } + } + } + + public ILogger CreateLogger(string categoryName) => new Capture(this); + + public void Dispose() + { + } + + private sealed class Capture(CapturingLoggerProvider owner) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + lock (owner._lines) + { + owner._lines.Add(formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception)); + } + } + } +} diff --git a/tests/Bifrost.Web.Tests/Support/TestAuthHandler.cs b/tests/Bifrost.Web.Tests/Support/TestAuthHandler.cs index f63fc13..298642e 100644 --- a/tests/Bifrost.Web.Tests/Support/TestAuthHandler.cs +++ b/tests/Bifrost.Web.Tests/Support/TestAuthHandler.cs @@ -21,11 +21,14 @@ public sealed class TestAuthHandler( return Task.FromResult(AuthenticateResult.NoResult()); } + // A test may sign in as another account by naming its GitHub id and login. + var gitHubId = Request.Headers["X-Test-GitHub-Id"].FirstOrDefault() ?? "12345"; + var login = Request.Headers["X-Test-GitHub-Login"].FirstOrDefault() ?? "test-admin"; var claims = new[] { - new Claim(ClaimTypes.NameIdentifier, "12345"), - new Claim(ClaimTypes.Name, "test-admin"), - new Claim(BifrostClaimTypes.GitHubLogin, "test-admin"), + new Claim(ClaimTypes.NameIdentifier, gitHubId), + new Claim(ClaimTypes.Name, login), + new Claim(BifrostClaimTypes.GitHubLogin, login), new Claim(BifrostClaimTypes.DisplayName, "Test Admin") }; diff --git a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs index ed3c4ed..7672a76 100644 --- a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs +++ b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs @@ -34,6 +34,8 @@ public class TestWebApplicationFactory : WebApplicationFactory ["GitHubApp:PrivateKeyPem"] = "test-private-key", ["GitHubApp:AppId"] = "1", ["Bridge:LocalBridgeToken"] = "test-bridge-token", + ["ConceptVotes:CastPermits"] = "100000", + ["ConceptVotes:MintPermits"] = "100000", ["Host:PublicBaseUrl"] = "https://localhost", ["Host:ExpectedHost"] = "localhost", ["Host:RequireStrictHostValidation"] = "false", From 7f375ecf8f39169fb8e27d8cdb075c28fa20ef01 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 09:02:01 +0200 Subject: [PATCH 02/40] Pin the empty Origin branch with a server-built request HttpClient drops an empty header value, so the first version of the test never sent the header; the request is now built on the TestServer. Co-Authored-By: Claude Opus 5.5 --- .../Bifrost.Web.Tests/ConceptVotePageTests.cs | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index b31c1c0..5434e8d 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -4,6 +4,7 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Tests.Support; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -190,12 +191,27 @@ public sealed partial class ConceptVotePageTests : IClassFixture + { + http.Request.Method = HttpMethods.Post; + http.Request.Scheme = "https"; + http.Request.Host = new HostString("localhost"); + http.Request.Path = $"/mill/{collection}/alpha"; + http.Request.QueryString = new QueryString("?handler=Up"); + http.Request.ContentType = "application/x-www-form-urlencoded"; + http.Request.Headers["X-Test-Anonymous"] = "true"; + http.Request.Headers["Origin"] = string.Empty; + if (fetchSite is not null) + { + http.Request.Headers["Sec-Fetch-Site"] = fetchSite; + } + }); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - Assert.False(response.Headers.Contains("Set-Cookie")); + Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); + Assert.False(context.Response.Headers.ContainsKey("Set-Cookie")); Assert.Empty(await Rows(collection, "alpha")); } From 9fcef927fd4086e05c6b505275329de61b49c50a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 09:35:50 +0200 Subject: [PATCH 03/40] Pin the budget sweep and Retry-After rounding; drop a redundant Math.Max Survivors of the first Stryker pass on FixedWindowBudget: the sweep dropping live windows, sweeping every call, and the exact window boundary (masked by the sweep in the first test). Co-Authored-By: Claude Opus 5.5 --- src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs | 2 +- .../ConceptVoteRateLimitTests.cs | 5 ++- .../FixedWindowBudgetTests.cs | 41 +++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index 5e46e1d..a06c3b3 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -140,7 +140,7 @@ public sealed class VoteModel( return null; } - Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); + Response.Headers.RetryAfter = ((int)Math.Ceiling(wait.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); return new StatusCodeResult(StatusCodes.Status429TooManyRequests); } diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs index 7165973..5049071 100644 --- a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -92,14 +92,15 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture Date: Sun, 11 Oct 2026 09:46:45 +0200 Subject: [PATCH 04/40] Pin that a signed-in retract spends the account budget Hand mutant retract-key-ignores-signed-in-voter survived the first table. Co-Authored-By: Claude Opus 5.5 --- .../ConceptVoteRateLimitTests.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs index 5049071..b2beb0b 100644 --- a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -152,6 +152,23 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture Date: Sun, 11 Oct 2026 17:32:01 +0200 Subject: [PATCH 05/40] Vote budgets: monotonic windows, a key cap, 30 mints an hour and a readable 429 FixedWindowBudget times windows on the clock's monotonic timestamps so a wall-clock step cannot stretch or reset one, expires windows from a start-ordered queue instead of a periodic sweep, and holds at most MaxTrackedKeys keys: a new key at the cap is denied with the true wait and a held key is never evicted. MintPermits is 30 per hour per the operator ruling, and a 429 now carries a plain-text reason; a refused new device is told it can sign in. Not yet built or tested at this commit. Co-Authored-By: Claude Sonnet 5.5 --- .../Configuration/ConceptVoteOptions.cs | 13 ++- .../ConceptVotes/ConceptVoteRateLimits.cs | 4 +- .../Features/Shared/FixedWindowBudget.cs | 68 ++++++----- .../Shared/StartupConfigurationValidator.cs | 5 + src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs | 23 +++- .../ConceptVoteRateLimitTests.cs | 44 ++++++- .../FixedWindowBudgetTests.cs | 109 ++++++++++++++---- tests/Bifrost.Web.Tests/HostTrustTests.cs | 18 ++- 8 files changed, 221 insertions(+), 63 deletions(-) diff --git a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs index 41a866e..c5e5701 100644 --- a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs +++ b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs @@ -28,12 +28,19 @@ public sealed class ConceptVoteOptions public TimeSpan CastWindow { get; init; } = TimeSpan.FromMinutes(10); - // Mint: a new device cookie is a new voter identity, the cheapest stuffing lever, and a household has few devices, - // so 5 new devices per hour per network prefix. - public int MintPermits { get; init; } = 5; + // Mint: a new device cookie is a new voter identity, the cheapest stuffing lever. Anonymous visitors share an + // address behind carrier, school and office NAT (the research note records it), so 30 new devices per hour per + // address (operator ruling concept-mill-votes:ruling:mint-budget-30-per-hour); per-device and per-prefix cast + // limits still bound stuffing. + public int MintPermits { get; init; } = 30; public TimeSpan MintWindow { get; init; } = TimeSpan.FromHours(1); + // The most partitions each budget holds at once. At the cap a partition not yet held is refused with a 429 until + // the oldest window ends; held partitions are never evicted, so a rotating caller cannot escape a spent budget. + // 100000 partitions is a few tens of MB; a real audience is far below it. + public int MaxTrackedKeys { get; init; } = 100_000; + // The origins whose pages may read the public tally export from a browser (CORS). Each entry is exactly // https://host[:non-default-port], lower case, no path; empty allows none. StartupConfigurationValidator // refuses anything else outside Development and Testing, because "*" would allow every origin. diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs index aac9802..d662b8a 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs @@ -12,8 +12,8 @@ namespace Bifrost.Web.Features.ConceptVotes; public sealed class ConceptVoteRateLimits(IOptions options, TimeProvider clock) { private readonly ConceptVoteOptions opts = options.Value; - private readonly FixedWindowBudget cast = new(options.Value.CastPermits, options.Value.CastWindow, clock); - private readonly FixedWindowBudget mint = new(options.Value.MintPermits, options.Value.MintWindow, clock); + private readonly FixedWindowBudget cast = new(options.Value.CastPermits, options.Value.CastWindow, options.Value.MaxTrackedKeys, clock); + private readonly FixedWindowBudget mint = new(options.Value.MintPermits, options.Value.MintWindow, options.Value.MaxTrackedKeys, clock); public TimeSpan? DenyCast(IPAddress? client, VoterIdentity? known) => cast.TryAcquire(known?.UserAccountId is { } account ? "account:" + account.ToString("N") : NetKey(client), out var wait) diff --git a/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs index abcaa0f..c002f03 100644 --- a/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs +++ b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs @@ -1,28 +1,37 @@ namespace Bifrost.Web.Features.Shared; -// The one rate-limit mechanism: a fixed-window permit budget per caller-chosen partition key, read against an injected -// clock. Keys are opaque and are never stored beyond their window: an entry whose window has ended is dropped by the -// next sweep, so the table is bounded by the distinct keys seen in one window. System.Threading.RateLimiting is not -// used because its limiters time themselves on Stopwatch and timers, which a test cannot move. +// The one rate-limit mechanism: a fixed-window permit budget per caller-chosen partition key, timed on the injected +// clock's monotonic timestamps (never its wall time, which can step), so a clock adjustment cannot stretch or reset a +// window. Windows all have one length, so they end in the order they began: a queue of window starts expires them from +// the front and the table never holds a key past its window. The table holds at most maxKeys keys; a new key arriving +// at the cap is denied until the oldest window ends, with the true wait, so a rotating caller cannot evict a spent key +// to escape its budget and the refusal is a 429 that says when to retry, never a silent drop. +// System.Threading.RateLimiting is not used because its limiters time themselves on Stopwatch and timers, which a +// test cannot move. public sealed class FixedWindowBudget { private readonly int permits; private readonly TimeSpan window; + private readonly int maxKeys; private readonly TimeProvider clock; + private readonly long origin; private readonly Dictionary entries = new(StringComparer.Ordinal); + private readonly Queue byStart = new(); private readonly object gate = new(); - private DateTimeOffset nextSweep = DateTimeOffset.MinValue; - public FixedWindowBudget(int permits, TimeSpan window, TimeProvider clock) + public FixedWindowBudget(int permits, TimeSpan window, int maxKeys, TimeProvider clock) { ArgumentOutOfRangeException.ThrowIfLessThan(permits, 1); ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(window, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThan(maxKeys, 1); this.permits = permits; this.window = window; + this.maxKeys = maxKeys; this.clock = clock; + origin = clock.GetTimestamp(); } - // How many keys are held now: bounded by the distinct keys seen in one window, not by every key ever seen. + // How many keys are held now: the keys whose window is still open, never more than maxKeys. public int TrackedKeys { get @@ -34,44 +43,47 @@ public sealed class FixedWindowBudget } } - // Takes one permit for the key. When none is left, returns false and how long until its window ends. + // Takes one permit for the key. When none is left, or the table is full and the key is new, returns false and how + // long until a window ends and the answer can change. public bool TryAcquire(string key, out TimeSpan retryAfter) { - var now = clock.GetUtcNow(); lock (gate) { - if (now >= nextSweep) + var now = clock.GetElapsedTime(origin); + while (byStart.TryPeek(out var oldest) && entries[oldest].Start + window <= now) { - foreach (var expired in entries.Where(x => x.Value.Start + window <= now).Select(x => x.Key).ToList()) + entries.Remove(byStart.Dequeue()); + } + + if (entries.TryGetValue(key, out var entry)) + { + if (entry.Count >= permits) { - entries.Remove(expired); + retryAfter = entry.Start + window - now; + return false; } - nextSweep = now + window; - } - - if (!entries.TryGetValue(key, out var entry) || entry.Start + window <= now) - { - entries[key] = new Entry(now); - retryAfter = TimeSpan.Zero; - return true; - } - - if (entry.Count < permits) - { entry.Count++; retryAfter = TimeSpan.Zero; return true; } - retryAfter = entry.Start + window - now; - return false; + if (entries.Count >= maxKeys) + { + retryAfter = entries[byStart.Peek()].Start + window - now; + return false; + } + + entries[key] = new Entry(now); + byStart.Enqueue(key); + retryAfter = TimeSpan.Zero; + return true; } } - private sealed class Entry(DateTimeOffset start) + private sealed class Entry(TimeSpan start) { - public DateTimeOffset Start { get; } = start; + public TimeSpan Start { get; } = start; public int Count { get; set; } = 1; } diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 676e628..98b8dc7 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -40,6 +40,11 @@ public sealed class StartupConfigurationValidator( failures.Add("ConceptVotes:MintPermits must be at least 1 and ConceptVotes:MintWindow must be positive."); } + if (conceptVotes.MaxTrackedKeys < 1) + { + failures.Add("ConceptVotes:MaxTrackedKeys must be at least 1."); + } + if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing")) { Refuse(failures); diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index a06c3b3..2e11b1f 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -77,7 +77,7 @@ public sealed class VoteModel( { var actor = await actorAccessor.GetAsync(cancellationToken); var voter = ResolveVoter(actor); - if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter)) is { } throttled) + if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter), CastRefusal) is { } throttled) { return throttled; } @@ -100,7 +100,7 @@ public sealed class VoteModel( var actor = await actorAccessor.GetAsync(cancellationToken); var client = HttpContext.Connection.RemoteIpAddress; var voter = ResolveVoter(actor); - if (Throttled(limits.DenyCast(client, voter)) is { } throttled) + if (Throttled(limits.DenyCast(client, voter), CastRefusal) is { } throttled) { return throttled; } @@ -112,7 +112,7 @@ public sealed class VoteModel( if (voter is null) { - if (Throttled(limits.DenyMint(client)) is { } mintThrottled) + if (Throttled(limits.DenyMint(client), MintRefusal) is { } mintThrottled) { return mintThrottled; } @@ -132,8 +132,14 @@ public sealed class VoteModel( private VoterIdentity? ResolveVoter(CurrentBifrostActor actor) => VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext)); - // A denied budget is a 429 that says when to retry; it writes no row and sets no cookie. - private IActionResult? Throttled(TimeSpan? retryAfter) + // A denied budget is a 429 that says when to retry, in words a voter can read; it writes no row, sets no cookie and + // repeats nothing of the request. A refused new device is told it can sign in, which has a budget of its own. + private const string CastRefusal = "You are voting too fast. Please wait a little before voting again."; + + private const string MintRefusal = + "Too many new voters have voted from your network recently. You can sign in to vote now, or try again later."; + + private IActionResult? Throttled(TimeSpan? retryAfter, string refusal) { if (retryAfter is not { } wait) { @@ -141,7 +147,12 @@ public sealed class VoteModel( } Response.Headers.RetryAfter = ((int)Math.Ceiling(wait.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); - return new StatusCodeResult(StatusCodes.Status429TooManyRequests); + return new ContentResult + { + StatusCode = StatusCodes.Status429TooManyRequests, + ContentType = "text/plain; charset=utf-8", + Content = refusal + }; } private async Task LoadAsync(string collection, string key, VoterIdentity? voter, CancellationToken cancellationToken) diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs index b2beb0b..8e533f4 100644 --- a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -206,6 +206,10 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture h.Key + ":" + string.Join(",", h.Value))); Assert.DoesNotContain("203.0.113", seen); Assert.DoesNotContain(logs.Lines, line => line.Contains("203.0.113", StringComparison.Ordinal)); } + [Fact] + public void Defaults_allow_thirty_new_devices_an_hour_per_address() + { + var defaults = new Bifrost.Web.Configuration.ConceptVoteOptions(); + + Assert.Equal(30, defaults.MintPermits); + Assert.Equal(TimeSpan.FromHours(1), defaults.MintWindow); + Assert.Equal(100_000, defaults.MaxTrackedKeys); + } + + [Fact] + public void Both_budgets_hold_at_most_the_configured_number_of_partitions_and_refuse_a_new_one_at_the_cap() + { + var clock = new TestClock(); + var limits = new Bifrost.Web.Features.ConceptVotes.ConceptVoteRateLimits( + Microsoft.Extensions.Options.Options.Create(new Bifrost.Web.Configuration.ConceptVoteOptions { MaxTrackedKeys = 1 }), + clock); + + Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.1"), null)); + Assert.NotNull(limits.DenyCast(IPAddress.Parse("198.51.100.2"), null)); + Assert.Null(limits.DenyMint(IPAddress.Parse("198.51.100.1"))); + Assert.NotNull(limits.DenyMint(IPAddress.Parse("198.51.100.2"))); + Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.1"), null)); + } + [Fact] public async Task Reads_are_not_under_the_vote_budgets() { @@ -387,10 +418,19 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture TimeSpan.TicksPerSecond; public override DateTimeOffset GetUtcNow() => _now; - public void Advance(TimeSpan by) => _now += by; + public override long GetTimestamp() => _ticks; + + public void Advance(TimeSpan by) + { + _now += by; + _ticks += by.Ticks; + } } // The proxy's connection: TestServer has no socket, so the peer address is set ahead of the app pipeline. diff --git a/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs index 0493a03..5d1c3b7 100644 --- a/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs +++ b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs @@ -5,12 +5,13 @@ namespace Bifrost.Web.Tests; public sealed class FixedWindowBudgetTests { private static readonly TimeSpan Window = TimeSpan.FromSeconds(60); + private const int Cap = 1000; [Fact] public void Permits_are_spent_per_key_and_the_next_is_denied_with_the_time_left() { var clock = new Clock(); - var budget = new FixedWindowBudget(2, Window, clock); + var budget = new FixedWindowBudget(2, Window, Cap, clock); Assert.True(budget.TryAcquire("a", out _)); clock.Advance(TimeSpan.FromSeconds(10)); @@ -25,7 +26,7 @@ public sealed class FixedWindowBudgetTests public void A_window_opens_at_its_first_permit_and_closes_exactly_one_window_later() { var clock = new Clock(); - var budget = new FixedWindowBudget(1, Window, clock); + var budget = new FixedWindowBudget(1, Window, Cap, clock); Assert.True(budget.TryAcquire("a", out _)); clock.Advance(Window - TimeSpan.FromTicks(1)); @@ -42,7 +43,7 @@ public sealed class FixedWindowBudgetTests public void A_denied_attempt_does_not_extend_the_window() { var clock = new Clock(); - var budget = new FixedWindowBudget(1, Window, clock); + var budget = new FixedWindowBudget(1, Window, Cap, clock); budget.TryAcquire("a", out _); for (var i = 0; i < 5; i++) { @@ -55,16 +56,16 @@ public sealed class FixedWindowBudgetTests } [Fact] - public void A_sweep_drops_only_expired_windows_and_a_live_one_keeps_counting_to_its_exact_end() + public void An_expired_window_is_dropped_and_a_live_one_keeps_counting_to_its_exact_end() { var clock = new Clock(); - var budget = new FixedWindowBudget(1, Window, clock); + var budget = new FixedWindowBudget(1, Window, Cap, clock); budget.TryAcquire("a", out _); clock.Advance(TimeSpan.FromSeconds(30)); budget.TryAcquire("b", out _); clock.Advance(TimeSpan.FromSeconds(30)); - // The sweep due at 60 s drops a, which ended, and keeps b, which has 30 s left. + // At 60 s a has ended and b has 30 s left. budget.TryAcquire("c", out _); Assert.Equal(2, budget.TrackedKeys); Assert.False(budget.TryAcquire("b", out var wait)); @@ -77,29 +78,79 @@ public sealed class FixedWindowBudgetTests } [Fact] - public void Sweeping_is_at_most_once_per_window_so_a_flood_of_keys_costs_one_pass_not_one_per_request() + public void A_wall_clock_step_back_does_not_extend_a_window_or_its_retry_after() { var clock = new Clock(); - var budget = new FixedWindowBudget(1, Window, clock); + var budget = new FixedWindowBudget(1, Window, Cap, clock); budget.TryAcquire("a", out _); - clock.Advance(TimeSpan.FromSeconds(30)); + clock.Advance(TimeSpan.FromSeconds(10)); + clock.StepWall(-TimeSpan.FromHours(1)); + + Assert.False(budget.TryAcquire("a", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(50), wait); + clock.Advance(TimeSpan.FromSeconds(50)); + Assert.True(budget.TryAcquire("a", out _)); + Assert.Equal(1, budget.TrackedKeys); + } + + [Fact] + public void A_wall_clock_step_forward_does_not_reset_a_window() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + budget.TryAcquire("a", out _); + clock.StepWall(TimeSpan.FromHours(1)); + + Assert.False(budget.TryAcquire("a", out var wait)); + Assert.Equal(Window, wait); + } + + [Fact] + public void A_new_key_at_the_cap_is_denied_until_the_oldest_window_ends_and_a_held_key_is_never_evicted() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, 2, clock); + budget.TryAcquire("a", out _); + clock.Advance(TimeSpan.FromSeconds(20)); budget.TryAcquire("b", out _); - clock.Advance(TimeSpan.FromSeconds(31)); - budget.TryAcquire("c", out _); + clock.Advance(TimeSpan.FromSeconds(10)); + + Assert.False(budget.TryAcquire("c", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(30), wait); Assert.Equal(2, budget.TrackedKeys); - // b ended at 90 s but the next sweep is not due until 121 s. - clock.Advance(TimeSpan.FromSeconds(39)); - budget.TryAcquire("d", out _); + // Rotating new keys cannot push a spent key out to get its budget back. + for (var i = 0; i < 10; i++) + { + Assert.False(budget.TryAcquire("rot" + i, out _)); + } - Assert.Equal(3, budget.TrackedKeys); + Assert.False(budget.TryAcquire("a", out wait)); + Assert.Equal(TimeSpan.FromSeconds(30), wait); + + clock.Advance(TimeSpan.FromSeconds(30)); + Assert.True(budget.TryAcquire("c", out _)); + Assert.Equal(2, budget.TrackedKeys); + Assert.False(budget.TryAcquire("b", out wait)); + Assert.Equal(TimeSpan.FromSeconds(20), wait); + } + + [Fact] + public void A_held_key_under_its_permits_is_served_even_when_the_table_is_full() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 1, clock); + Assert.True(budget.TryAcquire("a", out _)); + + Assert.True(budget.TryAcquire("a", out _)); + Assert.False(budget.TryAcquire("a", out _)); } [Fact] public void Expired_windows_are_dropped_so_the_table_does_not_grow_with_every_key_ever_seen() { var clock = new Clock(); - var budget = new FixedWindowBudget(1, Window, clock); + var budget = new FixedWindowBudget(1, Window, Cap, clock); for (var i = 0; i < 100; i++) { budget.TryAcquire("k" + i, out _); @@ -120,15 +171,33 @@ public sealed class FixedWindowBudgetTests public void A_budget_that_cannot_be_enforced_is_refused(int permits, int seconds) { Assert.ThrowsAny( - () => new FixedWindowBudget(permits, TimeSpan.FromSeconds(seconds), new Clock())); + () => new FixedWindowBudget(permits, TimeSpan.FromSeconds(seconds), Cap, new Clock())); } + [Fact] + public void A_cap_below_one_is_refused() + { + Assert.Throws(() => new FixedWindowBudget(1, Window, 0, new Clock())); + } + + // Time advances monotonic and wall together; StepWall moves only the wall clock, as an NTP step would. private sealed class Clock : TimeProvider { - private DateTimeOffset _now = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero); + private DateTimeOffset _wall = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero); + private long _ticks = 5_000_000; - public override DateTimeOffset GetUtcNow() => _now; + public override long TimestampFrequency => TimeSpan.TicksPerSecond; - public void Advance(TimeSpan by) => _now += by; + public override DateTimeOffset GetUtcNow() => _wall; + + public override long GetTimestamp() => _ticks; + + public void Advance(TimeSpan by) + { + _wall += by; + _ticks += by.Ticks; + } + + public void StepWall(TimeSpan by) => _wall += by; } } diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 959c883..54c0128 100644 --- a/tests/Bifrost.Web.Tests/HostTrustTests.cs +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -229,6 +229,18 @@ public sealed class HostTrustTests : IClassFixture Validate(keysPath: "/keys", environment: environment); } + [Theory] + [InlineData("Development")] + [InlineData("Testing")] + [InlineData("Production")] + public void Every_environment_refuses_a_vote_table_cap_below_one(string environment) + { + var refusal = Assert.Throws( + () => Validate(keysPath: "/keys", environment: environment, maxTrackedKeys: 0)); + + Assert.Contains("ConceptVotes:MaxTrackedKeys", refusal.Message); + } + [Theory] [InlineData("*")] [InlineData("")] @@ -315,7 +327,8 @@ public sealed class HostTrustTests : IClassFixture int castPermits = 30, TimeSpan? castWindow = null, int mintPermits = 5, - TimeSpan? mintWindow = null) + TimeSpan? mintWindow = null, + int maxTrackedKeys = 100_000) { var values = new Dictionary { @@ -347,7 +360,8 @@ public sealed class HostTrustTests : IClassFixture CastPermits = castPermits, CastWindow = castWindow ?? TimeSpan.FromMinutes(10), MintPermits = mintPermits, - MintWindow = mintWindow ?? TimeSpan.FromHours(1) + MintWindow = mintWindow ?? TimeSpan.FromHours(1), + MaxTrackedKeys = maxTrackedKeys })).Validate(); } From 8f57dc89f0f9f6c249f991e3c90b6a46388608b4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 17:38:56 +0200 Subject: [PATCH 06/40] Pin the no-client-address bucket of the vote budgets Kills the Stryker survivor on the null-client key: a request with no address shares one bucket and does not throw. Builds with a8c3ecf; test added after the main verify job. Co-Authored-By: Claude Sonnet 5.5 --- tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs index 8e533f4..4feac7c 100644 --- a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -284,6 +284,18 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture Date: Sun, 11 Oct 2026 19:16:30 +0200 Subject: [PATCH 07/40] Vote budgets: at the cap a new key evicts the least recently used key with permits left Ruling budget-table-evict-oldest. FixedWindowBudget keeps two linked lists (window start, last use among keys with permits left) so eviction and expiry are O(1); a spent key is never evicted, and a table full of spent keys refuses a new key as TableFull with the true wait. Accounts get their own cast table. The vote page checks the subject before spending any key, and the refusal names its reason: cast budget, mint budget, or a busy table. Not verified by a build yet at this commit. Co-Authored-By: Claude Sonnet 5.5 --- .../Configuration/ConceptVoteOptions.cs | 7 +- .../ConceptVotes/ConceptVoteRateLimits.cs | 23 +-- .../Features/Shared/FixedWindowBudget.cs | 83 ++++++-- src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs | 33 ++-- .../ConceptVoteRateLimitTests.cs | 148 +++++++++++++- .../FixedWindowBudgetTests.cs | 182 +++++++++++++----- 6 files changed, 377 insertions(+), 99 deletions(-) diff --git a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs index c5e5701..0e60f7d 100644 --- a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs +++ b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs @@ -36,9 +36,10 @@ public sealed class ConceptVoteOptions public TimeSpan MintWindow { get; init; } = TimeSpan.FromHours(1); - // The most partitions each budget holds at once. At the cap a partition not yet held is refused with a 429 until - // the oldest window ends; held partitions are never evicted, so a rotating caller cannot escape a spent budget. - // 100000 partitions is a few tens of MB; a real audience is far below it. + // The most partitions each budget table holds at once (cast by account, cast by network prefix, mint). At the cap a + // partition not yet held evicts the least recently used one that still has permits left; a partition that has + // spent its permits is never evicted, so rotating sources can at most win back a forgotten partition's partial + // budget. 100000 partitions is a few tens of MB; a real audience is far below it. public int MaxTrackedKeys { get; init; } = 100_000; // The origins whose pages may read the public tally export from a browser (CORS). Each entry is exactly diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs index d662b8a..fcfe361 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs @@ -6,23 +6,24 @@ using Microsoft.Extensions.Options; namespace Bifrost.Web.Features.ConceptVotes; // The one owner of the vote budgets. Cast is spent by every cast and retract; mint is spent additionally by a cast that -// is about to mint a device cookie. A denial returns how long to wait and writes nothing. The partition is the account -// when the voter is signed in, otherwise the network prefix (NetworkPrefix.Of) of the address the forwarded-headers -// middleware left on the connection. No header and no client input ever becomes a key. +// is about to mint a device cookie. A denial says why and how long to wait, and writes nothing. The partition is the +// account when the voter is signed in, otherwise the network prefix (NetworkPrefix.Of) of the address the +// forwarded-headers middleware left on the connection. No header and no client input ever becomes a key. Accounts +// have a table of their own, so a flood of anonymous sources cannot crowd a signed-in voter out of it. public sealed class ConceptVoteRateLimits(IOptions options, TimeProvider clock) { private readonly ConceptVoteOptions opts = options.Value; - private readonly FixedWindowBudget cast = new(options.Value.CastPermits, options.Value.CastWindow, options.Value.MaxTrackedKeys, clock); + private readonly FixedWindowBudget castAccounts = new(options.Value.CastPermits, options.Value.CastWindow, options.Value.MaxTrackedKeys, clock); + private readonly FixedWindowBudget castNetworks = new(options.Value.CastPermits, options.Value.CastWindow, options.Value.MaxTrackedKeys, clock); private readonly FixedWindowBudget mint = new(options.Value.MintPermits, options.Value.MintWindow, options.Value.MaxTrackedKeys, clock); - public TimeSpan? DenyCast(IPAddress? client, VoterIdentity? known) => - cast.TryAcquire(known?.UserAccountId is { } account ? "account:" + account.ToString("N") : NetKey(client), out var wait) - ? null - : wait; + public BudgetDenial? DenyCast(IPAddress? client, VoterIdentity? known) => + known?.UserAccountId is { } account + ? castAccounts.Spend(account.ToString("N")) + : castNetworks.Spend(NetKey(client)); - public TimeSpan? DenyMint(IPAddress? client) => - mint.TryAcquire(NetKey(client), out var wait) ? null : wait; + public BudgetDenial? DenyMint(IPAddress? client) => mint.Spend(NetKey(client)); private string NetKey(IPAddress? client) => - "net:" + (client is null ? "unknown" : NetworkPrefix.Of(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength)); + client is null ? "unknown" : NetworkPrefix.Of(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength); } diff --git a/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs index c002f03..b6c9083 100644 --- a/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs +++ b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs @@ -1,11 +1,18 @@ namespace Bifrost.Web.Features.Shared; +// Why a permit was refused and how long until the answer can change. TableFull is not the caller's own spending: the +// table holds only spent windows and has no room for a key it does not know. +public readonly record struct BudgetDenial(TimeSpan RetryAfter, bool TableFull); + // The one rate-limit mechanism: a fixed-window permit budget per caller-chosen partition key, timed on the injected // clock's monotonic timestamps (never its wall time, which can step), so a clock adjustment cannot stretch or reset a -// window. Windows all have one length, so they end in the order they began: a queue of window starts expires them from -// the front and the table never holds a key past its window. The table holds at most maxKeys keys; a new key arriving -// at the cap is denied until the oldest window ends, with the true wait, so a rotating caller cannot evict a spent key -// to escape its budget and the refusal is a 429 that says when to retry, never a silent drop. +// window. Windows all have one length, so they end in the order they began: a list of entries in start order expires +// them from the front and the table never holds a key past its window. +// The table holds at most maxKeys keys. A new key arriving at the cap evicts the least recently used key that still +// has permits left (nginx limit_req_zone does the same); a key that has spent all its permits is never evicted, so a +// rotating caller can at most win back a forgotten key's partial budget and never a spent one, and nobody fresh is +// refused because the table is full. Only when every held key is spent is a new key refused, with the true wait. +// Every step is O(1): two linked lists, one by window start and one by last use among keys with permits left. // System.Threading.RateLimiting is not used because its limiters time themselves on Stopwatch and timers, which a // test cannot move. public sealed class FixedWindowBudget @@ -16,7 +23,8 @@ public sealed class FixedWindowBudget private readonly TimeProvider clock; private readonly long origin; private readonly Dictionary entries = new(StringComparer.Ordinal); - private readonly Queue byStart = new(); + private readonly LinkedList byStart = new(); + private readonly LinkedList byUse = new(); private readonly object gate = new(); public FixedWindowBudget(int permits, TimeSpan window, int maxKeys, TimeProvider clock) @@ -43,48 +51,81 @@ public sealed class FixedWindowBudget } } - // Takes one permit for the key. When none is left, or the table is full and the key is new, returns false and how - // long until a window ends and the answer can change. - public bool TryAcquire(string key, out TimeSpan retryAfter) + // Takes one permit for the key and returns null, or returns why it could not. + public BudgetDenial? Spend(string key) { lock (gate) { var now = clock.GetElapsedTime(origin); - while (byStart.TryPeek(out var oldest) && entries[oldest].Start + window <= now) + while (byStart.First is { Value: var oldest } && oldest.Start + window <= now) { - entries.Remove(byStart.Dequeue()); + Remove(oldest); } if (entries.TryGetValue(key, out var entry)) { if (entry.Count >= permits) { - retryAfter = entry.Start + window - now; - return false; + return new BudgetDenial(entry.Start + window - now, false); } entry.Count++; - retryAfter = TimeSpan.Zero; - return true; + if (entry.Count >= permits) + { + byUse.Remove(entry.UseNode!); + entry.UseNode = null; + } + else + { + byUse.Remove(entry.UseNode!); + byUse.AddLast(entry.UseNode!); + } + + return null; } if (entries.Count >= maxKeys) { - retryAfter = entries[byStart.Peek()].Start + window - now; - return false; + if (byUse.First is not { Value: var forgotten }) + { + return new BudgetDenial(byStart.First!.Value.Start + window - now, true); + } + + Remove(forgotten); } - entries[key] = new Entry(now); - byStart.Enqueue(key); - retryAfter = TimeSpan.Zero; - return true; + var added = new Entry(key, now); + entries[key] = added; + added.StartNode = byStart.AddLast(added); + if (added.Count < permits) + { + added.UseNode = byUse.AddLast(added); + } + + return null; } } - private sealed class Entry(TimeSpan start) + private void Remove(Entry entry) { + entries.Remove(entry.Key); + byStart.Remove(entry.StartNode!); + if (entry.UseNode is not null) + { + byUse.Remove(entry.UseNode); + } + } + + private sealed class Entry(string key, TimeSpan start) + { + public string Key { get; } = key; + public TimeSpan Start { get; } = start; public int Count { get; set; } = 1; + + public LinkedListNode? StartNode { get; set; } + + public LinkedListNode? UseNode { get; set; } } } diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index 2e11b1f..f843f0a 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -77,16 +77,16 @@ public sealed class VoteModel( { var actor = await actorAccessor.GetAsync(cancellationToken); var voter = ResolveVoter(actor); - if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter), CastRefusal) is { } throttled) - { - return throttled; - } - if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) { return NotFound(); } + if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter), CastRefusal) is { } throttled) + { + return throttled; + } + await appraisals.RetractAsync(collection, key, voter, cancellationToken); return RedirectToPage(new { collection, key }); } @@ -100,16 +100,16 @@ public sealed class VoteModel( var actor = await actorAccessor.GetAsync(cancellationToken); var client = HttpContext.Connection.RemoteIpAddress; var voter = ResolveVoter(actor); - if (Throttled(limits.DenyCast(client, voter), CastRefusal) is { } throttled) - { - return throttled; - } - if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) { return NotFound(); } + if (Throttled(limits.DenyCast(client, voter), CastRefusal) is { } throttled) + { + return throttled; + } + if (voter is null) { if (Throttled(limits.DenyMint(client), MintRefusal) is { } mintThrottled) @@ -133,20 +133,25 @@ public sealed class VoteModel( VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext)); // A denied budget is a 429 that says when to retry, in words a voter can read; it writes no row, sets no cookie and - // repeats nothing of the request. A refused new device is told it can sign in, which has a budget of its own. + // repeats nothing of the request. The subject is checked before any budget is spent, so a request for a concept that + // does not exist costs the sender nothing and the table nothing. A refused new device is told it can sign in, which + // has a budget of its own. A table full of spent windows is not the visitor's doing and says so. private const string CastRefusal = "You are voting too fast. Please wait a little before voting again."; private const string MintRefusal = "Too many new voters have voted from your network recently. You can sign in to vote now, or try again later."; - private IActionResult? Throttled(TimeSpan? retryAfter, string refusal) + private const string BusyRefusal = "Voting is very busy right now. Please try again in a little while."; + + private IActionResult? Throttled(BudgetDenial? denial, string spentRefusal) { - if (retryAfter is not { } wait) + if (denial is not { } refused) { return null; } - Response.Headers.RetryAfter = ((int)Math.Ceiling(wait.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); + var refusal = refused.TableFull ? BusyRefusal : spentRefusal; + Response.Headers.RetryAfter = ((int)Math.Ceiling(refused.RetryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); return new ContentResult { StatusCode = StatusCodes.Status429TooManyRequests, diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs index 4feac7c..30ab093 100644 --- a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -1,6 +1,7 @@ using System.Net; using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; using Bifrost.Web.Tests.Support; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -270,18 +271,144 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture string.Join(",", h.Value)))); + Assert.Equal(HttpStatusCode.Redirect, account.StatusCode); + Assert.Equal(rowsBefore + 1, (await Rows(collection)).Count); + } + + [Fact] + public async Task Posts_to_a_missing_subject_spend_no_budget_and_mint_no_device() + { + var collection = await NewSubject(); + var sender = new Client(_factory, "203.0.113.140"); + foreach (var handler in new[] { "Up", "Down", "Retract" }) + { + for (var i = 0; i < 4; i++) + { + using var missing = await sender.Post("c" + Guid.NewGuid().ToString("N"), handler); + Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); + Assert.Null(SetCookie(missing)); + } + } + + for (var i = 0; i < 3; i++) + { + using var ok = await sender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await sender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); } [Fact] @@ -313,6 +440,13 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture Capped(int maxTrackedKeys) => + _factory.WithWebHostBuilder(b => b.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:MaxTrackedKeys"] = maxTrackedKeys.ToString(System.Globalization.CultureInfo.InvariantCulture) + }))); + private static string? RetryAfter(HttpResponseMessage response) => response.Headers.TryGetValues("Retry-After", out var values) ? values.Single() : null; diff --git a/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs index 5d1c3b7..e2fedb2 100644 --- a/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs +++ b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs @@ -13,13 +13,13 @@ public sealed class FixedWindowBudgetTests var clock = new Clock(); var budget = new FixedWindowBudget(2, Window, Cap, clock); - Assert.True(budget.TryAcquire("a", out _)); + Assert.True(Try(budget, "a", out _)); clock.Advance(TimeSpan.FromSeconds(10)); - Assert.True(budget.TryAcquire("a", out _)); - Assert.False(budget.TryAcquire("a", out var wait)); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out var wait)); Assert.Equal(TimeSpan.FromSeconds(50), wait); - Assert.True(budget.TryAcquire("b", out _)); + Assert.True(Try(budget, "b", out _)); } [Fact] @@ -27,15 +27,15 @@ public sealed class FixedWindowBudgetTests { var clock = new Clock(); var budget = new FixedWindowBudget(1, Window, Cap, clock); - Assert.True(budget.TryAcquire("a", out _)); + Assert.True(Try(budget, "a", out _)); clock.Advance(Window - TimeSpan.FromTicks(1)); - Assert.False(budget.TryAcquire("a", out var wait)); + Assert.False(Try(budget, "a", out var wait)); Assert.Equal(TimeSpan.FromTicks(1), wait); clock.Advance(TimeSpan.FromTicks(1)); - Assert.True(budget.TryAcquire("a", out _)); - Assert.False(budget.TryAcquire("a", out wait)); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out wait)); Assert.Equal(Window, wait); } @@ -44,15 +44,15 @@ public sealed class FixedWindowBudgetTests { var clock = new Clock(); var budget = new FixedWindowBudget(1, Window, Cap, clock); - budget.TryAcquire("a", out _); + Try(budget, "a", out _); for (var i = 0; i < 5; i++) { clock.Advance(TimeSpan.FromSeconds(10)); - Assert.False(budget.TryAcquire("a", out _)); + Assert.False(Try(budget, "a", out _)); } clock.Advance(TimeSpan.FromSeconds(10)); - Assert.True(budget.TryAcquire("a", out _)); + Assert.True(Try(budget, "a", out _)); } [Fact] @@ -60,21 +60,21 @@ public sealed class FixedWindowBudgetTests { var clock = new Clock(); var budget = new FixedWindowBudget(1, Window, Cap, clock); - budget.TryAcquire("a", out _); + Try(budget, "a", out _); clock.Advance(TimeSpan.FromSeconds(30)); - budget.TryAcquire("b", out _); + Try(budget, "b", out _); clock.Advance(TimeSpan.FromSeconds(30)); // At 60 s a has ended and b has 30 s left. - budget.TryAcquire("c", out _); + Try(budget, "c", out _); Assert.Equal(2, budget.TrackedKeys); - Assert.False(budget.TryAcquire("b", out var wait)); + Assert.False(Try(budget, "b", out var wait)); Assert.Equal(TimeSpan.FromSeconds(30), wait); clock.Advance(TimeSpan.FromSeconds(30) - TimeSpan.FromTicks(1)); - Assert.False(budget.TryAcquire("b", out _)); + Assert.False(Try(budget, "b", out _)); clock.Advance(TimeSpan.FromTicks(1)); - Assert.True(budget.TryAcquire("b", out _)); + Assert.True(Try(budget, "b", out _)); } [Fact] @@ -82,14 +82,14 @@ public sealed class FixedWindowBudgetTests { var clock = new Clock(); var budget = new FixedWindowBudget(1, Window, Cap, clock); - budget.TryAcquire("a", out _); + Try(budget, "a", out _); clock.Advance(TimeSpan.FromSeconds(10)); clock.StepWall(-TimeSpan.FromHours(1)); - Assert.False(budget.TryAcquire("a", out var wait)); + Assert.False(Try(budget, "a", out var wait)); Assert.Equal(TimeSpan.FromSeconds(50), wait); clock.Advance(TimeSpan.FromSeconds(50)); - Assert.True(budget.TryAcquire("a", out _)); + Assert.True(Try(budget, "a", out _)); Assert.Equal(1, budget.TrackedKeys); } @@ -98,52 +98,141 @@ public sealed class FixedWindowBudgetTests { var clock = new Clock(); var budget = new FixedWindowBudget(1, Window, Cap, clock); - budget.TryAcquire("a", out _); + Try(budget, "a", out _); clock.StepWall(TimeSpan.FromHours(1)); - Assert.False(budget.TryAcquire("a", out var wait)); + Assert.False(Try(budget, "a", out var wait)); Assert.Equal(Window, wait); } [Fact] - public void A_new_key_at_the_cap_is_denied_until_the_oldest_window_ends_and_a_held_key_is_never_evicted() + public void A_new_key_at_the_cap_evicts_the_least_recently_used_key_with_permits_left() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(3, Window, 2, clock); + Assert.True(Try(budget, "a", out _)); + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.True(Try(budget, "b", out _)); + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.True(Try(budget, "a", out _)); + + // b was used least recently, though a began first: c takes b's place and a keeps its count. + Assert.True(Try(budget, "c", out _)); + Assert.Equal(2, budget.TrackedKeys); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out _)); + } + + [Fact] + public void A_forgotten_key_regains_only_what_it_had_not_spent_and_a_spent_key_regains_nothing() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 2, clock); + Assert.True(Try(budget, "spent", out _)); + Assert.True(Try(budget, "spent", out _)); + Assert.True(Try(budget, "partial", out _)); + clock.Advance(TimeSpan.FromSeconds(10)); + + // Rotating callers evict partial, never spent. + for (var i = 0; i < 10; i++) + { + Assert.True(Try(budget, "rot" + i, out _)); + } + + Assert.False(Try(budget, "spent", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(50), wait); + Assert.Equal(2, budget.TrackedKeys); + + // partial came back whole (2 permits), which is all a rotating caller can win: its own forgotten budget. + Assert.True(Try(budget, "partial", out _)); + } + + [Fact] + public void A_key_that_spends_its_last_permit_leaves_the_evictable_set() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 2, clock); + Assert.True(Try(budget, "a", out _)); + Assert.True(Try(budget, "a", out _)); + Assert.True(Try(budget, "b", out _)); + + // a is spent and b is not: the newcomer evicts b although a was used less recently. + Assert.True(Try(budget, "c", out _)); + Assert.False(Try(budget, "a", out _)); + Assert.True(Try(budget, "b", out _)); + } + + [Fact] + public void A_table_full_of_spent_keys_refuses_a_new_key_as_table_full_with_the_wait_for_the_oldest_window() { var clock = new Clock(); var budget = new FixedWindowBudget(1, Window, 2, clock); - budget.TryAcquire("a", out _); + Assert.True(Try(budget, "a", out _)); clock.Advance(TimeSpan.FromSeconds(20)); - budget.TryAcquire("b", out _); + Assert.True(Try(budget, "b", out _)); clock.Advance(TimeSpan.FromSeconds(10)); - Assert.False(budget.TryAcquire("c", out var wait)); - Assert.Equal(TimeSpan.FromSeconds(30), wait); + var denial = budget.Spend("c"); + Assert.Equal(new BudgetDenial(TimeSpan.FromSeconds(30), true), denial); + Assert.Equal(new BudgetDenial(TimeSpan.FromSeconds(30), false), budget.Spend("a")); Assert.Equal(2, budget.TrackedKeys); - // Rotating new keys cannot push a spent key out to get its budget back. - for (var i = 0; i < 10; i++) - { - Assert.False(budget.TryAcquire("rot" + i, out _)); - } - - Assert.False(budget.TryAcquire("a", out wait)); - Assert.Equal(TimeSpan.FromSeconds(30), wait); - clock.Advance(TimeSpan.FromSeconds(30)); - Assert.True(budget.TryAcquire("c", out _)); + Assert.True(Try(budget, "c", out _)); Assert.Equal(2, budget.TrackedKeys); - Assert.False(budget.TryAcquire("b", out wait)); + Assert.False(Try(budget, "b", out var wait)); Assert.Equal(TimeSpan.FromSeconds(20), wait); } + [Fact] + public void An_evicted_key_leaves_the_expiry_list_so_its_old_window_end_cannot_remove_the_key_that_took_its_name() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 2, clock); + Try(budget, "a", out _); + clock.Advance(TimeSpan.FromSeconds(1)); + Try(budget, "b", out _); + clock.Advance(TimeSpan.FromSeconds(1)); + Try(budget, "c", out _); + clock.Advance(TimeSpan.FromSeconds(1)); + Try(budget, "a", out _); + + // The first a began at 0 and ended at 60; the a that returned at 3 is still inside its window. + clock.Advance(TimeSpan.FromSeconds(57)); + Try(budget, "c", out _); + + Assert.Equal(2, budget.TrackedKeys); + } + [Fact] public void A_held_key_under_its_permits_is_served_even_when_the_table_is_full() { var clock = new Clock(); var budget = new FixedWindowBudget(2, Window, 1, clock); - Assert.True(budget.TryAcquire("a", out _)); + Assert.True(Try(budget, "a", out _)); - Assert.True(budget.TryAcquire("a", out _)); - Assert.False(budget.TryAcquire("a", out _)); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out _)); + } + + [Fact] + public void Eviction_work_does_not_scan_the_table() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 50_000, clock); + for (var i = 0; i < 50_000; i++) + { + budget.Spend("k" + i); + } + + var started = System.Diagnostics.Stopwatch.StartNew(); + for (var i = 0; i < 200_000; i++) + { + budget.Spend("n" + i); + } + + Assert.Equal(50_000, budget.TrackedKeys); + Assert.True(started.Elapsed < TimeSpan.FromSeconds(10), $"200000 evicting inserts took {started.Elapsed}"); } [Fact] @@ -153,12 +242,12 @@ public sealed class FixedWindowBudgetTests var budget = new FixedWindowBudget(1, Window, Cap, clock); for (var i = 0; i < 100; i++) { - budget.TryAcquire("k" + i, out _); + Try(budget, "k" + i, out _); } Assert.Equal(100, budget.TrackedKeys); clock.Advance(Window); - budget.TryAcquire("fresh", out _); + Try(budget, "fresh", out _); Assert.Equal(1, budget.TrackedKeys); } @@ -180,6 +269,13 @@ public sealed class FixedWindowBudgetTests Assert.Throws(() => new FixedWindowBudget(1, Window, 0, new Clock())); } + private static bool Try(FixedWindowBudget budget, string key, out TimeSpan wait) + { + var denial = budget.Spend(key); + wait = denial?.RetryAfter ?? TimeSpan.Zero; + return denial is null; + } + // Time advances monotonic and wall together; StepWall moves only the wall clock, as an NTP step would. private sealed class Clock : TimeProvider { From 912d0838941406d6949c792bd1847f8d5c358973 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 19:17:27 +0200 Subject: [PATCH 08/40] Fix a string literal broken across lines in the table-full test Co-Authored-By: Claude Sonnet 5.5 --- tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs index 30ab093..46dff22 100644 --- a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -380,8 +380,7 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture string.Join(",", h.Value)))); + Assert.DoesNotContain("203.0.113", body + string.Join(";", visitor.Headers.Select(h => string.Join(",", h.Value)))); Assert.Equal(HttpStatusCode.Redirect, account.StatusCode); Assert.Equal(rowsBefore + 1, (await Rows(collection)).Count); } From a9b9b29bf906e24d539c0f679edf617f9f46b3cc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 19:58:09 +0200 Subject: [PATCH 09/40] Pin the account table's own cap The cap-not-wired-accounts hand mutant survived: no test filled the account table. Co-Authored-By: Claude Sonnet 5.5 --- tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs index 46dff22..1cedf97 100644 --- a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -282,6 +282,8 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture Date: Sun, 11 Oct 2026 20:23:59 +0200 Subject: [PATCH 10/40] Mill votes: the very-busy refusal offers sign-in to a voter who is not signed in Accounts have their own table, so signing in works while the cast or mint table is full. A signed-in voter is not told to sign in. Pinned through the full pipeline at a small cap, for both audiences. Co-Authored-By: Claude Sonnet 5.5 --- src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs | 15 +++++++---- .../ConceptVoteRateLimitTests.cs | 26 ++++++++++++++++++- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index f843f0a..dd87f6d 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -82,7 +82,7 @@ public sealed class VoteModel( return NotFound(); } - if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter), CastRefusal) is { } throttled) + if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter), CastRefusal, voter) is { } throttled) { return throttled; } @@ -105,14 +105,14 @@ public sealed class VoteModel( return NotFound(); } - if (Throttled(limits.DenyCast(client, voter), CastRefusal) is { } throttled) + if (Throttled(limits.DenyCast(client, voter), CastRefusal, voter) is { } throttled) { return throttled; } if (voter is null) { - if (Throttled(limits.DenyMint(client), MintRefusal) is { } mintThrottled) + if (Throttled(limits.DenyMint(client), MintRefusal, voter) is { } mintThrottled) { return mintThrottled; } @@ -143,14 +143,19 @@ public sealed class VoteModel( private const string BusyRefusal = "Voting is very busy right now. Please try again in a little while."; - private IActionResult? Throttled(BudgetDenial? denial, string spentRefusal) + // Accounts have their own table, so a visitor who is not signed in can still vote by signing in while a table is full. + private const string BusyRefusalSignIn = + "Voting is very busy right now. You can sign in to vote now, or try again in a little while."; + + private IActionResult? Throttled(BudgetDenial? denial, string spentRefusal, VoterIdentity? voter) { if (denial is not { } refused) { return null; } - var refusal = refused.TableFull ? BusyRefusal : spentRefusal; + var busy = voter?.UserAccountId is null ? BusyRefusalSignIn : BusyRefusal; + var refusal = refused.TableFull ? busy : spentRefusal; Response.Headers.RetryAfter = ((int)Math.Ceiling(refused.RetryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); return new ContentResult { diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs index 1cedf97..04cdff4 100644 --- a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -381,12 +381,36 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture string.Join(",", h.Value)))); Assert.Equal(HttpStatusCode.Redirect, account.StatusCode); Assert.Equal(rowsBefore + 1, (await Rows(collection)).Count); } + [Fact] + public async Task A_full_account_table_refuses_a_new_account_as_busy_without_offering_sign_in() + { + var factory = Capped(4); + var collection = await NewSubject(); + for (var i = 0; i < 4; i++) + { + var spender = new Client(factory, $"203.0.113.{150 + i}") { Account = ($"72{i:00}", $"spent-account-{i}") }; + for (var cast = 0; cast < 3; cast++) + { + using var ok = await spender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + } + + using var fresh = await new Client(factory, "203.0.113.160") { Account = ("7299", "fresh-signed-in") } + .Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.TooManyRequests, fresh.StatusCode); + var body = await fresh.Content.ReadAsStringAsync(); + Assert.Contains("busy", body); + Assert.DoesNotContain("sign in", body, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task Posts_to_a_missing_subject_spend_no_budget_and_mint_no_device() { From a15648db0487ec61d24b3f08892f54998e134893 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 23:26:49 +0200 Subject: [PATCH 11/40] Pin Npgsql 8.0.3, System.Text.Json 8.0.5, Caching.Memory 8.0.1; enable NuGet lockfiles Co-Authored-By: Claude Sonnet 5.5 --- Directory.Build.props | 7 +++++++ Dockerfile | 7 ++++--- src/Bifrost.Web/Bifrost.Web.csproj | 4 ++++ 3 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 Directory.Build.props diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..1bb3c83 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + + true + + + diff --git a/Dockerfile b/Dockerfile index 4f607e6..45072e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,15 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src -COPY src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/ -RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj +COPY Directory.Build.props . +COPY src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/packages.lock.json src/Bifrost.Web/ +RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj --locked-mode COPY . . RUN dotnet publish src/Bifrost.Web/Bifrost.Web.csproj \ --configuration Release \ --output /app/publish \ - /p:UseAppHost=false + /p:UseAppHost=false /p:RestoreLockedMode=true FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS runtime WORKDIR /app diff --git a/src/Bifrost.Web/Bifrost.Web.csproj b/src/Bifrost.Web/Bifrost.Web.csproj index 09a3ea4..128d84e 100644 --- a/src/Bifrost.Web/Bifrost.Web.csproj +++ b/src/Bifrost.Web/Bifrost.Web.csproj @@ -13,6 +13,10 @@ + + + + From d7d4b21c4befd8797a2849e67a10510b1a6e91ed Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 23:29:46 +0200 Subject: [PATCH 12/40] Commit NuGet lockfiles for the web and test projects Co-Authored-By: Claude Sonnet 5.5 --- src/Bifrost.Web/packages.lock.json | 330 +++++++++++++ tests/Bifrost.Web.Tests/packages.lock.json | 548 +++++++++++++++++++++ 2 files changed, 878 insertions(+) create mode 100644 src/Bifrost.Web/packages.lock.json create mode 100644 tests/Bifrost.Web.Tests/packages.lock.json diff --git a/src/Bifrost.Web/packages.lock.json b/src/Bifrost.Web/packages.lock.json new file mode 100644 index 0000000..0336b92 --- /dev/null +++ b/src/Bifrost.Web/packages.lock.json @@ -0,0 +1,330 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "BouncyCastle.Cryptography": { + "type": "Direct", + "requested": "[2.4.0, )", + "resolved": "2.4.0", + "contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ==" + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "94reKYu63jg4O75UI3LMJHwOSi8tQ6IfubiZhdnSsWcgtmAuF8OyLfjK/MIxuvaQRJZAF6E747FIuxjOtb8/og==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "4.5.0", + "Microsoft.EntityFrameworkCore.Relational": "8.0.0", + "Microsoft.Extensions.DependencyModel": "8.0.0", + "Mono.TextTemplating": "2.2.1" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Direct", + "requested": "[8.0.1, )", + "resolved": "8.0.1", + "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Npgsql": { + "type": "Direct", + "requested": "[8.0.3, )", + "resolved": "8.0.3", + "contentHash": "6WEmzsQJCZAlUG1pThKg/RmeF6V+I0DmBBBE/8YzpRtEzhyZzKcK7ulMANDm5CkxrALBEC8H+5plxHWtIL7xnA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "GDXiMS9peEdjSCU/rfgyHruio7q6tYuywGaktqEi6UPQ6ILechp3fVVX+dHXkIXt4nklCBzYVWkzFrSL9ubKUA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "8.0.0", + "Microsoft.EntityFrameworkCore.Abstractions": "8.0.0", + "Microsoft.EntityFrameworkCore.Relational": "8.0.0", + "Npgsql": "8.0.0" + } + }, + "System.Text.Json": { + "type": "Direct", + "requested": "[8.0.5, )", + "resolved": "8.0.5", + "contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==" + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.3.3", + "contentHash": "j/rOZtLMVJjrfLRlAMckJLPW/1rze9MT1yfWqSIbUPGRu1m1P0fuo9PmqapwsmePfGB5PJrudQLvmUOAMF0DqQ==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "lwAbIZNdnY0SUNoDmZHkVUwLO8UyNnyyh1t/4XsbFxi4Ounb3xszIYZaWhyj5ZjyfcwqwmtMbE7fUTVCqQEIdQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.3.3", + "System.Collections.Immutable": "6.0.0", + "System.Reflection.Metadata": "6.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encoding.CodePages": "6.0.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "cM59oMKAOxvdv76bdmaKPy5hfj+oR+zxikWoueEB7CwTko7mt9sVKZI8Qxlov0C/LuKEG+WQwifepqL3vuTiBQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Common": "[4.5.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "h74wTpmGOp4yS4hj+EvNzEiPgg/KVs2wmSfTZ81upJZOtPkJsVkgfsgtxxqmAeapjT/vLKfmYV0bS8n5MNVP+g==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.CSharp": "[4.5.0]", + "Microsoft.CodeAnalysis.Common": "[4.5.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[4.5.0]" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "l4dDRmGELXG72XZaonnOeORyD/T5RpEu5LGHOUIhnv+MmUWDY/m1kWXGwtcgQ5CJ5ynkFiRnIYzTKXYjUs7rbw==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Bcl.AsyncInterfaces": "6.0.0", + "Microsoft.CodeAnalysis.Common": "[4.5.0]", + "System.Composition": "6.0.0", + "System.IO.Pipelines": "6.0.3", + "System.Threading.Channels": "6.0.0" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "SoODat83pGQUpWB9xULdMX6tuKpq/RTXDuJ2WeC1ldUKcKzLkaFJD1n+I0nOLY58odez/e7z8b6zdp235G/kyg==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "8.0.0", + "Microsoft.EntityFrameworkCore.Analyzers": "8.0.0", + "Microsoft.Extensions.Caching.Memory": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "VR22s3+zoqlVI7xauFKn1znSIFHO8xuILT+noSwS8bZCKcHz0ydkTDQMuaxSa5WBaQrZmwtTz9rmRvJ7X8mSPQ==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ZXxEeLs2zoZ1TA+QoMMcw4f3Tirf8PzgdDax8RoWo0dxI2KmqiEGWYjhm2B/XyWfglc6+mNRyB8rZiQSmxCpeg==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "fFKkr24cYc7Zw5T6DC4tEyOEPgPbq23BBmym1r9kn4ET9F3HKaetpOeQtV2RryYyUxEeNkJuxgfiZHTisqZc+A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "V8S3bsm50ig6JSyrbcJJ8bW2b9QLGouz+G1miK3UTaOWmMtFwNNNzUf4AleyDWUmTrWMLNnFSLEQtxmxgNQnNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "NSmDw3K0ozNDgShSIpsZcbFIzBX4w28nDag+TfaQujkXGazBm+lid5onlWoCBy4VsLxqnnKjEBbGSJVWJMf43g==", + "dependencies": { + "System.Text.Encodings.Web": "8.0.0", + "System.Text.Json": "8.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "tvRkov9tAJ3xP51LCv3FJ2zINmv1P8Hi8lhhtcKGqM+ImiTCC84uOPEI4z8Cdq2C3o9e+Aa0Gw0rmrsJD77W+w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "nroMDjS7hNBPtkZqVBbSiQaQjWRDxITI8Y7XnDs97rqG3EbzVTNLZQf7bIeUJcaHOV8bca47s1Uxq94+2oGdxA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "2.2.1", + "contentHash": "KZYeKBET/2Z0gY1WlTAK7+RHTl7GSbtvTLDXEZZojUdAPqpQNDL6tHv7VUpqfX5VEOh+uRGKaZXkuD253nEOBQ==", + "dependencies": { + "System.CodeDom": "4.4.0" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "2sCCb7doXEwtYAbqzbF/8UAeDRMNmPaQbU2q50Psg1J9KzumyVVCgKQY8s53WIPTufNT0DpSe9QRvVjOzfDWBA==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Composition": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "d7wMuKQtfsxUa7S13tITC8n1cQzewuhD5iDjZtK2prwFfKVzdYtgrTHgjaV03Zq7feGQ5gkP85tJJntXwInsJA==", + "dependencies": { + "System.Composition.AttributedModel": "6.0.0", + "System.Composition.Convention": "6.0.0", + "System.Composition.Hosting": "6.0.0", + "System.Composition.Runtime": "6.0.0", + "System.Composition.TypedParts": "6.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "WK1nSDLByK/4VoC7fkNiFuTVEiperuCN/Hyn+VN30R+W2ijO1d0Z2Qm0ScEl9xkSn1G2MyapJi8xpf4R8WRa/w==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "XYi4lPRdu5bM4JVJ3/UIHAiG6V6lWWUlkhB9ab4IOq0FrRsp0F4wTyV4Dj+Ds+efoXJ3qbLqlvaUozDO7OLeXA==", + "dependencies": { + "System.Composition.AttributedModel": "6.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "w/wXjj7kvxuHPLdzZ0PAUt++qJl03t7lENmb2Oev0n3zbxyNULbWBlnd5J5WUMMv15kg5o+/TCZFb6lSwfaUUQ==", + "dependencies": { + "System.Composition.Runtime": "6.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "qkRH/YBaMPTnzxrS5RDk1juvqed4A6HOD/CwRcDGyPpYps1J27waBddiiq1y93jk2ZZ9wuA/kynM+NO0kb3PKg==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "iUR1eHrL8Cwd82neQCJ00MpwNIBs4NZgXzrPqx8NJf/k4+mwBO0XCRmHYJT4OLSwDDqh5nBLJWkz5cROnrGhRA==", + "dependencies": { + "System.Composition.AttributedModel": "6.0.0", + "System.Composition.Hosting": "6.0.0", + "System.Composition.Runtime": "6.0.0" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "III/lNMSn0ZRBuM9m5Cgbiho5j81u0FAEagFX5ta2DKbljZ3T0IpD8j+BIiHQPeKqJppWS9bGEp6JnKnWKze0g==", + "dependencies": { + "System.Collections.Immutable": "6.0.0" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "ZFCILZuOvtKPauZ/j/swhvw68ZRi9ATCfvGbk1QfydmcXBkIWecWKn/250UH7rahZ5OoDBaiAudJtPvLwzw85A==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==" + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "TY8/9+tI0mNaUMgntOxxaq2ndTkdXqLSxvPmas7XEqOlv9lQtB7wLjYGd756lOaO7Dvb5r/WXhluM+0Xe87v5Q==" + } + } + } +} \ No newline at end of file diff --git a/tests/Bifrost.Web.Tests/packages.lock.json b/tests/Bifrost.Web.Tests/packages.lock.json new file mode 100644 index 0000000..fecc424 --- /dev/null +++ b/tests/Bifrost.Web.Tests/packages.lock.json @@ -0,0 +1,548 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" + }, + "Microsoft.AspNetCore.Mvc.Testing": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "PWPgPAlRdMEkVCHHic/Yy6t37PsPsymY1WkEH0TnjTeIlYu+g8Qfw1axj/ZmNDLvhKdiUNC2E2thHrYf2s7Yhg==", + "dependencies": { + "Microsoft.AspNetCore.TestHost": "8.0.0", + "Microsoft.Extensions.DependencyModel": "8.0.0", + "Microsoft.Extensions.Hosting": "8.0.0" + } + }, + "Microsoft.EntityFrameworkCore.InMemory": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "/pT9FOO0BxGSRscK/ekEb6TdiP3+nnyhPLElE1yuVG/QaZLaBAuM3RoywBHdIxWoFALaOS7ktXlKzuMX3khJ4A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "8.0.0" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.14.1, )", + "resolved": "17.14.1", + "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "dependencies": { + "Microsoft.CodeCoverage": "17.14.1", + "Microsoft.TestPlatform.TestHost": "17.14.1" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.4, )", + "resolved": "3.1.4", + "contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg==" + }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ==" + }, + "Microsoft.AspNetCore.TestHost": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "Rk6Ai9bFf1KubVY5oEbEPN5fiKWW2oeU+easjokyUqqYyTHRsXlkjFeMvwecgoXsoTfXMSwEHzJp8FCjQcyYTQ==", + "dependencies": { + "System.IO.Pipelines": "8.0.0" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "SoODat83pGQUpWB9xULdMX6tuKpq/RTXDuJ2WeC1ldUKcKzLkaFJD1n+I0nOLY58odez/e7z8b6zdp235G/kyg==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "8.0.0", + "Microsoft.EntityFrameworkCore.Analyzers": "8.0.0", + "Microsoft.Extensions.Caching.Memory": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "VR22s3+zoqlVI7xauFKn1znSIFHO8xuILT+noSwS8bZCKcHz0ydkTDQMuaxSa5WBaQrZmwtTz9rmRvJ7X8mSPQ==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ZXxEeLs2zoZ1TA+QoMMcw4f3Tirf8PzgdDax8RoWo0dxI2KmqiEGWYjhm2B/XyWfglc6+mNRyB8rZiQSmxCpeg==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "fFKkr24cYc7Zw5T6DC4tEyOEPgPbq23BBmym1r9kn4ET9F3HKaetpOeQtV2RryYyUxEeNkJuxgfiZHTisqZc+A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "0J/9YNXTMWSZP2p2+nvl8p71zpSwokZXZuJW+VjdErkegAnFdO1XlqtA62SJtgVYHdKu3uPxJHcMR/r35HwFBA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "mBMoXLsr5s1y2zOHWmKsE9veDcx8h1x/c3rz4baEdQKTeDcmQAPNbB54Pi/lhFO3K431eEq6PFbMgLaa6PHFfA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "NZuZMz3Q8Z780nKX3ifV1fE7lS+6pynDHK71OfU4OZ1ItgvDOhyOC7E6z+JMZrAj63zRpwbdldYFk499t3+1dQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "plvZ0ZIpq+97gdPNNvhwvrEZ92kNml9hd1pe3idMA7svR0PztdzVLkoWLcRFgySYXUJc3kSM3Xw3mNFMo/bxRA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "McP+Lz/EKwvtCv48z0YImw+L1gi1gy5rHhNaNIY2CrjloV+XY8gydT8DjMR6zWeL13AFK+DioVpppwAuO1Gi1w==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Physical": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "C2wqUoh9OmRL1akaCcKSTmRU8z0kckfImG7zLNI8uyi47Lp+zd5LWAD17waPQEqCz3ioWOCrFUo+JJuoeZLOBw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.FileExtensions": "8.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "System.Text.Json": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ihDHu2dJYQird9pl2CbdwuNDfvCZdOS0S7SPlNfhPt0B81UTT+yyZKz2pimFZGUp3AfuBRnqUCxB2SjsZKHVUw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.Json": "8.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Physical": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "V8S3bsm50ig6JSyrbcJJ8bW2b9QLGouz+G1miK3UTaOWmMtFwNNNzUf4AleyDWUmTrWMLNnFSLEQtxmxgNQnNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "NSmDw3K0ozNDgShSIpsZcbFIzBX4w28nDag+TfaQujkXGazBm+lid5onlWoCBy4VsLxqnnKjEBbGSJVWJMf43g==", + "dependencies": { + "System.Text.Encodings.Web": "8.0.0", + "System.Text.Json": "8.0.0" + } + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3PZp/YSkIXrF7QK7PfC1bkyRYwqOHpWFad8Qx+4wkuumAeXo1NHaxpS9LboNA9OvNSAu+QOVlXbMyoY+pHSqcw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "JHYCQG7HmugNYUhOl368g+NMxYE/N/AiclCYRNlgCY9eVyiBkOHMwK4x60RYMxv9EL3+rmj1mqHvdCiPpC+D4Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "System.Diagnostics.DiagnosticSource": "8.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ZbaMlhJlpisjuWbvXr4LdAst/1XxH3vZ6A0BsgTphZ2L4PGuxRLz7Jr/S7mkAAnOn78Vu0fKhEgNF5JO3zfjqQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "UboiXxpPUpwulHvIAVE36Knq0VSHaAmfrFkegLyBZeaADuKezJ/AIXYAW8F5GBlGk/VaibN2k/Zn1ca8YAfVdA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.FileSystemGlobbing": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OK+670i7esqlQrPjdIKRbsyMCe9g5kSLpRRQGSr4Q58AOYEe/hCnfLZprh7viNisSUUQZmMrbbuDaIrP+V1ebQ==" + }, + "Microsoft.Extensions.Hosting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ItYHpdqVp5/oFLT5QqbopnkKlyFG9EW/9nhM6/yfObeKt6Su0wkBio6AizgRHGNwhJuAtlE5VIjow5JOTrip6w==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.0", + "Microsoft.Extensions.Configuration.CommandLine": "8.0.0", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "8.0.0", + "Microsoft.Extensions.Configuration.FileExtensions": "8.0.0", + "Microsoft.Extensions.Configuration.Json": "8.0.0", + "Microsoft.Extensions.Configuration.UserSecrets": "8.0.0", + "Microsoft.Extensions.DependencyInjection": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Diagnostics": "8.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Physical": "8.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging.Configuration": "8.0.0", + "Microsoft.Extensions.Logging.Console": "8.0.0", + "Microsoft.Extensions.Logging.Debug": "8.0.0", + "Microsoft.Extensions.Logging.EventLog": "8.0.0", + "Microsoft.Extensions.Logging.EventSource": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AG7HWwVRdCHlaA++1oKDxLsXIBxmDpMPb3VoyOoAghEWnkUvEAdYQUwnV4jJbAaa/nMYNiEh5ByoLauZBEiovg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "tvRkov9tAJ3xP51LCv3FJ2zINmv1P8Hi8lhhtcKGqM+ImiTCC84uOPEI4z8Cdq2C3o9e+Aa0Gw0rmrsJD77W+w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "nroMDjS7hNBPtkZqVBbSiQaQjWRDxITI8Y7XnDs97rqG3EbzVTNLZQf7bIeUJcaHOV8bca47s1Uxq94+2oGdxA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ixXXV0G/12g6MXK65TLngYN9V5hQQRuV+fZi882WIoVJT7h5JvoYoxTEwCgdqwLjSneqh1O+66gM8sMr9z/rsQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "e+48o7DztoYog+PY430lPxrM4mm3PbA6qucvQtUDDwVo4MO+ejMw7YGc/o2rnxbxj4isPxdfKFzTxvXMwAz83A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging.Configuration": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "System.Text.Json": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dt0x21qBdudHLW/bjMJpkixv858RRr8eSomgVbU8qljOyfrfDGi1JQvpF9w8S7ziRPtRKisuWaOwFxJM82GxeA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3X9D3sl7EmOu7vQp5MJrmIJBl5XSdOhZPYXUeFfYa6Nnm9+tok8x3t3IVPLhm7UJtPOU61ohFchw8rNm9tIYOQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "System.Diagnostics.EventLog": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "oKcPMrw+luz2DUAKhwFXrmFikZWnyc8l2RKoQwqU3KIZZjcfoJE0zRHAnqATfhRZhtcbjl/QkiY2Xjxp0xu+6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0", + "System.Text.Json": "8.0.0" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "0f4DMRqEd50zQh+UyJc+/HiBsZ3vhAQALgdkcQEalSH1L2isdC7Yj54M3cyo5e+BeO5fcBQ7Dxly8XiBBcvRgw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.14.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "6WEmzsQJCZAlUG1pThKg/RmeF6V+I0DmBBBE/8YzpRtEzhyZzKcK7ulMANDm5CkxrALBEC8H+5plxHWtIL7xnA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "GDXiMS9peEdjSCU/rfgyHruio7q6tYuywGaktqEi6UPQ6ILechp3fVVX+dHXkIXt4nklCBzYVWkzFrSL9ubKUA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "8.0.0", + "Microsoft.EntityFrameworkCore.Abstractions": "8.0.0", + "Microsoft.EntityFrameworkCore.Relational": "8.0.0", + "Npgsql": "8.0.0" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "fdYxcRjQqTTacKId/2IECojlDSFvp7LP5N78+0z/xH7v/Tuw5ZAxu23Y6PTCRinqyu2ePx+Gn1098NC6jM6d+A==" + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "FHNOatmUq0sqJOkTx+UF/9YK1f180cnW5FVqnQMvYUN0elp6wFzbtPSiqbo1/ru8ICp43JM1i7kKkk6GsNGHlA==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.5", + "contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "bifrost.web": { + "type": "Project", + "dependencies": { + "BouncyCastle.Cryptography": "[2.4.0, )", + "Microsoft.Extensions.Caching.Memory": "[8.0.1, )", + "Npgsql": "[8.0.3, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.0, )", + "System.Text.Json": "[8.0.5, )" + } + } + } + } +} \ No newline at end of file From 83daabe512771a005580d4290a28cbfb275d17e8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 27 Oct 2026 04:57:16 +0100 Subject: [PATCH 13/40] Bifrost as OIDC issuer for the Matrix chat (gamecult-chat cut bifrost-oidc-issuer, squash of 1ef289a..ec4acfa and abd12b3..) One configured client (stale clients removed with their tokens and authorizations), Active membership re-checked at token and userinfo through IsStillActiveMemberAsync, PKCE required by the server option, OpenIddict.Server.AspNetCore + EntityFrameworkCore 7.7.1 with no vulnerable transitives. Soul s2, s3 and s4 hold; Low conformance gaps in gamecult-chat:follow_up:oidc-issuer-spec-conformance. Co-Authored-By: Claude Opus 5.5 --- docs/context.md | 8 +- docs/jurisdiction-map.md | 5 +- docs/oidc-issuer.md | 85 + src/Bifrost.Web/Bifrost.Web.csproj | 2 + .../Configuration/OidcIssuerOptions.cs | 41 + src/Bifrost.Web/Data/BifrostDbContext.cs | 4 + .../20261022163159_AddOpenIddict.Designer.cs | 2441 +++++++++++++++++ .../20261022163159_AddOpenIddict.cs | 166 ++ .../BifrostDbContextModelSnapshot.cs | 246 +- .../Features/Oidc/OidcClientSeeder.cs | 72 + .../Features/Oidc/OidcEndpoints.cs | 142 + .../Features/Oidc/OidcKeyMaterial.cs | 133 + .../Oidc/OidcServiceCollectionExtensions.cs | 73 + src/Bifrost.Web/Program.cs | 13 + src/Bifrost.Web/packages.lock.json | 142 +- .../MigrationDiscoveryTests.cs | 3 +- tests/Bifrost.Web.Tests/OidcIssuerTests.cs | 737 +++++ .../Bifrost.Web.Tests/OidcKeyMaterialTests.cs | 104 + .../Bifrost.Web.Tests/Support/OidcTestKit.cs | 260 ++ tests/Bifrost.Web.Tests/packages.lock.json | 142 +- 20 files changed, 4771 insertions(+), 48 deletions(-) create mode 100644 docs/oidc-issuer.md create mode 100644 src/Bifrost.Web/Configuration/OidcIssuerOptions.cs create mode 100644 src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.Designer.cs create mode 100644 src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.cs create mode 100644 src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs create mode 100644 src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs create mode 100644 src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs create mode 100644 src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs create mode 100644 tests/Bifrost.Web.Tests/OidcIssuerTests.cs create mode 100644 tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs create mode 100644 tests/Bifrost.Web.Tests/Support/OidcTestKit.cs diff --git a/docs/context.md b/docs/context.md index fdcef97..542ff8d 100644 --- a/docs/context.md +++ b/docs/context.md @@ -18,8 +18,10 @@ The product center is the Labor Platform loop: eligible participants express pol - source of deployment and infrastructure conventions - documents Yggdrasil, nginx, systemd, and local-Postgres patterns - `E:\Projects\Heimdall` - - owns OAuth, linked accounts, token custody, signed claims, grants, consent, and revocation - - Bifrost consumes Heimdall identity/capability facts instead of becoming the credential vault + - authenticates: owns OAuth, linked accounts, token custody, signed claims, grants, consent, and revocation + - does not issue GameCult identity: its account id (`HeimdallAccountId`) is an authenticator handle, never who a person is on GameCult + - Bifrost consumes Heimdall authentication/capability facts instead of becoming the credential vault + - remaining scar: Heimdall still mints its own account id in code (`src/app.ts` `createAccount`); chat does not need that to move, so it has not - `E:\Projects\VoidBot` - observes Discord, packages consensus, and runs repo Persona heartbeats - should call Bifrost for governed GitHub/Discord/Reddit/intake crossings instead of becoming hidden transport authority @@ -55,7 +57,7 @@ After bootstrap, the next session should start implementing the first vertical s - member alpha first - invite-only onboarding -- GitHub as the only auth provider in v1 +- Bifrost is the GameCult identity issuer: `UserAccount.Id` is the subject, and Bifrost is the OIDC provider for chat (`docs/oidc-issuer.md`); GitHub, native, and Heimdall-linked sign-in only authenticate into it - GitHub issues and internal tasks both supported - app-native governance and voting - priority and reward pressure should be transparent and auditable diff --git a/docs/jurisdiction-map.md b/docs/jurisdiction-map.md index c0a2754..a251b00 100644 --- a/docs/jurisdiction-map.md +++ b/docs/jurisdiction-map.md @@ -103,6 +103,8 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut - signed identity and capability claims - grants, consent, revocation, and capability evaluation +Heimdall authenticates; it does not issue GameCult identity. Bifrost owns the identity (`UserAccount.Id`, `NormalizedBifrostIdentity`) and issues it to chat as an OIDC provider (`docs/oidc-issuer.md`). `HeimdallAccountId` is an authenticator handle that links a Heimdall account to a Bifrost identity. + Bifrost consumes Heimdall claims. It does not become the key vault. The split is bridge versus gate: Heimdall decides who may cross under which grant; Bifrost moves authorized work across public protocols and records where it landed. ### Patron Collection Crossing @@ -153,7 +155,7 @@ Repo Personas may generate bridge requests. They do not own the bridge itself. | Concern | Owner | Reason | | --- | --- | --- | -| Who is this actor? | Heimdall | Identity and account custody must stay with the auth authority. | +| Who is this actor? | Bifrost (identity) with Heimdall (authentication) | Bifrost issues the identity; account custody and credential proof stay with the auth authority. | | Is this actor allowed to request this crossing? | Heimdall plus Bifrost | Heimdall issues grants; Bifrost applies bridge-domain policy. | | What work is being requested? | Bifrost | The request shape, priority, provenance, and lifecycle are governance concerns. | | How does interest become reward pressure? | Bifrost | Priority, bounty growth, vote weight, and reward allocation are Labor Platform concerns. | @@ -205,6 +207,7 @@ This is not the final hosted control plane. It is the current bridge actuator an - Do not add Bifrost-scoped Discord transport or dispatch receipts to arbitrary repo Persona scripts. Call Bifrost. - Do not add Bifrost-scoped Reddit posting or Persona flair handling to arbitrary repo Persona scripts. Call Bifrost. - Do not put OAuth credentials or account custody into Bifrost helper scripts as the long-term plan. Use Heimdall claims. +- Do not hand Heimdall ids, GitHub ids, or display names out as the GameCult subject. The OIDC `sub` is `UserAccount.Id`, and the localpart is `NormalizedBifrostIdentity`, minted once. - Do not make CultCache documents decide policy. They store packets; Bifrost gives those packets meaning. - Do not let a repo Persona turn a proposal into endless Aquarium pressure once it has enough shape. Route it to Bifrost as a reviewable artifact. - Do not expose bridge debug noise to public channels. Public receipts should say what happened, which Bifrost topic or work item owns it, where it landed, and what comes next. diff --git a/docs/oidc-issuer.md b/docs/oidc-issuer.md new file mode 100644 index 0000000..5698c25 --- /dev/null +++ b/docs/oidc-issuer.md @@ -0,0 +1,85 @@ +# Bifrost OIDC issuer + +Bifrost is the GameCult identity issuer. It runs an OpenID Connect provider on its public host so that Matrix +Authentication Service (MAS) can sign chat users in with their Bifrost account. Heimdall authenticates people into +Bifrost; it does not issue the identity chat sees. + +The provider is OpenIddict (`OpenIddict.AspNetCore` and `OpenIddict.EntityFrameworkCore`, 7.7.1). Bifrost owns two +decisions at the authorize endpoint: who the chat user is, and whether they may get a session. + +## Endpoints + +The issuer is `Host:PublicBaseUrl`, normalized by .NET into an absolute URI. For a bare host that adds a trailing +slash, so for `https://bifrost.gamecult.org` the issuer string is `https://bifrost.gamecult.org/`. Configure MAS with +that exact string. + +| Endpoint | Path | +| --- | --- | +| Discovery | `/.well-known/openid-configuration` | +| JWKS | `/.well-known/jwks` | +| Authorization | `/connect/authorize` | +| Token | `/connect/token` | +| Userinfo | `/connect/userinfo` | + +Only the authorization code flow exists, and PKCE (S256) is required. There is no implicit, hybrid, password, +client-credentials or device flow, no refresh token, and no dynamic client registration. The token endpoint takes +`client_secret_basic` only. Scopes are `openid` and `profile`. + +## The one client + +Exactly one client is registered, from configuration, and seeded or updated at startup; every other application +row (with its authorizations and tokens) is deleted at startup. Any other `client_id`, an unregistered `redirect_uri`, or a request without PKCE is refused by the +provider before any sign-in redirect. + +## Configuration + +The section is `Bifrost:Oidc`. Absent, the provider surface is not mapped at all and discovery answers 404. Present, +it must be complete: an unreadable or wrong-shaped file stops startup with a message that names the setting and never +echoes its value. + +| Setting | Meaning | +| --- | --- | +| `Bifrost:Oidc:SigningKeyPath` | PEM file with an EC P-256 private key. Tokens and id_tokens are signed ES256; JWKS publishes exactly that key. A non-P-256 or public-only key stops startup. | +| `Bifrost:Oidc:EncryptionKeyPath` | File holding 32 random bytes as base64 (`openssl rand -base64 32`). Encrypts the tokens OpenIddict issues, the access token and the parked authorize request among them. | +| `Bifrost:Oidc:Clients:Mas:ClientId` | The client id MAS presents. | +| `Bifrost:Oidc:Clients:Mas:ClientSecretPath` | File holding the client secret. | +| `Bifrost:Oidc:Clients:Mas:RedirectUri` | MAS's callback URL, registered exactly. | + +There are no development or ephemeral keys in the host. Key files are provisioned like the other +`[workload.secret_files]` paths. + +## Claims + +| Claim | Value | +| --- | --- | +| `sub` | `UserAccount.Id`, formatted `D` | +| `preferred_username` | `NormalizedBifrostIdentity` (minted once; there is no rename path) | +| `name` | `DisplayName`, else `BifrostIdentity` | + +No e-mail, no Heimdall id, no GitHub id, and no membership detail leaves Bifrost. + +## Who may sign in + +The authorize endpoint reads the cookie session and the same actor facts the `/App` policy reads. + +- No session: challenge to `/auth/sign-in`, which resumes the original request after sign-in. OpenIddict parks the + full request (its request token, in the OpenIddict token table, expiring after ten minutes, spent when used) and + the return path carries only a short `request_uri`, so `LocalReturnPath.MaxLength` stays 256. +- Signed in, membership not `Active`: redirect to the client's `redirect_uri` with `error=access_denied` and + `error_description=The account is not an active GameCult member.` +- Signed in, `Active`, but `NormalizedBifrostIdentity` empty: `error=access_denied` with + `error_description=The account has no Bifrost identity.` +- Signed in and `Active`: a code is issued. +- Token endpoint: membership is read again; an account no longer `Active` gets `invalid_grant` and no tokens. + +## MAS configuration + +Configure MAS's upstream provider with the issuer above, the client id and secret, the token endpoint auth method +`client_secret_basic`, the id_token signing algorithm `ES256`, scope `openid profile`, and a localpart claim import +from `preferred_username`. MAS mints the Matrix localpart on first sign-in, so the claim must never change for an +account. + +## Remaining scar + +Heimdall still mints its own account id in code (`src/app.ts` `createAccount`). That id is an authenticator handle +and chat does not need it to move. diff --git a/src/Bifrost.Web/Bifrost.Web.csproj b/src/Bifrost.Web/Bifrost.Web.csproj index 128d84e..7cd14b3 100644 --- a/src/Bifrost.Web/Bifrost.Web.csproj +++ b/src/Bifrost.Web/Bifrost.Web.csproj @@ -17,6 +17,8 @@ + + diff --git a/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs b/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs new file mode 100644 index 0000000..1a14457 --- /dev/null +++ b/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs @@ -0,0 +1,41 @@ +namespace Bifrost.Web.Configuration; + +/// +/// The OIDC provider surface (section Bifrost:Oidc). An absent section means the provider is not mapped at +/// all; a present section is complete or startup fails. Keys and the client secret are named by file path, never inlined. +/// +public sealed class OidcIssuerOptions +{ + public const string SectionName = "Bifrost:Oidc"; + + /// PEM file holding the EC P-256 private key that signs tokens (ES256). + public string SigningKeyPath { get; init; } = string.Empty; + + /// File holding the 32-byte symmetric token-encryption key as base64. + public string EncryptionKeyPath { get; init; } = string.Empty; + + public OidcClientsOptions Clients { get; init; } = new(); + + /// Null when the section is absent. + public static OidcIssuerOptions? FromConfiguration(IConfiguration configuration) + { + var section = configuration.GetSection(SectionName); + return section.Exists() ? section.Get() ?? new OidcIssuerOptions() : null; + } +} + +public sealed class OidcClientsOptions +{ + /// Matrix Authentication Service, the one client of this provider. + public OidcClientOptions Mas { get; init; } = new(); +} + +public sealed class OidcClientOptions +{ + public string ClientId { get; init; } = string.Empty; + + /// File holding the client secret. + public string ClientSecretPath { get; init; } = string.Empty; + + public string RedirectUri { get; init; } = string.Empty; +} diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 9863cfd..5596594 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -5,6 +5,10 @@ namespace Bifrost.Web.Data; public sealed class BifrostDbContext(DbContextOptions options) : DbContext(options) { + // The OpenIddict tables are part of this model for every way a context is built: the host, the concepts + // command, tests and the design-time factory that generates migrations. + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseOpenIddict(); + public DbSet UserAccounts => Set(); public DbSet MemberProfiles => Set(); diff --git a/src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.Designer.cs b/src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.Designer.cs new file mode 100644 index 0000000..154c5ae --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.Designer.cs @@ -0,0 +1,2441 @@ +// +using System; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + [DbContext(typeof(BifrostDbContext))] + [Migration("20261022163159_AddOpenIddict")] + partial class AddOpenIddict + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.30") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .IsRequired() + .HasColumnType("text"); + + b.Property("LaunchMode") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("LogPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ResultPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("TargetRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ThreadId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TurnId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkerProcessId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RequestId"); + + b.HasIndex("StartedByUserAccountId"); + + b.HasIndex("Status"); + + b.HasIndex("TargetRepoName", "TargetAgentIdentity", "Status"); + + b.ToTable("AgentDispatchRuns"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentTransportReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TargetAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("TargetRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("OccurredAtUtc"); + + b.HasIndex("RequestId"); + + b.HasIndex("TargetRepoName", "TargetAgentIdentity", "ActivityKind"); + + b.ToTable("AgentTransportReceipts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("Assignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("AuthorityReference") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("BifrostIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpiphanyAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("EpiphanyLaneId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EpiphanyRunId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ExternalReceiptId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("FailureReason") + .IsRequired() + .HasColumnType("text"); + + b.Property("HeimdallCapabilityReference") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReceiptUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetLocator") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TargetSurface") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetSurfaceName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("BifrostIdentity"); + + b.HasIndex("HeimdallCapabilityReference"); + + b.HasIndex("MotionId"); + + b.HasIndex("Status"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("ActorKind", "ActorName"); + + b.HasIndex("SourceKind", "SourceId"); + + b.HasIndex("TargetSurface", "ActionKind", "Status"); + + b.HasIndex("TargetSurface", "TargetSurfaceName", "Status"); + + b.ToTable("BridgeActions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NetworkTag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Stance") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("VoterClass") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("VoterKey") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("NetworkTag", "CastAtUtc"); + + b.HasIndex("SubjectId", "VoterKey") + .IsUnique(); + + b.ToTable("ConceptAppraisals"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.ConceptSubject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Collection") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("RegisteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RetiredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.HasKey("Id"); + + b.HasIndex("Collection", "Key") + .IsUnique(); + + b.ToTable("ConceptSubjects"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.DecayRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RanAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Rate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.HasKey("Id"); + + b.ToTable("DecayRuns"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IssueNumber") + .HasColumnType("integer"); + + b.Property("IssueUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId") + .IsUnique(); + + b.HasIndex("RepositoryFullName", "IssueNumber") + .IsUnique(); + + b.ToTable("GitHubIssueLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsMerged") + .HasColumnType("boolean"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PullRequestNumber") + .HasColumnType("integer"); + + b.Property("PullRequestUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ReviewDecision") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("RepositoryFullName", "PullRequestNumber") + .IsUnique(); + + b.ToTable("GitHubPullRequestLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GovernanceActivityReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorKind") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("CommentId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DispatchRequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("JurisdictionAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("JurisdictionRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TopicId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("OccurredAtUtc"); + + b.HasIndex("TopicId"); + + b.HasIndex("JurisdictionRepoName", "JurisdictionAgentIdentity", "ActivityKind"); + + b.ToTable("GovernanceActivityReceipts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("EffectiveAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryType") + .IsRequired() + .HasColumnType("text"); + + b.Property("NominalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Points") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("LedgerEntries"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Availability") + .IsRequired() + .HasColumnType("text"); + + b.Property("Headline") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("PortfolioUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Skills") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("MemberProfiles"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CanManageLedger") + .HasColumnType("boolean"); + + b.Property("CanManageProjects") + .HasColumnType("boolean"); + + b.Property("CanModerateMotions") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPlatformAdmin") + .HasColumnType("boolean"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("Memberships"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AcceptedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedByUserAccountId"); + + b.HasIndex("IssuedByUserAccountId"); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique(); + + b.ToTable("MembershipInvitations"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalThreshold") + .HasPrecision(6, 3) + .HasColumnType("numeric(6,3)"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClosesAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("OpensAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ResolutionNote") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.ToTable("Motions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("ExternalSupportId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("IsCurrentRecurringSupport") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProviderEventId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProviderPayerId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProviderSubscriptionId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("Provider", "ProviderEventId") + .IsUnique() + .HasFilter("\"ProviderEventId\" <> ''"); + + b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport"); + + b.ToTable("PatronSupportEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.ToTable("PayoutProposalBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDecaying") + .HasColumnType("boolean"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("PointTransactions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GitHubRepository") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("OwnerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GitHubRepository"); + + b.HasIndex("OwnerUserAccountId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("RevenueEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("RevenueEventId") + .HasColumnType("uuid"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("RevenueEventId"); + + b.ToTable("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Basis") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RevenueShareBatchId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RevenueShareBatchId"); + + b.HasIndex("UserAccountId"); + + b.ToTable("RevenueShareLines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("MembershipId", "Role") + .IsUnique(); + + b.ToTable("RoleAssignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapturedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveFromUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("MembershipId", "Kind", "IsCurrent"); + + b.ToTable("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BifrostIdentity") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("GitHubUserId") + .HasColumnType("bigint"); + + b.Property("HeimdallAccountId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedBifrostIdentity") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("GitHubUserId") + .IsUnique() + .HasFilter("\"GitHubUserId\" IS NOT NULL"); + + b.HasIndex("HeimdallAccountId") + .IsUnique() + .HasFilter("\"HeimdallAccountId\" <> ''"); + + b.HasIndex("NormalizedBifrostIdentity") + .IsUnique() + .HasFilter("\"NormalizedBifrostIdentity\" <> ''"); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique() + .HasFilter("\"NormalizedGitHubLogin\" <> ''"); + + b.ToTable("UserAccounts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId", "UserAccountId") + .IsUnique(); + + b.ToTable("VolunteerClaims"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Choice") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("MotionId", "UserAccountId") + .IsUnique(); + + b.ToTable("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedToUserAccountId") + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContributionPoints") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EstimatedHours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ExternalSourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RequestedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("SkillLevel") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RequestedByUserAccountId"); + + b.ToTable("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Hours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkLogs"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReviewerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewerUserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkReviews"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "StartedByUserAccount") + .WithMany("AgentDispatchRuns") + .HasForeignKey("StartedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("StartedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentTransportReceipt", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("AgentTransportReceipts") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Assignments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("Assignments") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("AuditEvents") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("BridgeActions") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany() + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany() + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + + b.Navigation("Motion"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b => + { + b.HasOne("Bifrost.Web.Domain.ConceptSubject", "Subject") + .WithMany() + .HasForeignKey("SubjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", null) + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Subject"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithOne("GitHubIssueLink") + .HasForeignKey("Bifrost.Web.Domain.GitHubIssueLink", "WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("GitHubPullRequestLinks") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GovernanceActivityReceipt", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("GovernanceActivityReceipts") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("LedgerEntries") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("LedgerEntries") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("LedgerEntries") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("MemberProfile") + .HasForeignKey("Bifrost.Web.Domain.MemberProfile", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("Membership") + .HasForeignKey("Bifrost.Web.Domain.Membership", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AcceptedByUserAccount") + .WithMany() + .HasForeignKey("AcceptedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "IssuedByUserAccount") + .WithMany() + .HasForeignKey("IssuedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AcceptedByUserAccount"); + + b.Navigation("IssuedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany("CreatedMotions") + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("Motions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("PatronSupportEvents") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PatronSupportEvents") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("PointTransactions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PointTransactions") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("PointTransactions") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "OwnerUserAccount") + .WithMany("OwnedProjects") + .HasForeignKey("OwnerUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OwnerUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueEvents") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.RevenueEvent", "RevenueEvent") + .WithMany("RevenueShareBatches") + .HasForeignKey("RevenueEventId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("RevenueEvent"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueShareLines") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.RevenueShareBatch", "RevenueShareBatch") + .WithMany("Lines") + .HasForeignKey("RevenueShareBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("RevenueShareBatch"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("RoleAssignments") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("TierSnapshots") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("VolunteerClaims") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("VolunteerClaims") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany("Votes") + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Votes") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Motion"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedToUserAccount") + .WithMany("AssignedWorkItems") + .HasForeignKey("AssignedToUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("WorkItems") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "RequestedByUserAccount") + .WithMany("RequestedWorkItems") + .HasForeignKey("RequestedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AssignedToUserAccount"); + + b.Navigation("Project"); + + b.Navigation("RequestedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewedByUserAccount") + .WithMany() + .HasForeignKey("ReviewedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("WorkLogs") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkLogs") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewerUserAccount") + .WithMany("WorkReviews") + .HasForeignKey("ReviewerUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkReviews") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewerUserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Navigation("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Navigation("LedgerEntries"); + + b.Navigation("Motions"); + + b.Navigation("PatronSupportEvents"); + + b.Navigation("PointTransactions"); + + b.Navigation("RevenueEvents"); + + b.Navigation("RevenueShareLines"); + + b.Navigation("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Navigation("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Navigation("AgentDispatchRuns"); + + b.Navigation("AgentTransportReceipts"); + + b.Navigation("AssignedWorkItems"); + + b.Navigation("Assignments"); + + b.Navigation("AuditEvents"); + + b.Navigation("BridgeActions"); + + b.Navigation("CreatedMotions"); + + b.Navigation("GovernanceActivityReceipts"); + + b.Navigation("LedgerEntries"); + + b.Navigation("MemberProfile"); + + b.Navigation("Membership"); + + b.Navigation("OwnedProjects"); + + b.Navigation("PatronSupportEvents"); + + b.Navigation("PointTransactions"); + + b.Navigation("RequestedWorkItems"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("Votes"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Navigation("Assignments"); + + b.Navigation("GitHubIssueLink"); + + b.Navigation("GitHubPullRequestLinks"); + + b.Navigation("LedgerEntries"); + + b.Navigation("PointTransactions"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.cs b/src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.cs new file mode 100644 index 0000000..64efe36 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.cs @@ -0,0 +1,166 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + /// + public partial class AddOpenIddict : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OpenIddictApplications", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ClientId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + ClientSecret = table.Column(type: "text", nullable: true), + ClientType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ConsentType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + DisplayName = table.Column(type: "text", nullable: true), + DisplayNames = table.Column(type: "text", nullable: true), + JsonWebKeySet = table.Column(type: "text", nullable: true), + Permissions = table.Column(type: "text", nullable: true), + PostLogoutRedirectUris = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + RedirectUris = table.Column(type: "text", nullable: true), + Requirements = table.Column(type: "text", nullable: true), + Settings = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictApplications", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictScopes", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Description = table.Column(type: "text", nullable: true), + Descriptions = table.Column(type: "text", nullable: true), + DisplayName = table.Column(type: "text", nullable: true), + DisplayNames = table.Column(type: "text", nullable: true), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Properties = table.Column(type: "text", nullable: true), + Resources = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictScopes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictAuthorizations", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationId = table.Column(type: "text", nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: true), + Properties = table.Column(type: "text", nullable: true), + Scopes = table.Column(type: "text", nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Subject = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Type = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictAuthorizations", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddictAuthorizations_OpenIddictApplications_Application~", + column: x => x.ApplicationId, + principalTable: "OpenIddictApplications", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictTokens", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationId = table.Column(type: "text", nullable: true), + AuthorizationId = table.Column(type: "text", nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: true), + ExpirationDate = table.Column(type: "timestamp with time zone", nullable: true), + Payload = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + RedemptionDate = table.Column(type: "timestamp with time zone", nullable: true), + ReferenceId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Subject = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Type = table.Column(type: "character varying(150)", maxLength: 150, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictTokens", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddictTokens_OpenIddictApplications_ApplicationId", + column: x => x.ApplicationId, + principalTable: "OpenIddictApplications", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_OpenIddictTokens_OpenIddictAuthorizations_AuthorizationId", + column: x => x.AuthorizationId, + principalTable: "OpenIddictAuthorizations", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictApplications_ClientId", + table: "OpenIddictApplications", + column: "ClientId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictAuthorizations_ApplicationId_Status_Subject_Type", + table: "OpenIddictAuthorizations", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictScopes_Name", + table: "OpenIddictScopes", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_ApplicationId_Status_Subject_Type", + table: "OpenIddictTokens", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_AuthorizationId", + table: "OpenIddictTokens", + column: "AuthorizationId"); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_ReferenceId", + table: "OpenIddictTokens", + column: "ReferenceId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OpenIddictScopes"); + + migrationBuilder.DropTable( + name: "OpenIddictTokens"); + + migrationBuilder.DropTable( + name: "OpenIddictAuthorizations"); + + migrationBuilder.DropTable( + name: "OpenIddictApplications"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index b8c50c5..8c47ed8 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace Bifrost.Web.Data.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("ProductVersion", "8.0.30") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -1616,6 +1616,214 @@ namespace Bifrost.Web.Data.Migrations b.ToTable("WorkReviews"); }); + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => { b.HasOne("Bifrost.Web.Domain.UserAccount", "StartedByUserAccount") @@ -2090,6 +2298,30 @@ namespace Bifrost.Web.Data.Migrations b.Navigation("WorkItem"); }); + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => { b.Navigation("RoleAssignments"); @@ -2188,6 +2420,18 @@ namespace Bifrost.Web.Data.Migrations b.Navigation("WorkReviews"); }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); #pragma warning restore 612, 618 } } diff --git a/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs b/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs new file mode 100644 index 0000000..edf1dd3 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs @@ -0,0 +1,72 @@ +using Bifrost.Web.Configuration; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +/// +/// Makes the provider's one client match configuration at startup, creating it or updating it in place, and removes +/// every other application (with its authorizations and tokens), so a client left by an older configuration cannot get codes. +/// +public sealed class OidcClientSeeder( + IServiceScopeFactory scopeFactory, + OidcIssuerOptions options, + OidcKeyMaterial material, + IOptions serverOptions) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + // Reading the server options now runs their configuration, so a bad issuer stops startup rather than the first request. + _ = serverOptions.Value; + + await using var scope = scopeFactory.CreateAsyncScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + var mas = options.Clients.Mas; + + var descriptor = new OpenIddictApplicationDescriptor + { + ClientId = mas.ClientId, + ClientSecret = material.ClientSecret, + ClientType = ClientTypes.Confidential, + DisplayName = "Matrix Authentication Service", + RedirectUris = { new Uri(mas.RedirectUri, UriKind.Absolute) }, + Permissions = + { + Permissions.Endpoints.Authorization, + Permissions.Endpoints.Token, + Permissions.GrantTypes.AuthorizationCode, + Permissions.ResponseTypes.Code, + Permissions.Scopes.Profile, + Permissions.Prefixes.Scope + Scopes.OpenId + } + }; + + var existing = await manager.FindByClientIdAsync(mas.ClientId, cancellationToken); + if (existing is null) + { + await manager.CreateAsync(descriptor, cancellationToken); + } + else + { + await manager.UpdateAsync(existing, descriptor, cancellationToken); + } + + var applications = new List(); + await foreach (var application in manager.ListAsync(cancellationToken: cancellationToken)) + { + applications.Add(application); + } + + foreach (var application in applications) + { + if (!string.Equals(await manager.GetClientIdAsync(application, cancellationToken), mas.ClientId, StringComparison.Ordinal)) + { + await manager.DeleteAsync(application, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs new file mode 100644 index 0000000..6d164e2 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -0,0 +1,142 @@ +using System.Security.Claims; +using Microsoft.AspNetCore; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +/// +/// The decisions Bifrost owns at the OIDC provider: who the chat user is (sub = UserAccount.Id, +/// preferred_username = NormalizedBifrostIdentity) and whether they may get a session (membership Active). +/// OpenIddict does the protocol; these endpoints read the cookie session and the same actor facts the /App policy reads. +/// +public static class OidcEndpoints +{ + public const string NotActiveDescription = "The account is not an active GameCult member."; + public const string NoIdentityDescription = "The account has no Bifrost identity."; + + private const string OpenIddictScheme = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme; + private const string IdentityAuthenticationType = "Bifrost.Oidc"; + + public static void MapBifrostOidc(this IEndpointRouteBuilder app) + { + app.MapMethods(OidcServiceCollectionExtensions.AuthorizationPath, ["GET", "POST"], (Delegate)AuthorizeAsync).AllowAnonymous(); + app.MapPost(OidcServiceCollectionExtensions.TokenPath, (Delegate)ExchangeAsync).AllowAnonymous(); + app.MapMethods(OidcServiceCollectionExtensions.UserInfoPath, ["GET", "POST"], (Delegate)UserInfoAsync).AllowAnonymous(); + } + + private static async Task AuthorizeAsync(HttpContext http, ICurrentBifrostActorAccessor actors) + { + var request = http.GetOpenIddictServerRequest() + ?? throw new InvalidOperationException("The OpenIddict request could not be retrieved."); + + if (http.User.Identity?.IsAuthenticated != true) + { + // OpenIddict has already replaced the request with a short request_uri, so this URL fits a sign-in + // return path (LocalReturnPath.MaxLength); a test pins that, because Parse would otherwise fall back to /App. + return Results.Challenge( + new AuthenticationProperties { RedirectUri = http.Request.PathBase + http.Request.Path + http.Request.QueryString }, + [CookieAuthenticationDefaults.AuthenticationScheme]); + } + + var actor = await actors.GetAsync(http.RequestAborted); + if (actor.UserAccount is null || !actor.IsActiveMember) + { + return Deny(Errors.AccessDenied, NotActiveDescription); + } + + var account = actor.UserAccount; + if (string.IsNullOrWhiteSpace(account.NormalizedBifrostIdentity)) + { + return Deny(Errors.AccessDenied, NoIdentityDescription); + } + + var identity = new ClaimsIdentity(IdentityAuthenticationType, Claims.Name, Claims.Role); + identity.SetClaim(Claims.Subject, account.Id.ToString("D")); + identity.SetClaim(Claims.PreferredUsername, account.NormalizedBifrostIdentity); + identity.SetClaim( + Claims.Name, + string.IsNullOrWhiteSpace(account.DisplayName) ? account.BifrostIdentity : account.DisplayName); + + var principal = new ClaimsPrincipal(identity); + principal.SetScopes(request.GetScopes()); + principal.SetDestinations(claim => claim.Type switch + { + Claims.Subject => [Destinations.AccessToken, Destinations.IdentityToken], + Claims.PreferredUsername or Claims.Name when principal.HasScope(Scopes.Profile) => + [Destinations.AccessToken, Destinations.IdentityToken], + _ => [Destinations.AccessToken] + }); + + return Results.SignIn(principal, properties: null, OpenIddictScheme); + } + + private static async Task ExchangeAsync(HttpContext http, BifrostDbContext db) + { + // Only the authorization code grant is enabled, so OpenIddict has refused every other grant already. + // The code's own principal carries the claims chosen at authorize time; membership is read again here, + // because the code outlives the authorize decision (a member suspended since gets no tokens). + var result = await http.AuthenticateAsync(OpenIddictScheme); + var subject = result.Principal?.GetClaim(Claims.Subject); + if (!await IsStillActiveMemberAsync(db, subject, http.RequestAborted)) + { + return Deny(Errors.InvalidGrant, NotActiveDescription); + } + + return Results.SignIn(result.Principal!, properties: null, OpenIddictScheme); + } + + private static async Task UserInfoAsync(HttpContext http, BifrostDbContext db) + { + var result = await http.AuthenticateAsync(OpenIddictScheme); + if (!result.Succeeded || result.Principal is null) + { + return Results.Challenge(authenticationSchemes: [OpenIddictScheme]); + } + + // An access token outlives the authorize decision too: a member suspended since is no longer answered. + if (!await IsStillActiveMemberAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted)) + { + return Deny(Errors.InsufficientAccess, NotActiveDescription); + } + + var claims = new Dictionary { [Claims.Subject] = result.Principal.GetClaim(Claims.Subject) }; + if (result.Principal.HasScope(Scopes.Profile)) + { + claims[Claims.PreferredUsername] = result.Principal.GetClaim(Claims.PreferredUsername); + claims[Claims.Name] = result.Principal.GetClaim(Claims.Name); + } + + return Results.Json(claims); + } + + /// The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints. + private static async Task IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) + { + if (!Guid.TryParse(subject, out var accountId)) + { + return false; + } + + var account = await db.UserAccounts.AsNoTracking() + .Include(x => x.Membership) + .SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken); + return new CurrentBifrostActor(true, account).IsActiveMember; + } + + private static IResult Deny(string error, string description) => + Results.Forbid( + new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = error, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description + }), + [OpenIddictScheme]); +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs b/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs new file mode 100644 index 0000000..58f541a --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs @@ -0,0 +1,133 @@ +using System.Security.Cryptography; +using Bifrost.Web.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace Bifrost.Web.Features.Oidc; + +/// A configuration fault in the OIDC provider. The message names the setting, never its value. +public sealed class OidcConfigurationException(string message) : InvalidOperationException(message); + +/// +/// The secrets the OIDC provider runs on, read once from the files names. Any +/// unreadable or wrong-shaped file stops startup; there is no fallback key. +/// +public sealed class OidcKeyMaterial : IDisposable +{ + private const string NistP256Oid = "1.2.840.10045.3.1.7"; + private const int EncryptionKeyBytes = 32; + + private readonly ECDsa _signingEcdsa; + + private OidcKeyMaterial(ECDsa signingEcdsa, SecurityKey signingKey, SecurityKey encryptionKey, string clientSecret) + { + _signingEcdsa = signingEcdsa; + SigningKey = signingKey; + EncryptionKey = encryptionKey; + ClientSecret = clientSecret; + } + + public SecurityKey SigningKey { get; } + + public SecurityKey EncryptionKey { get; } + + public string ClientSecret { get; } + + public static OidcKeyMaterial Load(OidcIssuerOptions options) + { + var mas = options.Clients.Mas; + Require(options.SigningKeyPath, "Bifrost:Oidc:SigningKeyPath"); + Require(options.EncryptionKeyPath, "Bifrost:Oidc:EncryptionKeyPath"); + Require(mas.ClientId, "Bifrost:Oidc:Clients:Mas:ClientId"); + Require(mas.ClientSecretPath, "Bifrost:Oidc:Clients:Mas:ClientSecretPath"); + Require(mas.RedirectUri, "Bifrost:Oidc:Clients:Mas:RedirectUri"); + // A rooted path such as "/callback" parses as an absolute file URI on Linux, so the scheme is checked too. + if (!Uri.TryCreate(mas.RedirectUri, UriKind.Absolute, out var redirect) || + redirect.Scheme is not ("https" or "http")) + { + throw new OidcConfigurationException("Bifrost:Oidc:Clients:Mas:RedirectUri must be an absolute http(s) URI."); + } + + var ecdsa = LoadSigningKey(options.SigningKeyPath); + try + { + var signingKey = new ECDsaSecurityKey(ecdsa) + { + KeyId = Base64UrlEncoder.Encode(SHA256.HashData(ecdsa.ExportSubjectPublicKeyInfo())) + }; + return new OidcKeyMaterial( + ecdsa, + signingKey, + new SymmetricSecurityKey(LoadEncryptionKey(options.EncryptionKeyPath)), + ReadFile(mas.ClientSecretPath, "Bifrost:Oidc:Clients:Mas:ClientSecretPath").Trim()); + } + catch + { + ecdsa.Dispose(); + throw; + } + } + + public void Dispose() => _signingEcdsa.Dispose(); + + private static ECDsa LoadSigningKey(string path) + { + const string field = "Bifrost:Oidc:SigningKeyPath"; + var pem = ReadFile(path, field); + var ecdsa = ECDsa.Create(); + try + { + ecdsa.ImportFromPem(pem); + if (ecdsa.ExportParameters(false).Curve.Oid.Value != NistP256Oid) + { + throw new CryptographicException(); + } + + // ImportFromPem accepts a public key; a signing key needs the private half. + ecdsa.ExportParameters(true); + return ecdsa; + } + catch (Exception error) when (error is CryptographicException or ArgumentException) + { + ecdsa.Dispose(); + throw new OidcConfigurationException($"{field} must name a PEM file holding an EC P-256 private key."); + } + } + + private static byte[] LoadEncryptionKey(string path) + { + const string field = "Bifrost:Oidc:EncryptionKeyPath"; + try + { + var key = Convert.FromBase64String(ReadFile(path, field).Trim()); + if (key.Length == EncryptionKeyBytes) + { + return key; + } + } + catch (FormatException) + { + } + + throw new OidcConfigurationException($"{field} must name a file holding {EncryptionKeyBytes} bytes as base64."); + } + + private static string ReadFile(string path, string field) + { + try + { + return File.ReadAllText(path); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + throw new OidcConfigurationException($"{field} must name a readable file."); + } + } + + private static void Require(string value, string field) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new OidcConfigurationException($"{field} is required when Bifrost:Oidc is configured."); + } + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs new file mode 100644 index 0000000..0f1da67 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs @@ -0,0 +1,73 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +public static class OidcServiceCollectionExtensions +{ + public const string AuthorizationPath = "/connect/authorize"; + public const string TokenPath = "/connect/token"; + public const string UserInfoPath = "/connect/userinfo"; + + /// How long a parked authorize request (OpenIddict's request token) can wait for the user to sign in. + private static readonly TimeSpan ParkedRequestLifetime = TimeSpan.FromMinutes(10); + + /// + /// Registers Bifrost as an OIDC provider whose issuer is : authorization-code flow with + /// PKCE, ES256 id_tokens, scopes openid and profile, one configured client. Keys come from files, never from + /// generated development keys. The authorize request is parked by OpenIddict's own request caching, so the + /// sign-in return path carries only a short request_uri. + /// + public static IServiceCollection AddBifrostOidc(this IServiceCollection services, OidcIssuerOptions options) + { + var material = OidcKeyMaterial.Load(options); + services.AddSingleton(material); + services.AddSingleton(options); + + services.AddOpenIddict() + .AddCore(core => core.UseEntityFrameworkCore().UseDbContext()) + .AddServer(server => + { + server.SetAuthorizationEndpointUris(AuthorizationPath) + .SetTokenEndpointUris(TokenPath) + .SetUserInfoEndpointUris(UserInfoPath); + + server.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange(); + server.RegisterScopes(Scopes.OpenId, Scopes.Profile); + server.RegisterClaims(Claims.Subject, Claims.PreferredUsername, Claims.Name); + server.EnableAuthorizationRequestCaching(); + server.Configure(opts => + { + opts.ClientAuthenticationMethods.Clear(); + opts.ClientAuthenticationMethods.Add(ClientAuthenticationMethods.ClientSecretBasic); + opts.RequestTokenLifetime = ParkedRequestLifetime; + }); + + server.AddSigningKey(material.SigningKey); + server.AddEncryptionKey(material.EncryptionKey); + + server.UseAspNetCore() + .EnableAuthorizationEndpointPassthrough() + .EnableTokenEndpointPassthrough() + .EnableUserInfoEndpointPassthrough(); + }); + + // The issuer is the public host, read from the bound options so it has one source. + services.AddOptions().Configure>((server, host) => + { + if (!Uri.TryCreate(host.Value.PublicBaseUrl, UriKind.Absolute, out var issuer)) + { + throw new OidcConfigurationException("Host:PublicBaseUrl is required when Bifrost:Oidc is configured."); + } + + server.Issuer = issuer; + }); + + services.AddHostedService(); + return services; + } +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index c0f5e40..d0656d3 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -11,6 +11,7 @@ using Bifrost.Web.Features.GitHub; using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Motions; +using Bifrost.Web.Features.Oidc; using Bifrost.Web.Features.Patronage; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Authentication; @@ -225,6 +226,13 @@ builder.Services.AddAuthorization(options => builder.Services.AddConceptTallyCors(); +// The OIDC provider is mapped only when Bifrost:Oidc is configured; a configured provider that cannot load its keys stops startup. +var oidcOptions = OidcIssuerOptions.FromConfiguration(builder.Configuration); +if (oidcOptions is not null) +{ + builder.Services.AddBifrostOidc(oidcOptions); +} + var app = builder.Build(); var appJsonSerializerOptions = CreateAppJsonSerializerOptions(); @@ -267,6 +275,11 @@ app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToke app.MapConceptTallyExport(); +if (oidcOptions is not null) +{ + app.MapBifrostOidc(); +} + app.MapPost("/github/webhooks", async ( HttpRequest request, GitHubWebhookService gitHubWebhookService, diff --git a/src/Bifrost.Web/packages.lock.json b/src/Bifrost.Web/packages.lock.json index 0336b92..bd15130 100644 --- a/src/Bifrost.Web/packages.lock.json +++ b/src/Bifrost.Web/packages.lock.json @@ -55,6 +55,26 @@ "Npgsql": "8.0.0" } }, + "OpenIddict.EntityFrameworkCore": { + "type": "Direct", + "requested": "[7.7.1, )", + "resolved": "7.7.1", + "contentHash": "U+Lq0DuEm9bolWOVur0Qy3LgwzCSjRQKs2Q8tUfFfpuAJsnhgK7j401eDm5PdHV1wnoaBYhXEMkkW+UKeADyaw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "8.0.30", + "OpenIddict.Core": "7.7.1", + "OpenIddict.EntityFrameworkCore.Models": "7.7.1" + } + }, + "OpenIddict.Server.AspNetCore": { + "type": "Direct", + "requested": "[7.7.1, )", + "resolved": "7.7.1", + "contentHash": "YQW64MJBVXsJ+H5kb+HFAQaSXsavOCEG+kDgD+DaYK7apLFtbOrgN233RhHOuztChWcY8SfjpjeN0ZXPwTFsbA==", + "dependencies": { + "OpenIddict.Server": "7.7.1" + } + }, "System.Text.Json": { "type": "Direct", "requested": "[8.0.5, )", @@ -71,6 +91,14 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==", + "dependencies": { + "System.Formats.Asn1": "10.0.2" + } + }, "Microsoft.CodeAnalysis.Analyzers": { "type": "Transitive", "resolved": "3.3.3", @@ -122,31 +150,31 @@ }, "Microsoft.EntityFrameworkCore": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "SoODat83pGQUpWB9xULdMX6tuKpq/RTXDuJ2WeC1ldUKcKzLkaFJD1n+I0nOLY58odez/e7z8b6zdp235G/kyg==", + "resolved": "8.0.30", + "contentHash": "Y6GP16iuqAUkcXSgMqjgao9w0UHSz8x04LRDadsi++sn7yBI9C/sy61xNLzPKM+6DZEbyPRHGq3M58f6DhB25g==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "8.0.0", - "Microsoft.EntityFrameworkCore.Analyzers": "8.0.0", - "Microsoft.Extensions.Caching.Memory": "8.0.0", - "Microsoft.Extensions.Logging": "8.0.0" + "Microsoft.EntityFrameworkCore.Abstractions": "8.0.30", + "Microsoft.EntityFrameworkCore.Analyzers": "8.0.30", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Extensions.Logging": "8.0.1" } }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "VR22s3+zoqlVI7xauFKn1znSIFHO8xuILT+noSwS8bZCKcHz0ydkTDQMuaxSa5WBaQrZmwtTz9rmRvJ7X8mSPQ==" + "resolved": "8.0.30", + "contentHash": "JoVu3708PrMcA3BUeo1+gTcD4nRI8o3g3kwNXwgTPUDhe0dqN2CQmHmEPDD0uZlI1v2oq3F7tR46VycUHzP9ng==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "ZXxEeLs2zoZ1TA+QoMMcw4f3Tirf8PzgdDax8RoWo0dxI2KmqiEGWYjhm2B/XyWfglc6+mNRyB8rZiQSmxCpeg==" + "resolved": "8.0.30", + "contentHash": "+ZVRBR3CzGu0AGWTbewOWTzV+UG//yDrA0gdvb9i1dTidSvwG9qRRggEI9fie0MPC0LAcS9Z8X5HRYWWxefutg==" }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "fFKkr24cYc7Zw5T6DC4tEyOEPgPbq23BBmym1r9kn4ET9F3HKaetpOeQtV2RryYyUxEeNkJuxgfiZHTisqZc+A==", + "resolved": "8.0.30", + "contentHash": "uGWv6fvDUvJDKAYXfVwPVLmit2i6WCmfdzwrHHuw4OJ30s9GDVLsvV62Gi8TIgFSUlTVgSVW/c+hAvDwmY1TDw==", "dependencies": { - "Microsoft.EntityFrameworkCore": "8.0.0", + "Microsoft.EntityFrameworkCore": "8.0.30", "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" } }, @@ -168,10 +196,10 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "V8S3bsm50ig6JSyrbcJJ8bW2b9QLGouz+G1miK3UTaOWmMtFwNNNzUf4AleyDWUmTrWMLNnFSLEQtxmxgNQnNQ==", + "resolved": "8.0.1", + "contentHash": "BmANAnR5Xd4Oqw7yQ75xOAYODybZQRzdeNucg7kS5wWKd2PNnMdYtJ2Vciy0QLylRmv42DGl5+AFL9izA6F1Rw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { @@ -190,12 +218,12 @@ }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "tvRkov9tAJ3xP51LCv3FJ2zINmv1P8Hi8lhhtcKGqM+ImiTCC84uOPEI4z8Cdq2C3o9e+Aa0Gw0rmrsJD77W+w==", + "resolved": "8.0.1", + "contentHash": "4x+pzsQEbqxhNf1QYRr5TDkLP9UsLT3A6MdRKDDEgrW7h1ljiEPgTNhKYUhNCCAaVpQECVQ+onA91PTPnIp6Lw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "8.0.0", - "Microsoft.Extensions.Logging.Abstractions": "8.0.0", - "Microsoft.Extensions.Options": "8.0.0" + "Microsoft.Extensions.DependencyInjection": "8.0.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2" } }, "Microsoft.Extensions.Logging.Abstractions": { @@ -220,6 +248,37 @@ "resolved": "8.0.0", "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "HJbo/lnSfNHUfphPRT910poQc4T2/9+8svFLvzuaYHGAOJ2Tu+oEDqpX0BVP3BJ4OuUM1kylEKyaiX2fCAK3Cw==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "ui3fuBT4fs8kdKfBthI4NzLYIBIneEVS8UrL1JVBzAn80UiKmngBBi0BEByE7n/9c+EElcfFlCMFFTqpkBSLNA==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "r5YLDIxGOnkVJHrqXv/iD1FM1CgGrQOdriXuvuWvTPmKbnGANhEysq9XKmN6IHjf2a+9bAGEpnoRBsAQlvJU5w==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.19.2" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "GtPC1S02uH1gOO4fQ+zRysIicKmEXaYFP8PIkdJYXqMyruYhopre4ozVHp0XiDSA0+GJvOZH9prxCPvgBMg4Ww==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "10.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.IdentityModel.Logging": "8.19.2" + } + }, "Mono.TextTemplating": { "type": "Transitive", "resolved": "2.2.1", @@ -228,6 +287,42 @@ "System.CodeDom": "4.4.0" } }, + "OpenIddict.Abstractions": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "dgb+2dGHzwjVUs7Jc8Dt3PQurVCEllX8XW8+WPmdB1eV5IKRoAZyiJyp3mgFOR1sk/jvilPqnMzQYGkVNVf66Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0", + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "OpenIddict.Core": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "TLk8u4zZ9IvTpTYV+a6nX1eBpnPR5E3seMPGcArXhfBK0VTImWWzPbcdx+GxiPxyq7+8iOg05uIJt7FsRwDpZg==", + "dependencies": { + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Extensions.Logging": "8.0.1", + "Microsoft.Extensions.Options": "8.0.2", + "OpenIddict.Abstractions": "7.7.1" + } + }, + "OpenIddict.EntityFrameworkCore.Models": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "DjJ5/7TBV7q7zB43v7em73nQBIacid5hxtlZzLnuWBg7kzvlBlUvvmgEhW/asoMBT3sR2nEyufB0wKOnn1niXQ==" + }, + "OpenIddict.Server": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "Se5+zNefCR9VpRPudmXOVJ6z79rSdEx+PvlPn1fGvKhLW6cqT5w8q8lPdRKe+zdiyAEXpSg+K5m6lTSpVqYOWA==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.1", + "Microsoft.IdentityModel.JsonWebTokens": "8.19.2", + "OpenIddict.Abstractions": "7.7.1" + } + }, "System.CodeDom": { "type": "Transitive", "resolved": "4.4.0", @@ -289,6 +384,11 @@ "System.Composition.Runtime": "6.0.0" } }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "9Hee6iQ74pDgdRNIL6lO8Vlx2onVWd5aDnqwk4Pvo0h5FklBd7QwcQDKgxeLuhiy0o3ibzUVO0KFQe0/hPXU+Q==" + }, "System.IO.Pipelines": { "type": "Transitive", "resolved": "6.0.3", diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index f4b1077..04401c1 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -21,6 +21,7 @@ public sealed class MigrationDiscoveryTests "20260622202000_AddBridgeActionTargetSurfaceName", "20260705190000_AddPatronSupportProjectAttribution", "20261010043937_AddConceptVotes", + "20261022163159_AddOpenIddict", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. @@ -50,7 +51,7 @@ public sealed class MigrationDiscoveryTests } [Fact] - public void All_thirteen_migrations_are_listed_in_order() + public void All_fourteen_migrations_are_listed_in_order() { Assert.Equal(ExpectedMigrations, Discovered()); } diff --git a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs new file mode 100644 index 0000000..08e8595 --- /dev/null +++ b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs @@ -0,0 +1,737 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Bifrost.Web.Tests; + +public sealed class OidcIssuerTests : IClassFixture +{ + private static readonly Uri Origin = new("https://localhost"); + private readonly OidcWebApplicationFactory _factory; + + public OidcIssuerTests(OidcWebApplicationFactory factory) => _factory = factory; + + [Fact] + public async Task Discovery_advertises_ES256_and_the_code_flow_only() + { + using var client = NewClient(); + var discovery = await client.GetFromJsonAsync("/.well-known/openid-configuration"); + + Assert.Equal(Origin.AbsoluteUri, discovery.GetProperty("issuer").GetString()); + Assert.Equal("/.well-known/jwks", new Uri(discovery.GetProperty("jwks_uri").GetString()!).AbsolutePath); + Assert.Equal(["ES256"], Strings(discovery, "id_token_signing_alg_values_supported")); + Assert.Equal(["code"], Strings(discovery, "response_types_supported")); + Assert.Equal(["authorization_code"], Strings(discovery, "grant_types_supported")); + Assert.Contains("S256", Strings(discovery, "code_challenge_methods_supported")); + Assert.Equal(["client_secret_basic"], Strings(discovery, "token_endpoint_auth_methods_supported")); + Assert.Equal(["openid", "profile"], Strings(discovery, "scopes_supported").Order()); + } + + [Fact] + public async Task Jwks_holds_exactly_the_configured_P256_key() + { + using var client = NewClient(); + var jwks = await GetJwksAsync(client); + + var key = Assert.Single(jwks.GetProperty("keys").EnumerateArray()); + Assert.Equal("EC", key.GetProperty("kty").GetString()); + Assert.Equal("P-256", key.GetProperty("crv").GetString()); + Assert.Equal("ES256", key.GetProperty("alg").GetString()); + + using var ec = ECDsa.Create(); + ec.ImportFromPem(_factory.Kit.SigningPublicKeyPem); + var expected = ec.ExportParameters(false); + Assert.Equal(Base64UrlEncoder.Encode(expected.Q.X), key.GetProperty("x").GetString()); + Assert.Equal(Base64UrlEncoder.Encode(expected.Q.Y), key.GetProperty("y").GetString()); + Assert.False(key.TryGetProperty("d", out _)); + } + + [Fact] + public async Task An_active_member_completes_code_and_pkce_and_gets_the_right_claims() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Ada Lovelace"); + var accountId = _factory.AccountId(identity); + await SetLinksAsync(identity, "heimdall-canary-7", "github-canary-7"); + + var tokens = await CompleteFlowAsync(client); + + var verified = await ValidateIdTokenAsync(tokens.IdToken, tokens.Request.Nonce); + Assert.Equal(accountId.ToString("D"), verified.Claim("sub")); + Assert.Equal(identity, verified.Claim("preferred_username")); + Assert.Equal("Ada Lovelace", verified.Claim("name")); + Assert.Equal(["ES256"], [verified.Alg]); + + // Nothing about the authenticator links, e-mail or membership leaves Bifrost. + Assert.DoesNotContain("heimdall-canary-7", verified.PayloadJson); + Assert.DoesNotContain("github-canary-7", verified.PayloadJson); + Assert.DoesNotContain("email", verified.PayloadJson, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("urn:bifrost", verified.PayloadJson); + + using var userinfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo"); + userinfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + using var userinfoResponse = await NewClient().SendAsync(userinfo); + Assert.Equal(HttpStatusCode.OK, userinfoResponse.StatusCode); + var body = await userinfoResponse.Content.ReadAsStringAsync(); + var info = JsonDocument.Parse(body).RootElement; + Assert.Equal(accountId.ToString("D"), info.GetProperty("sub").GetString()); + Assert.Equal(identity, info.GetProperty("preferred_username").GetString()); + Assert.Equal("Ada Lovelace", info.GetProperty("name").GetString()); + Assert.DoesNotContain("canary", body); + } + + [Fact] + public async Task Sub_and_preferred_username_are_stable_across_display_name_changes() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "First Name"); + + var first = await ValidateIdTokenAsync((await CompleteFlowAsync(client)).IdToken, null); + await _factory.SetDisplayNameAsync(identity, "Second Name"); + var second = await ValidateIdTokenAsync((await CompleteFlowAsync(client)).IdToken, null); + + Assert.Equal(first.Claim("sub"), second.Claim("sub")); + Assert.Equal(first.Claim("preferred_username"), second.Claim("preferred_username")); + Assert.Equal("First Name", first.Claim("name")); + Assert.Equal("Second Name", second.Claim("name")); + } + + [Theory] + [InlineData(MembershipStatus.Authenticated)] + [InlineData(MembershipStatus.PendingApproval)] + [InlineData(MembershipStatus.Suspended)] + public async Task A_member_who_is_not_active_is_refused_with_access_denied_and_no_code(MembershipStatus status) + { + var (client, _) = await SignedInAsync(status, "Not Active"); + var request = NewRequest(); + + var final = await DriveAsync(client, request); + + var redirect = AssertRedirectToClient(final, request.State); + Assert.Equal("access_denied", redirect["error"]); + Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.NotActiveDescription, redirect["error_description"]); + Assert.False(redirect.ContainsKey("code")); + } + + [Fact] + public async Task An_active_account_with_no_bifrost_identity_is_refused_never_given_an_empty_username() + { + using var factory = new OidcNoIdentityFactory(); + using var client = factory.CreateClient(ClientOptions()); + await RegisterAsync(client, $"noid-{Guid.NewGuid():N}"); + var request = NewRequest(); + + var final = await DriveAsync(client, request); + + var redirect = AssertRedirectToClient(final, request.State); + Assert.Equal("access_denied", redirect["error"]); + Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.NoIdentityDescription, redirect["error_description"]); + Assert.False(redirect.ContainsKey("code")); + } + + [Theory] + [InlineData("unknown-client", OidcTestKit.RedirectUri, true)] + [InlineData(OidcTestKit.ClientId, "https://evil.example.test/callback", true)] + [InlineData(OidcTestKit.ClientId, OidcTestKit.RedirectUri, false)] + public async Task Unknown_client_wrong_redirect_uri_and_missing_pkce_are_refused_before_any_sign_in_redirect( + string clientId, string redirectUri, bool pkce) + { + using var client = NewClient(); + var request = NewRequest(clientId, redirectUri, pkce); + + using var response = await client.GetAsync(request.Path); + + var location = response.Headers.Location?.OriginalString ?? string.Empty; + Assert.DoesNotContain("/auth/sign-in", location); + Assert.DoesNotContain("request_uri=", location); + Assert.DoesNotContain("code=", location); + Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task A_mas_sized_authorize_request_survives_sign_in_and_its_handle_is_single_use() + { + var request = NewRequest(extra: "&ui_locales=en&prompt=login&claims_locales=en&max_age=3600"); + Assert.True(request.Path.Length > LocalReturnPath.MaxLength, "the request must be longer than a sign-in return path"); + + using var client = NewClient(); + var signIn = await DriveAsync(client, request); + Assert.Equal(HttpStatusCode.Redirect, signIn.StatusCode); + Assert.Equal("/auth/sign-in", signIn.Headers.Location!.IsAbsoluteUri + ? signIn.Headers.Location.AbsolutePath + : signIn.Headers.Location.OriginalString.Split('?')[0]); + var resume = ReturnUrl(signIn); + Assert.True(resume.Length <= LocalReturnPath.MaxLength, "the resume path must fit the sign-in return path"); + Assert.Equal(resume, LocalReturnPath.Parse(resume).Value); + + var identity = await RegisterAsync(client, $"resume-{Guid.NewGuid():N}"); + await _factory.SetMembershipAsync(identity, MembershipStatus.Active); + + using var resumed = await client.GetAsync(resume); + var redirect = AssertRedirectToClient(resumed, request.State); + Assert.True(redirect.ContainsKey("code"), "the resumed request issues a code"); + + var tokens = await ExchangeAsync(client, request, redirect["code"]!); + var verified = await ValidateIdTokenAsync(tokens.IdToken, request.Nonce); + Assert.Equal(request.Nonce, verified.Claim("nonce")); + + // The handle was spent by the first resume. + using var replay = await client.GetAsync(resume); + var replayRedirect = replay.Headers.Location?.OriginalString ?? string.Empty; + Assert.DoesNotContain("code=", replayRedirect); + Assert.NotEqual(HttpStatusCode.OK, replay.StatusCode); + } + + [Fact] + public async Task A_parked_authorize_request_expires() + { + // Own host: moving the clock must not skew the id_token checks of the other tests. + using var own = new OidcWebApplicationFactory(); + await new OidcIssuerTests(own).ParkedRequestIsRefusedAfterItsLifetimeAsync(); + } + + private async Task ParkedRequestIsRefusedAfterItsLifetimeAsync() + { + // Fixed probe times, not the constant: a lifetime of a day, or of a minute, must show. + var early = NewRequest(); + var late = NewRequest(); + using var client = NewClient(); + var earlyResume = ReturnUrl(await DriveAsync(client, early)); + var lateResume = ReturnUrl(await DriveAsync(client, late)); + var identity = await RegisterAsync(client, $"late-{Guid.NewGuid():N}"); + await _factory.SetMembershipAsync(identity, MembershipStatus.Active); + + _factory.Clock.Advance(TimeSpan.FromMinutes(5)); + using var withinLifetime = await client.GetAsync(earlyResume); + Assert.True(AssertRedirectToClient(withinLifetime, early.State).ContainsKey("code"), "five minutes is within the lifetime"); + + _factory.Clock.Advance(TimeSpan.FromMinutes(25)); + using var expired = await client.GetAsync(lateResume); + var location = expired.Headers.Location?.OriginalString ?? string.Empty; + Assert.DoesNotContain("code=", location); + Assert.NotEqual(HttpStatusCode.OK, expired.StatusCode); + } + + [Fact] + public async Task The_token_endpoint_accepts_client_secret_basic_only() + { + var (client, _) = await SignedInAsync(MembershipStatus.Active, "Basic Only"); + var request = NewRequest(); + var code = await CodeAsync(client, request); + + var wrongSecret = await TokenAsync(client, request, code, secret: "wrong-secret"); + Assert.NotEqual(HttpStatusCode.OK, wrongSecret.StatusCode); + + var asPost = await TokenAsync(client, request, code, secretInBody: true); + Assert.NotEqual(HttpStatusCode.OK, asPost.StatusCode); + } + + [Fact] + public async Task Identity_is_minted_once() + { + using var scope = _factory.Services.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var account = new UserAccount { BifrostIdentity = "Original", NormalizedBifrostIdentity = "original" }; + + await service.EnsureIdentityAsync(account, "someone-else", CancellationToken.None); + + Assert.Equal("original", account.NormalizedBifrostIdentity); + Assert.Equal("Original", account.BifrostIdentity); + } + + [Fact] + public async Task The_openid_scope_alone_releases_the_subject_and_nothing_else() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Scoped Down"); + var request = NewRequest(scope: "openid"); + var code = await CodeAsync(client, request); + + var tokens = await ExchangeAsync(client, request, code); + + var verified = await ValidateIdTokenAsync(tokens.IdToken, request.Nonce); + Assert.Equal(_factory.AccountId(identity).ToString("D"), verified.Claim("sub")); + Assert.Null(verified.Claim("preferred_username")); + Assert.Null(verified.Claim("name")); + + using var userinfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo"); + userinfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + using var response = await NewClient().SendAsync(userinfo); + var info = (await response.Content.ReadFromJsonAsync()).EnumerateObject().Select(x => x.Name).ToArray(); + Assert.Equal(["sub"], info); + } + + [Fact] + public async Task A_blank_display_name_falls_back_to_the_bifrost_identity() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, " "); + + var verified = await ValidateIdTokenAsync((await CompleteFlowAsync(client)).IdToken, null); + + Assert.Equal(identity, verified.Claim("name")); + } + + [Fact] + public async Task Userinfo_without_a_valid_token_is_refused() + { + using var client = NewClient(); + using var none = await client.GetAsync("/connect/userinfo"); + Assert.Equal(HttpStatusCode.Unauthorized, none.StatusCode); + + using var forged = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo"); + forged.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "not-a-token"); + using var forgedResponse = await client.SendAsync(forged); + Assert.Equal(HttpStatusCode.Unauthorized, forgedResponse.StatusCode); + } + + [Fact] + public async Task Startup_makes_the_one_client_match_configuration_again() + { + // Own host: the drift below leaves the application manager's cache stale for every other test. + using var own = new OidcWebApplicationFactory(); + await new OidcIssuerTests(own).SeederRestoresTheClientAsync(); + } + + private async Task SeederRestoresTheClientAsync() + { + using var warm = NewClient(); + await warm.GetAsync("/healthz"); + using var scope = _factory.Services.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + var application = await manager.FindByClientIdAsync(OidcTestKit.ClientId); + Assert.NotNull(application); + + // Someone widened the client in the database; the next start puts configuration back. + var drifted = new OpenIddict.Abstractions.OpenIddictApplicationDescriptor + { + ClientId = OidcTestKit.ClientId, + ClientType = OpenIddict.Abstractions.OpenIddictConstants.ClientTypes.Confidential, + ClientSecret = "drifted-secret", + RedirectUris = { new Uri("https://evil.example.test/callback") } + }; + await manager.UpdateAsync(application, drifted); + var seeders = _factory.Services.GetServices().OfType().ToList(); + var seeder = Assert.Single(seeders); + await seeder.StartAsync(CancellationToken.None); + + // Read the stored row, not through the manager: the manager's cache still holds the drifted instance. + using var fresh = _factory.Services.CreateScope(); + var rows = fresh.ServiceProvider.GetRequiredService() + .Set() + .AsNoTracking().ToList(); + var stored = Assert.Single(rows); + Assert.Equal([OidcTestKit.RedirectUri], (await manager.GetRedirectUrisAsync(stored)).ToArray()); + Assert.True(await manager.ValidateClientSecretAsync(stored, _factory.Kit.ClientSecret)); + Assert.False(await manager.ValidateClientSecretAsync(stored, "drifted-secret")); + } + + [Theory] + [InlineData(MembershipStatus.Authenticated)] + [InlineData(MembershipStatus.PendingApproval)] + [InlineData(MembershipStatus.Suspended)] + [InlineData(null)] + public async Task A_member_no_longer_active_after_authorize_gets_no_tokens(MembershipStatus? status) + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Gone"); + var request = NewRequest(); + var code = await CodeAsync(client, request); + + await LeaveActiveAsync(identity, status); + + using var response = await TokenAsync(client, request, code); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("invalid_grant", body); + Assert.DoesNotContain("id_token", body); + Assert.DoesNotContain("access_token", body); + } + + [Theory] + [InlineData(MembershipStatus.Authenticated)] + [InlineData(MembershipStatus.PendingApproval)] + [InlineData(MembershipStatus.Suspended)] + [InlineData(null)] + public async Task Userinfo_refuses_an_access_token_whose_member_is_no_longer_active(MembershipStatus? status) + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Gone"); + var tokens = await CompleteFlowAsync(client); + + using (var before = await UserInfoAsync(tokens.AccessToken)) + { + Assert.Equal(HttpStatusCode.OK, before.StatusCode); + } + + await LeaveActiveAsync(identity, status); + + using var after = await UserInfoAsync(tokens.AccessToken); + Assert.Equal(HttpStatusCode.Forbidden, after.StatusCode); + Assert.DoesNotContain(identity, await after.Content.ReadAsStringAsync()); + } + + private Task UserInfoAsync(string accessToken) + { + var request = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return NewClient().SendAsync(request); + } + + /// A null status deletes the account instead. + private Task LeaveActiveAsync(string identity, MembershipStatus? status) => + status is { } value ? _factory.SetMembershipAsync(identity, value) : _factory.DeleteAccountAsync(identity); + + [Fact] + public async Task Startup_removes_every_client_but_the_configured_one_with_its_tokens_and_authorizations() + { + // Own host: the stale clients are rows the other tests must not see. + using var own = new OidcCascadeFactory(); + await new OidcIssuerTests(own).StaleClientsAreRemovedAsync(); + } + + private async Task StaleClientsAreRemovedAsync() + { + // Two stale clients, one of them differing from the configured id only in case. + var stale = new[] + { + (Id: "old-mas", Redirect: "https://old.example.test/callback"), + (Id: OidcTestKit.ClientId.ToUpperInvariant(), Redirect: "https://shouting.example.test/callback") + }; + var (client, _) = await SignedInAsync(MembershipStatus.Active, "Member"); + using (var scope = _factory.Services.CreateScope()) + { + var manager = scope.ServiceProvider.GetRequiredService(); + foreach (var (id, redirect) in stale) + { + await manager.CreateAsync(new OpenIddict.Abstractions.OpenIddictApplicationDescriptor + { + ClientId = id, + ClientSecret = "stale-secret", + ClientType = OpenIddict.Abstractions.OpenIddictConstants.ClientTypes.Confidential, + RedirectUris = { new Uri(redirect) }, + Permissions = + { + OpenIddict.Abstractions.OpenIddictConstants.Permissions.Endpoints.Authorization, + OpenIddict.Abstractions.OpenIddictConstants.Permissions.Endpoints.Token, + OpenIddict.Abstractions.OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, + OpenIddict.Abstractions.OpenIddictConstants.Permissions.ResponseTypes.Code, + OpenIddict.Abstractions.OpenIddictConstants.Permissions.Scopes.Profile, + OpenIddict.Abstractions.OpenIddictConstants.Permissions.Prefixes.Scope + OpenIddict.Abstractions.OpenIddictConstants.Scopes.OpenId + } + }); + } + } + + // The control: before seeding every client, stale or configured, really can obtain a code and so holds + // an authorization and a token. + foreach (var (id, redirect) in stale) + { + using var issued = await DriveAsync(client, NewRequest(id, redirect)); + Assert.Contains("code=", issued.Headers.Location?.OriginalString ?? string.Empty); + } + + _ = await CodeAsync(client, NewRequest()); + var staleCounts = Counts(stale.Select(x => x.Id)); + Assert.Equal(stale.Length, staleCounts.Count); + Assert.All(staleCounts, x => Assert.True(x.Tokens > 0 && x.Authorizations > 0, "the control must leave grants to remove")); + var configuredBefore = Counts([OidcTestKit.ClientId]).Single(); + Assert.True(configuredBefore.Tokens > 0 && configuredBefore.Authorizations > 0); + + var seeder = Assert.Single(_factory.Services.GetServices().OfType()); + await seeder.StartAsync(CancellationToken.None); + + foreach (var (id, redirect) in stale) + { + using var refused = await DriveAsync(client, NewRequest(id, redirect)); + Assert.DoesNotContain("code=", refused.Headers.Location?.OriginalString ?? string.Empty); + Assert.NotEqual(HttpStatusCode.OK, refused.StatusCode); + } + + using var fresh = _factory.Services.CreateScope(); + var db = fresh.ServiceProvider.GetRequiredService(); + var rows = db.Set().AsNoTracking().ToList(); + Assert.Equal([OidcTestKit.ClientId], rows.Select(x => x.ClientId).ToArray()); + + // Nothing issued to a removed client survives, and the configured client's grants are untouched. + var keptId = rows.Single().Id; + Assert.Empty(db.Set().AsNoTracking().Where(x => x.Application!.Id != keptId)); + Assert.Empty(db.Set().AsNoTracking().Where(x => x.Application!.Id != keptId)); + Assert.Equal(configuredBefore, Counts([OidcTestKit.ClientId]).Single()); + } + + private sealed record GrantCount(int Tokens, int Authorizations); + + private List Counts(IEnumerable clientIds) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var applications = db.Set().AsNoTracking().ToList(); + return clientIds + .Select(id => applications.Single(x => x.ClientId == id).Id) + .Select(applicationId => new GrantCount( + db.Set().Count(x => x.Application!.Id == applicationId), + db.Set().Count(x => x.Application!.Id == applicationId))) + .ToList(); + } + + [Fact] + public async Task The_issuer_is_the_configured_public_base_url_not_the_request_origin() + { + using var factory = new OidcForeignIssuerFactory(); + using var client = factory.CreateClient(ClientOptions()); + var discovery = await client.GetFromJsonAsync("/.well-known/openid-configuration"); + + Assert.Equal(OidcForeignIssuerFactory.PublicBaseUrl, discovery.GetProperty("issuer").GetString()); + } + + // ---- helpers ---- + + private static WebApplicationFactoryClientOptions ClientOptions() => new() + { + AllowAutoRedirect = false, + HandleCookies = true, + BaseAddress = Origin + }; + + private HttpClient NewClient() => _factory.CreateClient(ClientOptions()); + + private static async Task RegisterAsync(HttpClient client, string identity) + { + using var response = await client.PostAsJsonAsync("/auth/bifrost/register", new { identity, displayName = identity }); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return identity.ToLowerInvariant(); + } + + private async Task<(HttpClient Client, string Identity)> SignedInAsync(MembershipStatus status, string displayName) + { + var client = NewClient(); + var identity = await RegisterAsync(client, $"member-{Guid.NewGuid():N}"); + await _factory.SetMembershipAsync(identity, status); + await _factory.SetDisplayNameAsync(identity, displayName); + return (client, identity); + } + + private async Task SetLinksAsync(string identity, string heimdallAccountId, string gitHubLogin) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity); + account.HeimdallAccountId = heimdallAccountId; + account.GitHubLogin = gitHubLogin; + await db.SaveChangesAsync(); + } + + private sealed record AuthRequest(string Verifier, string State, string Nonce, string Path) + { + public string Challenge => Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(Verifier))); + } + + private static AuthRequest NewRequest( + string clientId = OidcTestKit.ClientId, + string redirectUri = OidcTestKit.RedirectUri, + bool pkce = true, + string extra = "", + string scope = "openid%20profile") + { + var verifier = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48)); + var state = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(24)); + var nonce = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(24)); + var challenge = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + var path = "/connect/authorize?response_type=code" + + $"&client_id={Uri.EscapeDataString(clientId)}" + + $"&redirect_uri={Uri.EscapeDataString(redirectUri)}" + + $"&scope={scope}" + + $"&state={state}&nonce={nonce}" + + (pkce ? $"&code_challenge={challenge}&code_challenge_method=S256" : string.Empty) + + extra; + return new AuthRequest(verifier, state, nonce, path); + } + + /// Starts an authorize request and follows the provider's own redirects until the first response that is not one of them. + private static async Task DriveAsync(HttpClient client, AuthRequest request) + { + var response = await client.GetAsync(request.Path); + for (var hops = 0; hops < 4 && IsAuthorizeRedirect(response); hops++) + { + var next = response.Headers.Location!; + response.Dispose(); + response = await client.GetAsync(next.IsAbsoluteUri ? next.PathAndQuery : next.OriginalString); + } + + return response; + } + + private static bool IsAuthorizeRedirect(HttpResponseMessage response) => + response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found && + response.Headers.Location is { } location && + (location.IsAbsoluteUri ? location.AbsolutePath : location.OriginalString).StartsWith("/connect/authorize", StringComparison.Ordinal); + + private static string ReturnUrl(HttpResponseMessage signInRedirect) + { + var location = signInRedirect.Headers.Location!; + var query = location.IsAbsoluteUri ? location.Query : location.OriginalString[location.OriginalString.IndexOf('?')..]; + return Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(query) + .Single(x => string.Equals(x.Key, "returnUrl", StringComparison.OrdinalIgnoreCase)).Value.ToString(); + } + + private static Dictionary AssertRedirectToClient(HttpResponseMessage response, string state) + { + Assert.True(response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found, $"expected a redirect, got {(int)response.StatusCode}"); + var location = response.Headers.Location!; + Assert.Equal(new Uri(OidcTestKit.RedirectUri).GetLeftPart(UriPartial.Path), location.GetLeftPart(UriPartial.Path)); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(location.Query); + Assert.Equal(state, query["state"].ToString()); + return query.ToDictionary(x => x.Key, x => (string?)x.Value.ToString()); + } + + private async Task CodeAsync(HttpClient signedIn, AuthRequest request) + { + var final = await DriveAsync(signedIn, request); + return AssertRedirectToClient(final, request.State)["code"]!; + } + + private sealed record Tokens(AuthRequest Request, string IdToken, string AccessToken); + + private async Task CompleteFlowAsync(HttpClient signedIn) + { + var request = NewRequest(); + var code = await CodeAsync(signedIn, request); + return await ExchangeAsync(signedIn, request, code); + } + + private async Task ExchangeAsync(HttpClient client, AuthRequest request, string code) + { + using var response = await TokenAsync(client, request, code); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.DoesNotContain("refresh_token", body.EnumerateObject().Select(x => x.Name)); + return new Tokens(request, body.GetProperty("id_token").GetString()!, body.GetProperty("access_token").GetString()!); + } + + private async Task TokenAsync( + HttpClient client, AuthRequest request, string code, string? secret = null, bool secretInBody = false) + { + var form = new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = OidcTestKit.RedirectUri, + ["code_verifier"] = request.Verifier + }; + var message = new HttpRequestMessage(HttpMethod.Post, "/connect/token"); + var effectiveSecret = secret ?? _factory.Kit.ClientSecret; + if (secretInBody) + { + form["client_id"] = OidcTestKit.ClientId; + form["client_secret"] = effectiveSecret; + } + else + { + message.Headers.Authorization = new AuthenticationHeaderValue( + "Basic", + Convert.ToBase64String(Encoding.ASCII.GetBytes($"{OidcTestKit.ClientId}:{effectiveSecret}"))); + } + + message.Content = new FormUrlEncodedContent(form); + // The code is bound to the client; use a cookieless client so a session cannot influence the exchange. + using var bare = NewClient(); + return await bare.SendAsync(message); + } + + private sealed record Verified(string Alg, string PayloadJson, JsonElement Payload) + { + public string? Claim(string name) => Payload.TryGetProperty(name, out var value) ? value.GetString() : null; + } + + private async Task ValidateIdTokenAsync(string idToken, string? nonce) + { + using var client = NewClient(); + var jwks = new JsonWebKeySet((await GetJwksAsync(client)).GetRawText()); + var result = await new JsonWebTokenHandler().ValidateTokenAsync(idToken, new TokenValidationParameters + { + ValidIssuer = Origin.AbsoluteUri, + ValidAudience = OidcTestKit.ClientId, + IssuerSigningKeys = jwks.GetSigningKeys(), + ValidAlgorithms = ["ES256"] + }); + Assert.True(result.IsValid, result.Exception?.Message); + var jwt = (JsonWebToken)result.SecurityToken; + var payload = Encoding.UTF8.GetString(Base64UrlEncoder.DecodeBytes(jwt.EncodedPayload)); + if (nonce is not null) + { + Assert.Equal(nonce, JsonDocument.Parse(payload).RootElement.GetProperty("nonce").GetString()); + } + + return new Verified(jwt.Alg, payload, JsonDocument.Parse(payload).RootElement.Clone()); + } + + private static async Task GetJwksAsync(HttpClient client) + { + var discovery = await client.GetFromJsonAsync("/.well-known/openid-configuration"); + var jwksUri = new Uri(discovery.GetProperty("jwks_uri").GetString()!); + return await client.GetFromJsonAsync(jwksUri.PathAndQuery); + } + + private static string[] Strings(JsonElement document, string property) => + document.GetProperty(property).EnumerateArray().Select(x => x.GetString()!).ToArray(); +} + +public sealed class OidcSurfaceAbsentTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + + public OidcSurfaceAbsentTests(TestWebApplicationFactory factory) => _factory = factory; + + [Theory] + [InlineData("/.well-known/openid-configuration")] + [InlineData("/.well-known/jwks")] + [InlineData("/connect/authorize")] + [InlineData("/connect/token")] + [InlineData("/connect/userinfo")] + public async Task Without_the_configuration_section_no_provider_surface_exists(string path) + { + using var client = _factory.CreateClient(); + using var response = await client.GetAsync(path); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} + +public sealed class OidcStartupFailureTests +{ + [Theory] + [InlineData(OidcKeyFault.MissingFile)] + [InlineData(OidcKeyFault.RsaKey)] + [InlineData(OidcKeyFault.PublicKeyOnly)] + [InlineData(OidcKeyFault.NotPem)] + public void A_configured_provider_with_an_unusable_signing_key_stops_startup_naming_the_setting_not_the_path(OidcKeyFault fault) + { + using var factory = new OidcKeyFaultFactory(fault); + + var failure = Assert.ThrowsAny(() => factory.CreateClient()); + + var messages = Flatten(failure).ToList(); + Assert.Contains(messages, m => m.Contains("Bifrost:Oidc:SigningKeyPath", StringComparison.Ordinal)); + Assert.DoesNotContain(messages, m => m.Contains("canary-", StringComparison.Ordinal)); + Assert.DoesNotContain(messages, m => m.Contains("BEGIN", StringComparison.Ordinal)); + } + + private static IEnumerable Flatten(Exception error) + { + for (var current = error; current is not null; current = current.InnerException!) + { + yield return current.Message; + if (current.InnerException is null) + { + yield break; + } + } + } +} diff --git a/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs b/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs new file mode 100644 index 0000000..a5bc0e0 --- /dev/null +++ b/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs @@ -0,0 +1,104 @@ +using System.Security.Cryptography; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Oidc; +using Bifrost.Web.Tests.Support; +using Microsoft.IdentityModel.Tokens; + +namespace Bifrost.Web.Tests; + +public sealed class OidcKeyMaterialTests : IDisposable +{ + private readonly OidcTestKit _kit = new(); + + public void Dispose() => _kit.Dispose(); + + [Fact] + public void A_complete_configuration_loads_the_signing_key_the_secret_and_the_encryption_key() + { + using var material = OidcKeyMaterial.Load(Options()); + + var signing = Assert.IsType(material.SigningKey); + Assert.False(string.IsNullOrEmpty(signing.KeyId)); + Assert.Equal(_kit.ClientSecret, material.ClientSecret); + Assert.Equal(32, Assert.IsType(material.EncryptionKey).Key.Length); + } + + [Theory] + [InlineData("SigningKeyPath", "Bifrost:Oidc:SigningKeyPath")] + [InlineData("EncryptionKeyPath", "Bifrost:Oidc:EncryptionKeyPath")] + [InlineData("ClientId", "Bifrost:Oidc:Clients:Mas:ClientId")] + [InlineData("ClientSecretPath", "Bifrost:Oidc:Clients:Mas:ClientSecretPath")] + [InlineData("RedirectUri", "Bifrost:Oidc:Clients:Mas:RedirectUri")] + public void A_missing_setting_stops_startup_naming_that_setting(string blank, string expectedField) + { + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options(blank: blank))); + + Assert.Contains(expectedField, failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_redirect_uri_that_is_not_absolute_stops_startup() + { + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options(redirectUri: "/callback"))); + + Assert.Contains("Bifrost:Oidc:Clients:Mas:RedirectUri", failure.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("missing")] + [InlineData("not base64 !!")] + [InlineData("AAAAAAAAAAAAAAAAAAAAAA==")] + public void An_unusable_encryption_key_stops_startup_naming_the_setting_not_the_path(string content) + { + if (content == "missing") + { + File.Delete(_kit.EncryptionKeyPath); + } + else + { + File.WriteAllText(_kit.EncryptionKeyPath, content); + } + + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options())); + + Assert.Contains("Bifrost:Oidc:EncryptionKeyPath", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("canary-", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_missing_client_secret_file_stops_startup_naming_the_setting_not_the_path() + { + File.Delete(_kit.ClientSecretPath); + + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options())); + + Assert.Contains("Bifrost:Oidc:Clients:Mas:ClientSecretPath", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("canary-", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_P384_key_is_not_a_P256_key() + { + using var p384 = ECDsa.Create(ECCurve.NamedCurves.nistP384); + File.WriteAllText(_kit.SigningKeyPath, p384.ExportPkcs8PrivateKeyPem()); + + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options())); + + Assert.Contains("Bifrost:Oidc:SigningKeyPath", failure.Message, StringComparison.Ordinal); + } + + private OidcIssuerOptions Options(string? blank = null, string? redirectUri = null) => new() + { + SigningKeyPath = blank == "SigningKeyPath" ? "" : _kit.SigningKeyPath, + EncryptionKeyPath = blank == "EncryptionKeyPath" ? "" : _kit.EncryptionKeyPath, + Clients = new OidcClientsOptions + { + Mas = new OidcClientOptions + { + ClientId = blank == "ClientId" ? "" : OidcTestKit.ClientId, + ClientSecretPath = blank == "ClientSecretPath" ? "" : _kit.ClientSecretPath, + RedirectUri = blank == "RedirectUri" ? "" : redirectUri ?? OidcTestKit.RedirectUri + } + } + }; +} diff --git a/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs b/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs new file mode 100644 index 0000000..231cd33 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs @@ -0,0 +1,260 @@ +using System.Security.Cryptography; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using OpenIddict.EntityFrameworkCore; +using OpenIddict.EntityFrameworkCore.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Bifrost.Web.Tests.Support; + +/// What is wrong with the signing key file a test host is started with. +public enum OidcKeyFault +{ + None, + MissingFile, + RsaKey, + PublicKeyOnly, + NotPem +} + +/// Key files and client settings for an OIDC test host. Everything is generated per kit; nothing is shared or committed. +public sealed class OidcTestKit : IDisposable +{ + public const string ClientId = "mas-test"; + public const string RedirectUri = "https://chat.example.test/_synapse/client/oidc/callback"; + + public OidcTestKit(OidcKeyFault fault = OidcKeyFault.None) + { + // The directory name is a canary: no error may echo any part of a configured path. + Directory = Path.Combine(Path.GetTempPath(), "canary-" + Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(Directory); + + using var ec = ECDsa.Create(ECCurve.NamedCurves.nistP256); + SigningKeyPem = ec.ExportPkcs8PrivateKeyPem(); + SigningPublicKeyPem = ec.ExportSubjectPublicKeyInfoPem(); + ClientSecret = Convert.ToHexString(RandomNumberGenerator.GetBytes(24)); + + SigningKeyPath = Path.Combine(Directory, "signing.pem"); + EncryptionKeyPath = Path.Combine(Directory, "encryption.key"); + ClientSecretPath = Path.Combine(Directory, "mas.secret"); + + switch (fault) + { + case OidcKeyFault.None: + File.WriteAllText(SigningKeyPath, SigningKeyPem); + break; + case OidcKeyFault.RsaKey: + using (var rsa = RSA.Create(2048)) + { + File.WriteAllText(SigningKeyPath, rsa.ExportPkcs8PrivateKeyPem()); + } + + break; + case OidcKeyFault.PublicKeyOnly: + File.WriteAllText(SigningKeyPath, SigningPublicKeyPem); + break; + case OidcKeyFault.NotPem: + File.WriteAllText(SigningKeyPath, "not a key"); + break; + case OidcKeyFault.MissingFile: + break; + } + + File.WriteAllText(EncryptionKeyPath, Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + "\n"); + File.WriteAllText(ClientSecretPath, ClientSecret + "\n"); + } + + public string Directory { get; } + + public string SigningKeyPath { get; } + + public string EncryptionKeyPath { get; } + + public string ClientSecretPath { get; } + + public string ClientSecret { get; } + + public string SigningKeyPem { get; } + + public string SigningPublicKeyPem { get; } + + public IReadOnlyDictionary Configuration => new Dictionary + { + ["Bifrost:Oidc:SigningKeyPath"] = SigningKeyPath, + ["Bifrost:Oidc:EncryptionKeyPath"] = EncryptionKeyPath, + ["Bifrost:Oidc:Clients:Mas:ClientId"] = ClientId, + ["Bifrost:Oidc:Clients:Mas:ClientSecretPath"] = ClientSecretPath, + ["Bifrost:Oidc:Clients:Mas:RedirectUri"] = RedirectUri + }; + + public void Dispose() + { + try + { + System.IO.Directory.Delete(Directory, recursive: true); + } + catch (IOException) + { + } + } +} + +/// A clock the test moves, so a parked authorize request can be aged past its lifetime. +public sealed class OidcTestClock : TimeProvider +{ + private DateTimeOffset _now = DateTimeOffset.UtcNow; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; +} + +/// +/// A test host with the OIDC provider configured from generated key files and cookie authentication as the real +/// sign-in state (the shared test host signs everyone in as an admin through a header-driven handler instead). +/// +public class OidcWebApplicationFactory : TestWebApplicationFactory +{ + public OidcWebApplicationFactory() : this(OidcKeyFault.None) + { + } + + protected OidcWebApplicationFactory(OidcKeyFault fault) => Kit = new(fault); + + public OidcTestKit Kit { get; } + + public OidcTestClock Clock { get; } = new(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + + // Program decides at registration time whether the provider exists, so these must be host settings: + // configuration added with ConfigureAppConfiguration reaches the app only at Build(). + foreach (var (key, value) in Kit.Configuration) + { + builder.UseSetting(key, value); + } + + builder.ConfigureServices(services => + { + services.Configure(options => + { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; + }); + services.RemoveAll(); + services.AddSingleton(Clock); + ConfigureActors(services); + }); + } + + /// A hook for tests that replace the seam Bifrost reads the signed-in account through. + protected virtual void ConfigureActors(IServiceCollection services) + { + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) + { + Kit.Dispose(); + } + } + + /// Sets the membership of the account registered under . + public async Task SetMembershipAsync(string identity, MembershipStatus status) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity); + db.Memberships.Single(x => x.UserAccountId == account.Id).Status = status; + await db.SaveChangesAsync(); + } + + /// Renames the account's display name, as the owner would between two sign-ins. + public async Task SetDisplayNameAsync(string identity, string displayName) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).DisplayName = displayName; + await db.SaveChangesAsync(); + } + + /// Removes the account (and with it its membership), as an owner deleting themselves would. + public async Task DeleteAccountAsync(string identity) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.UserAccounts.Remove(db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity)); + await db.SaveChangesAsync(); + } + + public Guid AccountId(string identity) + { + using var scope = Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService() + .UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).Id; + } +} + +/// An OIDC host whose signing key file is unusable, so startup must refuse it. +public sealed class OidcKeyFaultFactory(OidcKeyFault fault) : OidcWebApplicationFactory(fault); + +/// An OIDC host whose actor accessor reports an Active account that has no Bifrost identity, which sign-in itself never produces. +public sealed class OidcNoIdentityFactory : OidcWebApplicationFactory +{ + protected override void ConfigureActors(IServiceCollection services) + { + services.RemoveAll(); + services.AddScoped(); + } + + private sealed class ActiveWithoutIdentity : ICurrentBifrostActorAccessor + { + public Task GetAsync(CancellationToken cancellationToken) => + Task.FromResult(new CurrentBifrostActor(true, new UserAccount + { + NormalizedBifrostIdentity = string.Empty, + DisplayName = "No Identity", + Membership = new Bifrost.Web.Domain.Membership { Status = MembershipStatus.Active } + })); + } +} + +/// An OIDC host whose public base URL differs from the origin its requests arrive on, so the issuer cannot be request-derived. +public sealed class OidcForeignIssuerFactory : OidcWebApplicationFactory +{ + public const string PublicBaseUrl = "https://id.example.test/"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary { ["Host:PublicBaseUrl"] = PublicBaseUrl })); + } +} + +/// +/// An OIDC host on OpenIddict's own non-bulk deletion path. The in-memory test database cannot run the bulk +/// ExecuteDelete the library otherwise uses to remove an application's tokens and authorizations, so this switches +/// that option and the library's cascade runs unchanged. +/// +public sealed class OidcCascadeFactory : OidcWebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureServices(services => + services.Configure(options => options.DisableBulkOperations = true)); + } +} diff --git a/tests/Bifrost.Web.Tests/packages.lock.json b/tests/Bifrost.Web.Tests/packages.lock.json index fecc424..e20d6e5 100644 --- a/tests/Bifrost.Web.Tests/packages.lock.json +++ b/tests/Bifrost.Web.Tests/packages.lock.json @@ -68,6 +68,14 @@ "System.IO.Pipelines": "8.0.0" } }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==", + "dependencies": { + "System.Formats.Asn1": "10.0.2" + } + }, "Microsoft.CodeCoverage": { "type": "Transitive", "resolved": "17.14.1", @@ -75,31 +83,31 @@ }, "Microsoft.EntityFrameworkCore": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "SoODat83pGQUpWB9xULdMX6tuKpq/RTXDuJ2WeC1ldUKcKzLkaFJD1n+I0nOLY58odez/e7z8b6zdp235G/kyg==", + "resolved": "8.0.30", + "contentHash": "Y6GP16iuqAUkcXSgMqjgao9w0UHSz8x04LRDadsi++sn7yBI9C/sy61xNLzPKM+6DZEbyPRHGq3M58f6DhB25g==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "8.0.0", - "Microsoft.EntityFrameworkCore.Analyzers": "8.0.0", - "Microsoft.Extensions.Caching.Memory": "8.0.0", - "Microsoft.Extensions.Logging": "8.0.0" + "Microsoft.EntityFrameworkCore.Abstractions": "8.0.30", + "Microsoft.EntityFrameworkCore.Analyzers": "8.0.30", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Extensions.Logging": "8.0.1" } }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "VR22s3+zoqlVI7xauFKn1znSIFHO8xuILT+noSwS8bZCKcHz0ydkTDQMuaxSa5WBaQrZmwtTz9rmRvJ7X8mSPQ==" + "resolved": "8.0.30", + "contentHash": "JoVu3708PrMcA3BUeo1+gTcD4nRI8o3g3kwNXwgTPUDhe0dqN2CQmHmEPDD0uZlI1v2oq3F7tR46VycUHzP9ng==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "ZXxEeLs2zoZ1TA+QoMMcw4f3Tirf8PzgdDax8RoWo0dxI2KmqiEGWYjhm2B/XyWfglc6+mNRyB8rZiQSmxCpeg==" + "resolved": "8.0.30", + "contentHash": "+ZVRBR3CzGu0AGWTbewOWTzV+UG//yDrA0gdvb9i1dTidSvwG9qRRggEI9fie0MPC0LAcS9Z8X5HRYWWxefutg==" }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "fFKkr24cYc7Zw5T6DC4tEyOEPgPbq23BBmym1r9kn4ET9F3HKaetpOeQtV2RryYyUxEeNkJuxgfiZHTisqZc+A==", + "resolved": "8.0.30", + "contentHash": "uGWv6fvDUvJDKAYXfVwPVLmit2i6WCmfdzwrHHuw4OJ30s9GDVLsvV62Gi8TIgFSUlTVgSVW/c+hAvDwmY1TDw==", "dependencies": { - "Microsoft.EntityFrameworkCore": "8.0.0", + "Microsoft.EntityFrameworkCore": "8.0.30", "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" } }, @@ -203,10 +211,10 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "V8S3bsm50ig6JSyrbcJJ8bW2b9QLGouz+G1miK3UTaOWmMtFwNNNzUf4AleyDWUmTrWMLNnFSLEQtxmxgNQnNQ==", + "resolved": "8.0.1", + "contentHash": "BmANAnR5Xd4Oqw7yQ75xOAYODybZQRzdeNucg7kS5wWKd2PNnMdYtJ2Vciy0QLylRmv42DGl5+AFL9izA6F1Rw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { @@ -309,12 +317,12 @@ }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "tvRkov9tAJ3xP51LCv3FJ2zINmv1P8Hi8lhhtcKGqM+ImiTCC84uOPEI4z8Cdq2C3o9e+Aa0Gw0rmrsJD77W+w==", + "resolved": "8.0.1", + "contentHash": "4x+pzsQEbqxhNf1QYRr5TDkLP9UsLT3A6MdRKDDEgrW7h1ljiEPgTNhKYUhNCCAaVpQECVQ+onA91PTPnIp6Lw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "8.0.0", - "Microsoft.Extensions.Logging.Abstractions": "8.0.0", - "Microsoft.Extensions.Options": "8.0.0" + "Microsoft.Extensions.DependencyInjection": "8.0.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2" } }, "Microsoft.Extensions.Logging.Abstractions": { @@ -414,6 +422,37 @@ "resolved": "8.0.0", "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "HJbo/lnSfNHUfphPRT910poQc4T2/9+8svFLvzuaYHGAOJ2Tu+oEDqpX0BVP3BJ4OuUM1kylEKyaiX2fCAK3Cw==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "ui3fuBT4fs8kdKfBthI4NzLYIBIneEVS8UrL1JVBzAn80UiKmngBBi0BEByE7n/9c+EElcfFlCMFFTqpkBSLNA==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "r5YLDIxGOnkVJHrqXv/iD1FM1CgGrQOdriXuvuWvTPmKbnGANhEysq9XKmN6IHjf2a+9bAGEpnoRBsAQlvJU5w==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.19.2" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "GtPC1S02uH1gOO4fQ+zRysIicKmEXaYFP8PIkdJYXqMyruYhopre4ozVHp0XiDSA0+GJvOZH9prxCPvgBMg4Ww==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "10.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.IdentityModel.Logging": "8.19.2" + } + }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", "resolved": "17.14.1", @@ -455,6 +494,60 @@ "Npgsql": "8.0.0" } }, + "OpenIddict.Abstractions": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "dgb+2dGHzwjVUs7Jc8Dt3PQurVCEllX8XW8+WPmdB1eV5IKRoAZyiJyp3mgFOR1sk/jvilPqnMzQYGkVNVf66Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0", + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "OpenIddict.Core": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "TLk8u4zZ9IvTpTYV+a6nX1eBpnPR5E3seMPGcArXhfBK0VTImWWzPbcdx+GxiPxyq7+8iOg05uIJt7FsRwDpZg==", + "dependencies": { + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Extensions.Logging": "8.0.1", + "Microsoft.Extensions.Options": "8.0.2", + "OpenIddict.Abstractions": "7.7.1" + } + }, + "OpenIddict.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "U+Lq0DuEm9bolWOVur0Qy3LgwzCSjRQKs2Q8tUfFfpuAJsnhgK7j401eDm5PdHV1wnoaBYhXEMkkW+UKeADyaw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "8.0.30", + "OpenIddict.Core": "7.7.1", + "OpenIddict.EntityFrameworkCore.Models": "7.7.1" + } + }, + "OpenIddict.EntityFrameworkCore.Models": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "DjJ5/7TBV7q7zB43v7em73nQBIacid5hxtlZzLnuWBg7kzvlBlUvvmgEhW/asoMBT3sR2nEyufB0wKOnn1niXQ==" + }, + "OpenIddict.Server": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "Se5+zNefCR9VpRPudmXOVJ6z79rSdEx+PvlPn1fGvKhLW6cqT5w8q8lPdRKe+zdiyAEXpSg+K5m6lTSpVqYOWA==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.1", + "Microsoft.IdentityModel.JsonWebTokens": "8.19.2", + "OpenIddict.Abstractions": "7.7.1" + } + }, + "OpenIddict.Server.AspNetCore": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "YQW64MJBVXsJ+H5kb+HFAQaSXsavOCEG+kDgD+DaYK7apLFtbOrgN233RhHOuztChWcY8SfjpjeN0ZXPwTFsbA==", + "dependencies": { + "OpenIddict.Server": "7.7.1" + } + }, "System.Collections.Immutable": { "type": "Transitive", "resolved": "8.0.0", @@ -470,6 +563,11 @@ "resolved": "8.0.0", "contentHash": "fdYxcRjQqTTacKId/2IECojlDSFvp7LP5N78+0z/xH7v/Tuw5ZAxu23Y6PTCRinqyu2ePx+Gn1098NC6jM6d+A==" }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "9Hee6iQ74pDgdRNIL6lO8Vlx2onVWd5aDnqwk4Pvo0h5FklBd7QwcQDKgxeLuhiy0o3ibzUVO0KFQe0/hPXU+Q==" + }, "System.IO.Pipelines": { "type": "Transitive", "resolved": "8.0.0", @@ -540,6 +638,8 @@ "Microsoft.Extensions.Caching.Memory": "[8.0.1, )", "Npgsql": "[8.0.3, )", "Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.0, )", + "OpenIddict.EntityFrameworkCore": "[7.7.1, )", + "OpenIddict.Server.AspNetCore": "[7.7.1, )", "System.Text.Json": "[8.0.5, )" } } From 404dafdd401333862e364de48cc90185f543c37a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 00:38:15 +0100 Subject: [PATCH 14/40] OIDC back-channel logout: a reconciler ends the sessions of subjects who are not Active OidcLogoutReconciler derives pending logouts from valid OpenIddict authorizations x membership, POSTs one signed logout token per non-Active subject to MAS, and revokes the authorizations only after a 2xx. Registered only when Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri is set. The active-member reading is the endpoints' existing one, now shared. Co-Authored-By: Claude Opus 5.5 --- .../Configuration/OidcIssuerOptions.cs | 3 + .../Features/Oidc/OidcEndpoints.cs | 4 +- .../Features/Oidc/OidcKeyMaterial.cs | 7 + .../Features/Oidc/OidcLogoutReconciler.cs | 204 +++++++++++++ .../Oidc/OidcServiceCollectionExtensions.cs | 6 + .../Bifrost.Web.Tests/OidcKeyMaterialTests.cs | 29 +- tests/Bifrost.Web.Tests/OidcLogoutTests.cs | 278 ++++++++++++++++++ .../Support/OidcLogoutKit.cs | 221 ++++++++++++++ 8 files changed, 748 insertions(+), 4 deletions(-) create mode 100644 src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs create mode 100644 tests/Bifrost.Web.Tests/OidcLogoutTests.cs create mode 100644 tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs diff --git a/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs b/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs index 1a14457..13ba192 100644 --- a/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs +++ b/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs @@ -38,4 +38,7 @@ public sealed class OidcClientOptions public string ClientSecretPath { get; init; } = string.Empty; public string RedirectUri { get; init; } = string.Empty; + + /// MAS's back-channel logout URL for this provider. Empty: no logout is sent and no session is ended. + public string BackchannelLogoutUri { get; init; } = string.Empty; } diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index 6d164e2..b435347 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -117,8 +117,8 @@ public static class OidcEndpoints return Results.Json(claims); } - /// The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints. - private static async Task IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) + /// The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints and the logout reconciler. + internal static async Task IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) { if (!Guid.TryParse(subject, out var accountId)) { diff --git a/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs b/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs index 58f541a..8f0be75 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs @@ -47,6 +47,13 @@ public sealed class OidcKeyMaterial : IDisposable throw new OidcConfigurationException("Bifrost:Oidc:Clients:Mas:RedirectUri must be an absolute http(s) URI."); } + if (!string.IsNullOrWhiteSpace(mas.BackchannelLogoutUri) && + (!Uri.TryCreate(mas.BackchannelLogoutUri, UriKind.Absolute, out var logout) || + logout.Scheme is not ("https" or "http"))) + { + throw new OidcConfigurationException("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri must be an absolute http(s) URI."); + } + var ecdsa = LoadSigningKey(options.SigningKeyPath); try { diff --git a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs new file mode 100644 index 0000000..ba88f48 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs @@ -0,0 +1,204 @@ +using System.Net; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +/// +/// Ends the chat sessions of a subject who is no longer an Active member. Each tick it lists the subjects holding valid +/// authorizations of the configured client; for each one the shared active-member reading refuses, it POSTs one signed +/// OIDC back-channel logout token to MAS and, only after a 2xx, revokes that subject's authorizations. The +/// authorizations table is the queue: nothing is stored, so a restart, a MAS outage or a later-set URI resumes from it. +/// Per-subject backoff lives in memory only. It never writes membership. +/// +public sealed class OidcLogoutReconciler( + IServiceScopeFactory scopeFactory, + OidcIssuerOptions options, + OidcKeyMaterial material, + IOptions serverOptions, + IHttpClientFactory httpClients, + TimeProvider time, + ILogger logger) : BackgroundService +{ + public const string HttpClientName = "bifrost-oidc-logout"; + public const string LogoutEvent = "http://schemas.openid.net/event/backchannel-logout"; + public const string LogoutTokenType = "logout+jwt"; + + public static readonly TimeSpan Interval = TimeSpan.FromSeconds(30); + public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); + public static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(2); + public static readonly TimeSpan MaxBackoff = TimeSpan.FromMinutes(15); + + private readonly Dictionary _backoff = new(StringComparer.Ordinal); + + private sealed record Backoff(int Failures, DateTimeOffset NotBefore); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ReconcileOnceAsync(stoppingToken); + await Task.Delay(Interval, time, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception error) + { + // The type only: messages can carry connection strings or URLs. + logger.LogError("Back-channel logout reconcile failed: {ErrorType}", error.GetType().Name); + try + { + await Task.Delay(Interval, time, stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + } + } + } + + /// One reconcile pass. Subjects are independent: one that cannot be delivered never blocks another. + public async Task ReconcileOnceAsync(CancellationToken cancellationToken) + { + var subjects = await ListSubjectsAsync(cancellationToken); + foreach (var gone in _backoff.Keys.Except(subjects).ToArray()) + { + _backoff.Remove(gone); + } + + foreach (var subject in subjects) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_backoff.TryGetValue(subject, out var wait) && time.GetUtcNow() < wait.NotBefore) + { + continue; + } + + await ReconcileSubjectAsync(subject, cancellationToken); + } + } + + private async Task> ListSubjectsAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var applications = scope.ServiceProvider.GetRequiredService(); + var authorizations = scope.ServiceProvider.GetRequiredService(); + + var subjects = new HashSet(StringComparer.Ordinal); + var application = await applications.FindByClientIdAsync(options.Clients.Mas.ClientId, cancellationToken); + if (application is null || await applications.GetIdAsync(application, cancellationToken) is not { } applicationId) + { + return subjects; + } + + await foreach (var authorization in authorizations.FindByApplicationIdAsync(applicationId, cancellationToken)) + { + if (await authorizations.HasStatusAsync(authorization, Statuses.Valid, cancellationToken) && + await authorizations.GetSubjectAsync(authorization, cancellationToken) is { Length: > 0 } subject) + { + subjects.Add(subject); + } + } + + return subjects; + } + + private async Task ReconcileSubjectAsync(string subject, CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (await OidcEndpoints.IsStillActiveMemberAsync(db, subject, cancellationToken)) + { + _backoff.Remove(subject); + return; + } + + var failures = _backoff.TryGetValue(subject, out var previous) ? previous.Failures + 1 : 1; + var status = await SendLogoutAsync(subject, cancellationToken); + if (status is { } code && (int)code is >= 200 and < 300) + { + var authorizations = scope.ServiceProvider.GetRequiredService(); + await foreach (var authorization in authorizations.FindBySubjectAsync(subject, cancellationToken)) + { + if (await authorizations.HasStatusAsync(authorization, Statuses.Valid, cancellationToken)) + { + await authorizations.TryRevokeAsync(authorization, cancellationToken); + } + } + + _backoff.Remove(subject); + return; + } + + // 400 (token refused) and 404 (unknown provider) are configuration faults: retrying sooner cannot fix them. + var fault = status is HttpStatusCode.BadRequest or HttpStatusCode.NotFound; + var delay = fault ? MaxBackoff : BackoffFor(failures); + _backoff[subject] = new Backoff(failures, time.GetUtcNow() + delay); + logger.LogWarning( + "Back-channel logout to MAS failed with {Status}; retrying in {Delay}", + status is { } s ? (int)s : "no response", + delay); + } + + internal static TimeSpan BackoffFor(int failures) + { + var seconds = Interval.TotalSeconds * Math.Pow(2, Math.Min(failures, 16) - 1); + return TimeSpan.FromSeconds(Math.Min(seconds, MaxBackoff.TotalSeconds)); + } + + /// The status MAS answered, or null on a network fault or timeout. The response body is never read. + private async Task SendLogoutAsync(string subject, CancellationToken cancellationToken) + { + var token = BuildLogoutToken(subject); + using var request = new HttpRequestMessage(HttpMethod.Post, options.Clients.Mas.BackchannelLogoutUri) + { + Content = new FormUrlEncodedContent([new KeyValuePair("logout_token", token)]) + }; + try + { + using var response = await httpClients.CreateClient(HttpClientName) + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + return response.StatusCode; + } + catch (Exception error) when (error is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + return null; + } + } + + /// + /// The OIDC back-channel logout token MAS verifies like an id_token: iss and aud as the id_token carries them, exp + /// and iat both present, an events member, and neither nonce nor sid (MAS finds the session by sub). + /// + private string BuildLogoutToken(string subject) + { + var now = time.GetUtcNow(); + var descriptor = new SecurityTokenDescriptor + { + TokenType = LogoutTokenType, + Issuer = serverOptions.Value.Issuer!.AbsoluteUri, + Audience = options.Clients.Mas.ClientId, + IssuedAt = now.UtcDateTime, + Expires = now.UtcDateTime + TokenLifetime, + SigningCredentials = new SigningCredentials(material.SigningKey, SecurityAlgorithms.EcdsaSha256), + Claims = new Dictionary + { + [Claims.Subject] = Guid.TryParse(subject, out var id) ? id.ToString("D") : subject, + [Claims.JwtId] = Guid.NewGuid().ToString("D"), + ["events"] = new Dictionary { [LogoutEvent] = new Dictionary() } + } + }; + return new JsonWebTokenHandler().CreateToken(descriptor); + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs index 0f1da67..0e3bbf1 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs @@ -68,6 +68,12 @@ public static class OidcServiceCollectionExtensions }); services.AddHostedService(); + if (!string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri)) + { + services.AddHttpClient(OidcLogoutReconciler.HttpClientName, client => client.Timeout = OidcLogoutReconciler.RequestTimeout); + services.AddHostedService(); + } + return services; } } diff --git a/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs b/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs index a5bc0e0..73d5200 100644 --- a/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs +++ b/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs @@ -87,7 +87,31 @@ public sealed class OidcKeyMaterialTests : IDisposable Assert.Contains("Bifrost:Oidc:SigningKeyPath", failure.Message, StringComparison.Ordinal); } - private OidcIssuerOptions Options(string? blank = null, string? redirectUri = null) => new() + [Theory] + [InlineData("not a uri")] + [InlineData("/upstream/backchannel-logout/01ABC")] + [InlineData("ftp://mas.example.test/upstream/backchannel-logout/01ABC")] + public void A_back_channel_logout_uri_that_is_not_absolute_http_stops_startup_naming_the_setting_not_the_value(string uri) + { + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options(logoutUri: uri))); + + Assert.Contains("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain(uri, failure.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("https://mas.example.test/upstream/backchannel-logout/01ABC")] + [InlineData("http://mas.internal/upstream/backchannel-logout/01ABC")] + public void An_absent_or_absolute_http_back_channel_logout_uri_loads(string uri) + { + using var material = OidcKeyMaterial.Load(Options(logoutUri: uri)); + + Assert.NotNull(material.SigningKey); + } + + private OidcIssuerOptions Options(string? blank = null, string? redirectUri = null, string logoutUri = "") => new() { SigningKeyPath = blank == "SigningKeyPath" ? "" : _kit.SigningKeyPath, EncryptionKeyPath = blank == "EncryptionKeyPath" ? "" : _kit.EncryptionKeyPath, @@ -97,7 +121,8 @@ public sealed class OidcKeyMaterialTests : IDisposable { ClientId = blank == "ClientId" ? "" : OidcTestKit.ClientId, ClientSecretPath = blank == "ClientSecretPath" ? "" : _kit.ClientSecretPath, - RedirectUri = blank == "RedirectUri" ? "" : redirectUri ?? OidcTestKit.RedirectUri + RedirectUri = blank == "RedirectUri" ? "" : redirectUri ?? OidcTestKit.RedirectUri, + BackchannelLogoutUri = logoutUri } } }; diff --git a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs new file mode 100644 index 0000000..3b37f21 --- /dev/null +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -0,0 +1,278 @@ +using System.Text.Json; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Oidc; +using Bifrost.Web.Tests.Support; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; + +namespace Bifrost.Web.Tests; + +public sealed class OidcLogoutTests +{ + private static readonly TimeSpan[] ExpectedBackoff = + [30, 60, 120, 240, 480, 900, 900].Select(x => TimeSpan.FromSeconds(x)).ToArray(); + + [Fact] + public async Task Probe_authorizations_carry_the_subject_and_a_valid_status_after_a_code_flow() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(); + + Assert.True(factory.ValidAuthorizations(factory.AccountId(identity).ToString("D")) > 0); + using var scope = factory.Services.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + var found = new List(); + await foreach (var authorization in manager.FindBySubjectAsync(factory.AccountId(identity).ToString("D"))) + { + found.Add(authorization); + } + + Assert.NotEmpty(found); + Assert.True(await manager.TryRevokeAsync(found[0])); + } + + [Theory] + [InlineData(MembershipStatus.Suspended)] + [InlineData(MembershipStatus.PendingApproval)] + [InlineData(MembershipStatus.Authenticated)] + public async Task A_member_who_is_not_active_gets_one_signed_logout_and_loses_the_authorizations(MembershipStatus status) + { + using var factory = new OidcLogoutFactory(); + var (identity, idToken) = await factory.GrantAsync(status); + var subject = factory.AccountId(identity).ToString("D"); + var reconciler = factory.Reconciler; + + await reconciler.ReconcileOnceAsync(CancellationToken.None); + + var token = Assert.Single(factory.Mas.LogoutTokens()); + var result = await new JsonWebTokenHandler().ValidateTokenAsync(token, new TokenValidationParameters + { + ValidIssuer = OidcLogoutFactory.Origin.AbsoluteUri, + ValidAudience = OidcTestKit.ClientId, + IssuerSigningKeys = (await factory.JwksAsync()).GetSigningKeys(), + ValidAlgorithms = ["ES256"], + ValidTypes = ["logout+jwt"], + ValidateLifetime = false + }); + Assert.True(result.IsValid, result.Exception?.Message); + var jwt = (JsonWebToken)result.SecurityToken; + Assert.Equal("logout+jwt", jwt.Typ); + Assert.Equal(new JsonWebToken(idToken).Issuer, jwt.Issuer); + Assert.Equal(subject, jwt.Subject); + Assert.Equal(TimeSpan.FromMinutes(2), jwt.ValidTo - jwt.IssuedAt); + Assert.Equal(factory.Time.GetUtcNow().ToUnixTimeSeconds(), new DateTimeOffset(jwt.IssuedAt).ToUnixTimeSeconds()); + Assert.False(string.IsNullOrEmpty(jwt.Id)); + var payload = JsonDocument.Parse(Base64UrlEncoder.Decode(jwt.EncodedPayload)).RootElement; + var events = payload.GetProperty("events"); + Assert.Equal(["http://schemas.openid.net/event/backchannel-logout"], events.EnumerateObject().Select(x => x.Name).ToArray()); + Assert.Equal(JsonValueKind.Object, events.EnumerateObject().Single().Value.ValueKind); + Assert.False(payload.TryGetProperty("nonce", out _)); + Assert.False(payload.TryGetProperty("sid", out _)); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Single(factory.Mas.Bodies); + } + + [Fact] + public async Task Two_logouts_for_one_subject_carry_different_jti() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + factory.Mas.Script(500, 200); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + factory.Time.Advance(TimeSpan.FromSeconds(30)); + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + var ids = factory.Mas.LogoutTokens().Select(x => new JsonWebToken(x).Id).ToArray(); + Assert.Equal(2, ids.Length); + Assert.NotEqual(ids[0], ids[1]); + Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(identity).ToString("D"))); + } + + [Fact] + public async Task An_active_member_is_never_sent_and_keeps_the_authorizations() + { + using var factory = new OidcLogoutFactory(); + var (suspended, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var (active, _) = await factory.GrantAsync(MembershipStatus.Active); + var activeSubject = factory.AccountId(active).ToString("D"); + var before = factory.ValidAuthorizations(activeSubject); + Assert.True(before > 0); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + var token = Assert.Single(factory.Mas.LogoutTokens()); + Assert.Equal(factory.AccountId(suspended).ToString("D"), new JsonWebToken(token).Subject); + Assert.Equal(before, factory.ValidAuthorizations(activeSubject)); + } + + [Fact] + public async Task One_subject_that_cannot_be_delivered_does_not_block_another() + { + using var factory = new OidcLogoutFactory(); + var (first, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var (second, _) = await factory.GrantAsync(MembershipStatus.Suspended); + factory.Mas.Script(500); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + Assert.Equal(2, factory.Mas.Bodies.Count); + var delivered = new[] { first, second }.Count(x => factory.ValidAuthorizations(factory.AccountId(x).ToString("D")) == 0); + Assert.Equal(1, delivered); + } + + [Fact] + public async Task Delivery_retries_with_exponential_capped_backoff_and_revokes_only_after_a_2xx() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + factory.Mas.Script(500, 500, 500, 500, 500, 500, 500, 200); + var reconciler = factory.Reconciler; + + var posts = 0; + foreach (var delay in ExpectedBackoff) + { + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(++posts, factory.Mas.Bodies.Count); + Assert.True(factory.ValidAuthorizations(subject) > 0, "a failed delivery must not revoke"); + + factory.Time.Advance(delay - TimeSpan.FromSeconds(1)); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(posts, factory.Mas.Bodies.Count); + + factory.Time.Advance(TimeSpan.FromSeconds(1)); + } + + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(8, factory.Mas.Bodies.Count); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + } + + [Theory] + [InlineData(FakeMasHandler.NetworkFault)] + [InlineData(FakeMasHandler.Timeout)] + [InlineData(302)] + [InlineData(401)] + public async Task A_network_fault_timeout_or_unexpected_status_keeps_the_authorizations_and_retries(int fault) + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + factory.Mas.Script(fault); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.True(factory.ValidAuthorizations(subject) > 0); + factory.Time.Advance(TimeSpan.FromSeconds(30)); + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + Assert.Equal(2, factory.Mas.Bodies.Count); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + } + + [Fact] + public async Task A_restarted_reconciler_resumes_the_pending_subject_from_the_table() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + factory.Mas.Script(500); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Single(factory.Mas.Bodies); + await factory.NewReconciler().ReconcileOnceAsync(CancellationToken.None); + + Assert.Equal(2, factory.Mas.Bodies.Count); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + } + + [Theory] + [InlineData(400)] + [InlineData(404)] + public async Task A_400_or_404_is_a_fault_not_success_and_backs_off_at_the_cap(int status) + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + factory.Mas.Script(status, status); + var reconciler = factory.Reconciler; + + await reconciler.ReconcileOnceAsync(CancellationToken.None); + factory.Time.Advance(OidcLogoutReconciler.MaxBackoff - TimeSpan.FromSeconds(1)); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Single(factory.Mas.Bodies); + Assert.True(factory.ValidAuthorizations(subject) > 0); + + factory.Time.Advance(TimeSpan.FromSeconds(1)); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(2, factory.Mas.Bodies.Count); + factory.Time.Advance(OidcLogoutReconciler.MaxBackoff - TimeSpan.FromSeconds(1)); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(2, factory.Mas.Bodies.Count); + Assert.True(factory.ValidAuthorizations(subject) > 0); + } + + [Fact] + public async Task A_subject_that_is_not_an_account_id_is_treated_as_not_active() + { + using var factory = new OidcLogoutFactory(); + var (active, _) = await factory.GrantAsync(MembershipStatus.Active); + using (var scope = factory.Services.CreateScope()) + { + var applications = scope.ServiceProvider.GetRequiredService(); + var authorizations = scope.ServiceProvider.GetRequiredService(); + var application = (await applications.FindByClientIdAsync(OidcTestKit.ClientId))!; + await authorizations.CreateAsync(new OpenIddictAuthorizationDescriptor + { + ApplicationId = await applications.GetIdAsync(application), + Subject = "not-a-guid", + Status = OpenIddictConstants.Statuses.Valid, + Type = OpenIddictConstants.AuthorizationTypes.AdHoc + }); + } + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + var token = Assert.Single(factory.Mas.LogoutTokens()); + Assert.Equal("not-a-guid", new JsonWebToken(token).Subject); + Assert.Equal(0, factory.ValidAuthorizations("not-a-guid")); + Assert.True(factory.ValidAuthorizations(factory.AccountId(active).ToString("D")) > 0); + } + + [Fact] + public async Task A_deleted_account_is_treated_as_not_active() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Active); + var subject = factory.AccountId(identity).ToString("D"); + await factory.DeleteAccountAsync(identity); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + Assert.Equal(subject, new JsonWebToken(Assert.Single(factory.Mas.LogoutTokens())).Subject); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + } + + [Fact] + public void Without_a_back_channel_logout_uri_the_reconciler_is_not_hosted_and_nothing_is_revoked() + { + using var factory = new OidcWebApplicationFactory(); + using var client = factory.CreateClient(); + + Assert.Empty(factory.Services.GetServices().OfType()); + } + + [Fact] + public void The_reconciler_is_hosted_when_the_uri_is_set() + { + using var factory = new OidcLogoutFactory(); + using var client = factory.CreateClient(); + + Assert.NotNull(factory.Reconciler); + } +} diff --git a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs new file mode 100644 index 0000000..b04ded9 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs @@ -0,0 +1,221 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Oidc; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.EntityFrameworkCore.Models; +using OpenIddict.Server; + +namespace Bifrost.Web.Tests.Support; + +/// A clock the test moves; its timers never fire, so the hosted reconciler only ticks when a test calls it. +public sealed class ManualClock : TimeProvider +{ + private DateTimeOffset _now = DateTimeOffset.UtcNow; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) => + base.CreateTimer(callback, state, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); +} + +/// +/// A stand-in for MAS's back-channel logout route: answers a scripted status per POST (then 200), records every form +/// body, and can fail like the network does. +/// +public sealed class FakeMasHandler : HttpMessageHandler +{ + public const int NetworkFault = -1; + public const int Timeout = -2; + + private readonly Queue _script = new(); + private readonly List _bodies = []; + + public void Script(params int[] statuses) + { + lock (_script) + { + foreach (var status in statuses) + { + _script.Enqueue(status); + } + } + } + + public IReadOnlyList Bodies + { + get + { + lock (_bodies) + { + return _bodies.ToArray(); + } + } + } + + /// The logout_tokens posted, in order; asserts each body is exactly one logout_token field. + public IReadOnlyList LogoutTokens() => Bodies.Select(body => + { + var form = QueryHelpers.ParseQuery(body); + Assert.Equal(["logout_token"], form.Keys.ToArray()); + return form["logout_token"].ToString(); + }).ToArray(); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("application/x-www-form-urlencoded", request.Content!.Headers.ContentType!.MediaType); + var body = await request.Content.ReadAsStringAsync(cancellationToken); + lock (_bodies) + { + _bodies.Add(body); + } + + int status; + lock (_script) + { + status = _script.Count > 0 ? _script.Dequeue() : 200; + } + + return status switch + { + NetworkFault => throw new HttpRequestException("network down"), + Timeout => throw new TaskCanceledException("timed out"), + _ => new HttpResponseMessage((HttpStatusCode)status) { Content = new StringContent("mas-body-canary") } + }; + } +} + +/// An OIDC host with MAS's back-channel logout URI set, a fake MAS behind it and a clock the test drives. +public sealed class OidcLogoutFactory : OidcWebApplicationFactory +{ + public const string LogoutUri = "https://mas.example.test/upstream/backchannel-logout/01TESTPROVIDER"; + public static readonly Uri Origin = new("https://localhost"); + + public FakeMasHandler Mas { get; } = new(); + + public ManualClock Time { get; } = new(); + + public OidcLogoutReconciler Reconciler => Services.GetServices().OfType().Single(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.UseSetting("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri", LogoutUri); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(Time); + services.AddHttpClient(OidcLogoutReconciler.HttpClientName).ConfigurePrimaryHttpMessageHandler(() => Mas); + }); + } + + /// A reconciler with no memory, built over this host's database, as a restarted process would have. + public OidcLogoutReconciler NewReconciler() => new( + Services.GetRequiredService(), + Services.GetRequiredService(), + Services.GetRequiredService(), + Services.GetRequiredService>(), + Services.GetRequiredService(), + Time, + NullLogger.Instance); + + public WebApplicationFactoryClientOptions ClientOptions() => new() + { + AllowAutoRedirect = false, + HandleCookies = true, + BaseAddress = Origin + }; + + /// Registers a member, sets , and completes one authorization-code flow for them. + public async Task<(string Identity, string IdToken)> GrantAsync(MembershipStatus status = MembershipStatus.Active) + { + using var client = CreateClient(ClientOptions()); + var identity = $"member-{Guid.NewGuid():N}"; + using (var register = await client.PostAsJsonAsync("/auth/bifrost/register", new { identity, displayName = identity })) + { + Assert.Equal(HttpStatusCode.OK, register.StatusCode); + } + + await SetMembershipAsync(identity, MembershipStatus.Active); + + var verifier = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48)); + var state = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(24)); + var challenge = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + var response = await client.GetAsync( + "/connect/authorize?response_type=code" + + $"&client_id={Uri.EscapeDataString(OidcTestKit.ClientId)}" + + $"&redirect_uri={Uri.EscapeDataString(OidcTestKit.RedirectUri)}" + + $"&scope=openid%20profile&state={state}&nonce=n-{state}" + + $"&code_challenge={challenge}&code_challenge_method=S256"); + for (var hops = 0; hops < 4 && IsAuthorizeRedirect(response); hops++) + { + var next = response.Headers.Location!; + response.Dispose(); + response = await client.GetAsync(next.IsAbsoluteUri ? next.PathAndQuery : next.OriginalString); + } + + var code = QueryHelpers.ParseQuery(response.Headers.Location!.Query)["code"].ToString(); + response.Dispose(); + + using var bare = CreateClient(ClientOptions()); + using var exchange = new HttpRequestMessage(HttpMethod.Post, "/connect/token") + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = OidcTestKit.RedirectUri, + ["code_verifier"] = verifier + }) + }; + exchange.Headers.Authorization = new AuthenticationHeaderValue( + "Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{OidcTestKit.ClientId}:{Kit.ClientSecret}"))); + using var tokens = await bare.SendAsync(exchange); + Assert.Equal(HttpStatusCode.OK, tokens.StatusCode); + var idToken = (await tokens.Content.ReadFromJsonAsync()).GetProperty("id_token").GetString()!; + + if (status != MembershipStatus.Active) + { + await SetMembershipAsync(identity, status); + } + + return (identity, idToken); + } + + private static bool IsAuthorizeRedirect(HttpResponseMessage response) => + response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found && + response.Headers.Location is { } location && + (location.IsAbsoluteUri ? location.AbsolutePath : location.OriginalString).StartsWith("/connect/authorize", StringComparison.Ordinal); + + /// How many of the subject's authorizations are still valid. + public int ValidAuthorizations(string subject) + { + using var scope = Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService() + .Set().AsNoTracking() + .Count(x => x.Subject == subject && x.Status == "valid"); + } + + public async Task JwksAsync() + { + using var client = CreateClient(ClientOptions()); + return new JsonWebKeySet(await client.GetStringAsync("/.well-known/jwks")); + } +} From 69b670e3afd2e739998bdef8a99f8d892e0f04e4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 00:38:56 +0100 Subject: [PATCH 15/40] Fix logout test support compile errors Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/OidcLogoutTests.cs | 2 +- tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs index 3b37f21..ed9416e 100644 --- a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -13,7 +13,7 @@ namespace Bifrost.Web.Tests; public sealed class OidcLogoutTests { private static readonly TimeSpan[] ExpectedBackoff = - [30, 60, 120, 240, 480, 900, 900].Select(x => TimeSpan.FromSeconds(x)).ToArray(); + new[] { 30, 60, 120, 240, 480, 900, 900 }.Select(x => TimeSpan.FromSeconds(x)).ToArray(); [Fact] public async Task Probe_authorizations_carry_the_subject_and_a_valid_status_after_a_code_flow() diff --git a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs index b04ded9..82872cc 100644 --- a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs +++ b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs @@ -4,6 +4,7 @@ using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Oidc; @@ -136,7 +137,7 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory Time, NullLogger.Instance); - public WebApplicationFactoryClientOptions ClientOptions() => new() + public WebApplicationFactoryClientOptions HostClientOptions() => new() { AllowAutoRedirect = false, HandleCookies = true, @@ -146,7 +147,7 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory /// Registers a member, sets , and completes one authorization-code flow for them. public async Task<(string Identity, string IdToken)> GrantAsync(MembershipStatus status = MembershipStatus.Active) { - using var client = CreateClient(ClientOptions()); + using var client = CreateClient(HostClientOptions()); var identity = $"member-{Guid.NewGuid():N}"; using (var register = await client.PostAsJsonAsync("/auth/bifrost/register", new { identity, displayName = identity })) { @@ -174,7 +175,7 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory var code = QueryHelpers.ParseQuery(response.Headers.Location!.Query)["code"].ToString(); response.Dispose(); - using var bare = CreateClient(ClientOptions()); + using var bare = CreateClient(HostClientOptions()); using var exchange = new HttpRequestMessage(HttpMethod.Post, "/connect/token") { Content = new FormUrlEncodedContent(new Dictionary @@ -215,7 +216,7 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory public async Task JwksAsync() { - using var client = CreateClient(ClientOptions()); + using var client = CreateClient(HostClientOptions()); return new JsonWebKeySet(await client.GetStringAsync("/.well-known/jwks")); } } From c9e470bef431f0293f19ff26fe10b678dbb1b0f6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 00:39:57 +0100 Subject: [PATCH 16/40] docs/oidc-issuer: BackchannelLogoutUri, reconcile semantics, MAS facts and the tick-window tradeoff Co-Authored-By: Claude Opus 5.5 --- docs/oidc-issuer.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/oidc-issuer.md b/docs/oidc-issuer.md index 5698c25..a7f59a4 100644 --- a/docs/oidc-issuer.md +++ b/docs/oidc-issuer.md @@ -44,6 +44,7 @@ echoes its value. | `Bifrost:Oidc:Clients:Mas:ClientId` | The client id MAS presents. | | `Bifrost:Oidc:Clients:Mas:ClientSecretPath` | File holding the client secret. | | `Bifrost:Oidc:Clients:Mas:RedirectUri` | MAS's callback URL, registered exactly. | +| `Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri` | Optional. MAS's back-channel logout URL, `https:///upstream/backchannel-logout/`; absolute http(s) or startup fails. Unset: no logout is sent and no authorization is revoked. | There are no development or ephemeral keys in the host. Key files are provisioned like the other `[workload.secret_files]` paths. @@ -72,6 +73,33 @@ The authorize endpoint reads the cookie session and the same actor facts the `/A - Signed in and `Active`: a code is issued. - Token endpoint: membership is read again; an account no longer `Active` gets `invalid_grant` and no tokens. +## Back-channel logout + +When `BackchannelLogoutUri` is set, a hosted reconciler (`OidcLogoutReconciler`) runs every 30 seconds. It lists the +subjects holding valid authorizations of the MAS client; for each subject whose membership is not `Active` (the same +reading the token and userinfo endpoints use; a deleted account or a subject that is not an account id counts as not +`Active`) it POSTs one OIDC back-channel logout token to MAS, and only after a 2xx revokes that subject's authorizations, +which also invalidates their tokens. Membership is never written by this path: any way of leaving `Active` (admin +action, sync, a database edit, a future suspend) is covered because the work is derived from membership state. + +The token is an ES256 JWT signed with the issuer key, header `typ` `logout+jwt`, with `iss` as the id_token carries it, +`aud` the MAS client id, `sub` the account id, `iat`, `exp` two minutes later, a fresh `jti` and the +`http://schemas.openid.net/event/backchannel-logout` event. It carries no `nonce` and no `sid`; MAS finds the sessions +by `sub`. It is sent as `application/x-www-form-urlencoded` field `logout_token` with a 10 second timeout, and the +response body is never logged. + +Failure handling is per subject, in memory: a 5xx, a timeout or a network fault backs off exponentially from 30 seconds +to 15 minutes; a 400 (token refused) or 404 (unknown provider ULID) is a configuration fault, logged by status only, +and retried at the 15 minute cap. Nothing is revoked until MAS answers 2xx. A restart loses the backoff and nothing +else: the authorizations table is the queue, so a later-set URI also catches up on earlier demotions. + +Tradeoff: delivery latency is up to one tick, and a member demoted and re-promoted inside one tick keeps their sessions. + +MAS verified at v1.28.0 (`crates/handlers/src/upstream_oauth2/backchannel_logout.rs`): the token is checked like an +id_token (`iss`, `aud`, `alg`, JWKS signature), `exp` and `iat` are required, `sub` or `sid` is required, `nonce` must be +absent. Owed on the MAS side (gamecult-chat): the provider's `backchannel_logout_uri` and +`on_backchannel_logout: logout_all`. Re-check against the MAS version pinned there. + ## MAS configuration Configure MAS's upstream provider with the issuer above, the client id and secret, the token endpoint auth method From af648683267076774f5ce291cc0ed9679d513f82 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 00:56:18 +0100 Subject: [PATCH 17/40] Logout tests: loop, log content, 2xx range, backoff reset, exception and cancellation paths Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/OidcLogoutTests.cs | 150 ++++++++++++++++-- .../Support/OidcLogoutKit.cs | 143 ++++++++++++++++- 2 files changed, 276 insertions(+), 17 deletions(-) diff --git a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs index ed9416e..9a3d228 100644 --- a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -3,6 +3,7 @@ using Bifrost.Web.Domain; using Bifrost.Web.Features.Oidc; using Bifrost.Web.Tests.Support; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; @@ -157,6 +158,7 @@ public sealed class OidcLogoutTests [Theory] [InlineData(FakeMasHandler.NetworkFault)] [InlineData(FakeMasHandler.Timeout)] + [InlineData(300)] [InlineData(302)] [InlineData(401)] public async Task A_network_fault_timeout_or_unexpected_status_keeps_the_authorizations_and_retries(int fault) @@ -222,19 +224,7 @@ public sealed class OidcLogoutTests { using var factory = new OidcLogoutFactory(); var (active, _) = await factory.GrantAsync(MembershipStatus.Active); - using (var scope = factory.Services.CreateScope()) - { - var applications = scope.ServiceProvider.GetRequiredService(); - var authorizations = scope.ServiceProvider.GetRequiredService(); - var application = (await applications.FindByClientIdAsync(OidcTestKit.ClientId))!; - await authorizations.CreateAsync(new OpenIddictAuthorizationDescriptor - { - ApplicationId = await applications.GetIdAsync(application), - Subject = "not-a-guid", - Status = OpenIddictConstants.Statuses.Valid, - Type = OpenIddictConstants.AuthorizationTypes.AdHoc - }); - } + await factory.AddAuthorizationAsync("not-a-guid"); await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); @@ -258,6 +248,140 @@ public sealed class OidcLogoutTests Assert.Equal(0, factory.ValidAuthorizations(subject)); } + [Theory] + [InlineData(201)] + [InlineData(204)] + [InlineData(299)] + public async Task Any_2xx_is_delivery(int status) + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + factory.Mas.Script(status); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(identity).ToString("D"))); + } + + [Fact] + public async Task A_failure_is_logged_by_status_and_delay_only_and_a_success_logs_no_failure() + { + using var factory = new OidcLogoutFactory(); + await factory.GrantAsync(MembershipStatus.Suspended); + await factory.GrantAsync(MembershipStatus.Suspended); + var log = new ListLogger(); + factory.Mas.Script(503, FakeMasHandler.NetworkFault); + + await factory.NewReconciler(logger: log).ReconcileOnceAsync(CancellationToken.None); + + // The first subject failed with 503, the second with no response; both were delivered nothing. + var warnings = log.Entries.Where(x => x.Level == LogLevel.Warning).Select(x => x.Message).ToArray(); + Assert.Equal(2, warnings.Length); + Assert.Contains(warnings, x => x.Contains("503", StringComparison.Ordinal) && x.Contains("00:00:30", StringComparison.Ordinal)); + Assert.Contains(warnings, x => x.Contains("no response", StringComparison.Ordinal)); + Assert.All(log.Entries, x => + { + Assert.DoesNotContain(FakeMasHandler.BodyCanary, x.Message, StringComparison.Ordinal); + Assert.DoesNotContain("mas.example.test", x.Message, StringComparison.Ordinal); + Assert.DoesNotContain("logout_token", x.Message, StringComparison.Ordinal); + }); + + var clean = new ListLogger(); + await factory.NewReconciler(logger: clean).ReconcileOnceAsync(CancellationToken.None); + Assert.Empty(clean.Entries.Where(x => x.Level >= LogLevel.Warning)); + } + + [Fact] + public async Task A_subject_who_recovers_or_whose_grants_vanish_carries_no_stale_backoff() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + factory.Mas.Script(500, 500); + var reconciler = factory.Reconciler; + + // Recovered: Active again clears the backoff, so the next demotion is delivered at once. + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Single(factory.Mas.Bodies); + await factory.SetMembershipAsync(identity, MembershipStatus.Active); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + await factory.SetMembershipAsync(identity, MembershipStatus.Suspended); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(2, factory.Mas.Bodies.Count); + + // Vanished: grants revoked elsewhere drop the subject from the list, which drops its backoff. + await factory.RevokeAllAsync(subject); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + await factory.AddAuthorizationAsync(subject); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(3, factory.Mas.Bodies.Count); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + } + + [Fact] + public async Task An_unexpected_exception_is_not_a_network_fault_and_a_cancelled_post_is_not_a_failed_delivery() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + var reconciler = factory.Reconciler; + + factory.Mas.Script(FakeMasHandler.Unexpected); + await Assert.ThrowsAsync(() => reconciler.ReconcileOnceAsync(CancellationToken.None)); + + // Stopping while the post is in flight surfaces as cancellation and records no backoff: the next pass posts at once. + factory.Mas.Script(FakeMasHandler.Hang); + using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + await Assert.ThrowsAnyAsync(() => reconciler.ReconcileOnceAsync(stop.Token)); + Assert.Equal(2, factory.Mas.Bodies.Count); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(3, factory.Mas.Bodies.Count); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + } + + [Fact] + public async Task The_hosted_loop_ticks_again_after_each_interval_and_survives_a_failed_tick() + { + using var factory = new OidcLogoutFactory(); + var clock = new ControlledClock(); + var reconciler = factory.NewReconciler(clock); + var (first, _) = await factory.GrantAsync(MembershipStatus.Suspended); + await reconciler.StartAsync(CancellationToken.None); + try + { + await WaitForAsync(() => factory.Mas.Bodies.Count == 1); + Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(first).ToString("D"))); + + // A failing tick is logged and retried only after the interval, never in a tight loop. + var (second, _) = await factory.GrantAsync(MembershipStatus.Suspended); + factory.Mas.Script(FakeMasHandler.Unexpected); + await WaitForAsync(() => clock.Waiting > 0); + clock.FireTimers(); + await WaitForAsync(() => factory.Mas.Bodies.Count == 2); + await Task.Delay(300); + Assert.Equal(2, factory.Mas.Bodies.Count); + + await WaitForAsync(() => clock.Waiting > 0); + clock.FireTimers(); + await WaitForAsync(() => factory.Mas.Bodies.Count == 3); + await WaitForAsync(() => factory.ValidAuthorizations(factory.AccountId(second).ToString("D")) == 0); + } + finally + { + await reconciler.StopAsync(CancellationToken.None); + } + } + + private static async Task WaitForAsync(Func condition) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (!condition()) + { + Assert.True(DateTime.UtcNow < deadline, "timed out waiting for the reconciler"); + await Task.Delay(20); + } + } + [Fact] public void Without_a_back_channel_logout_uri_the_reconciler_is_not_hosted_and_nothing_is_revoked() { diff --git a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs index 82872cc..abb5a06 100644 --- a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs +++ b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs @@ -15,9 +15,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; using OpenIddict.EntityFrameworkCore.Models; using OpenIddict.Server; @@ -36,6 +38,100 @@ public sealed class ManualClock : TimeProvider base.CreateTimer(callback, state, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); } +/// A clock whose timers fire only when the test says so, to drive the reconciler's own loop. +public sealed class ControlledClock : TimeProvider +{ + private readonly List _timers = []; + private DateTimeOffset _now = DateTimeOffset.UtcNow; + + public override DateTimeOffset GetUtcNow() => _now; + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = new PendingTimer(() => callback(state)); + lock (_timers) + { + _timers.Add(timer); + } + + return timer; + } + + /// How many delays are currently waiting to be fired. + public int Waiting + { + get + { + lock (_timers) + { + return _timers.Count(x => !x.Disposed); + } + } + } + + /// Fires every timer that is waiting (each delay the loop is sitting in). + public void FireTimers() + { + PendingTimer[] due; + lock (_timers) + { + due = _timers.Where(x => !x.Disposed).ToArray(); + _timers.Clear(); + } + + foreach (var timer in due) + { + timer.Fire(); + } + } + + private sealed class PendingTimer(Action fire) : ITimer + { + public bool Disposed { get; private set; } + + public void Fire() => fire(); + + public bool Change(TimeSpan dueTime, TimeSpan period) => true; + + public void Dispose() => Disposed = true; + + public ValueTask DisposeAsync() + { + Disposed = true; + return ValueTask.CompletedTask; + } + } +} + +/// Collects what a component logs, formatted, so a test can say what must and must not appear. +public sealed class ListLogger : ILogger +{ + private readonly List<(LogLevel Level, string Message)> _entries = []; + + public IReadOnlyList<(LogLevel Level, string Message)> Entries + { + get + { + lock (_entries) + { + return _entries.ToArray(); + } + } + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (_entries) + { + _entries.Add((logLevel, formatter(state, exception))); + } + } +} + /// /// A stand-in for MAS's back-channel logout route: answers a scripted status per POST (then 200), records every form /// body, and can fail like the network does. @@ -44,6 +140,8 @@ public sealed class FakeMasHandler : HttpMessageHandler { public const int NetworkFault = -1; public const int Timeout = -2; + public const int Unexpected = -3; + public const int Hang = -4; private readonly Queue _script = new(); private readonly List _bodies = []; @@ -98,9 +196,19 @@ public sealed class FakeMasHandler : HttpMessageHandler { NetworkFault => throw new HttpRequestException("network down"), Timeout => throw new TaskCanceledException("timed out"), - _ => new HttpResponseMessage((HttpStatusCode)status) { Content = new StringContent("mas-body-canary") } + Unexpected => throw new InvalidOperationException("not a network fault"), + Hang => await HangAsync(cancellationToken), + _ => new HttpResponseMessage((HttpStatusCode)status) { Content = new StringContent(BodyCanary) } }; } + + public const string BodyCanary = "mas-body-canary"; + + private static async Task HangAsync(CancellationToken cancellationToken) + { + await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("unreachable"); + } } /// An OIDC host with MAS's back-channel logout URI set, a fake MAS behind it and a clock the test drives. @@ -128,14 +236,14 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory } /// A reconciler with no memory, built over this host's database, as a restarted process would have. - public OidcLogoutReconciler NewReconciler() => new( + public OidcLogoutReconciler NewReconciler(TimeProvider? time = null, ILogger? logger = null) => new( Services.GetRequiredService(), Services.GetRequiredService(), Services.GetRequiredService(), Services.GetRequiredService>(), Services.GetRequiredService(), - Time, - NullLogger.Instance); + time ?? Time, + logger ?? NullLogger.Instance); public WebApplicationFactoryClientOptions HostClientOptions() => new() { @@ -205,6 +313,33 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory response.Headers.Location is { } location && (location.IsAbsoluteUri ? location.AbsolutePath : location.OriginalString).StartsWith("/connect/authorize", StringComparison.Ordinal); + /// Gives one more valid ad hoc authorization of the MAS client. + public async Task AddAuthorizationAsync(string subject) + { + using var scope = Services.CreateScope(); + var applications = scope.ServiceProvider.GetRequiredService(); + var authorizations = scope.ServiceProvider.GetRequiredService(); + var application = (await applications.FindByClientIdAsync(OidcTestKit.ClientId))!; + await authorizations.CreateAsync(new OpenIddictAuthorizationDescriptor + { + ApplicationId = await applications.GetIdAsync(application), + Subject = subject, + Status = OpenIddictConstants.Statuses.Valid, + Type = OpenIddictConstants.AuthorizationTypes.AdHoc + }); + } + + /// Revokes every authorization of the subject, as an operator would outside the reconciler. + public async Task RevokeAllAsync(string subject) + { + using var scope = Services.CreateScope(); + var authorizations = scope.ServiceProvider.GetRequiredService(); + await foreach (var authorization in authorizations.FindBySubjectAsync(subject)) + { + await authorizations.TryRevokeAsync(authorization); + } + } + /// How many of the subject's authorizations are still valid. public int ValidAuthorizations(string subject) { From 868b91fefb6c34bc0737a58c5b25fd930657a6c1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 00:57:31 +0100 Subject: [PATCH 18/40] Logout reconciler: a recovered subject's backoff clears at once, not when it expires Co-Authored-By: Claude Opus 5.5 --- .../Features/Oidc/OidcLogoutReconciler.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs index ba88f48..621772a 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs @@ -80,11 +80,6 @@ public sealed class OidcLogoutReconciler( foreach (var subject in subjects) { cancellationToken.ThrowIfCancellationRequested(); - if (_backoff.TryGetValue(subject, out var wait) && time.GetUtcNow() < wait.NotBefore) - { - continue; - } - await ReconcileSubjectAsync(subject, cancellationToken); } } @@ -124,7 +119,14 @@ public sealed class OidcLogoutReconciler( return; } - var failures = _backoff.TryGetValue(subject, out var previous) ? previous.Failures + 1 : 1; + // A subject that recovered is forgotten at once; one still owed a logout waits out its backoff. + _backoff.TryGetValue(subject, out var previous); + if (previous is not null && time.GetUtcNow() < previous.NotBefore) + { + return; + } + + var failures = (previous?.Failures ?? 0) + 1; var status = await SendLogoutAsync(subject, cancellationToken); if (status is { } code && (int)code is >= 200 and < 300) { From 51fe807b66a425b5ba89b1d840b61820b5279942 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 01:03:02 +0100 Subject: [PATCH 19/40] Logout loop test: the tick failure is logged by exception type only Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/OidcLogoutTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs index 9a3d228..cdbea0f 100644 --- a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -344,7 +344,8 @@ public sealed class OidcLogoutTests { using var factory = new OidcLogoutFactory(); var clock = new ControlledClock(); - var reconciler = factory.NewReconciler(clock); + var log = new ListLogger(); + var reconciler = factory.NewReconciler(clock, log); var (first, _) = await factory.GrantAsync(MembershipStatus.Suspended); await reconciler.StartAsync(CancellationToken.None); try @@ -364,6 +365,9 @@ public sealed class OidcLogoutTests await WaitForAsync(() => clock.Waiting > 0); clock.FireTimers(); await WaitForAsync(() => factory.Mas.Bodies.Count == 3); + var failure = Assert.Single(log.Entries, x => x.Level == LogLevel.Error); + Assert.Contains(nameof(InvalidOperationException), failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("not a network fault", failure.Message, StringComparison.Ordinal); await WaitForAsync(() => factory.ValidAuthorizations(factory.AccountId(second).ToString("D")) == 0); } finally From ec66fba5ca63d620faf7a5e75c0b14afd1f3e768 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 01:50:53 +0100 Subject: [PATCH 20/40] Logout client: no auto-redirect, pin the redirect rule and the 10 s timeout The named logout HttpClient's primary handler disables auto-redirect, so a 3xx from MAS is a non-delivery retried like any non-2xx instead of being followed to a final 2xx that revokes. Pinned through the real DI registration against a real loopback listener answering 302, and the registered client's Timeout is pinned to exactly 10 s. The unexpected-exception test is replaced by the isolation test (its fix is the next commit) and the cancellation test stands alone. Co-Authored-By: Claude Opus 5.5 --- .../Oidc/OidcServiceCollectionExtensions.cs | 4 +- tests/Bifrost.Web.Tests/OidcLogoutTests.cs | 75 +++++++++- .../Support/OidcLogoutKit.cs | 129 +++++++++++++++++- 3 files changed, 199 insertions(+), 9 deletions(-) diff --git a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs index 0e3bbf1..5ad30b8 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs @@ -70,7 +70,9 @@ public static class OidcServiceCollectionExtensions services.AddHostedService(); if (!string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri)) { - services.AddHttpClient(OidcLogoutReconciler.HttpClientName, client => client.Timeout = OidcLogoutReconciler.RequestTimeout); + // MAS answers the logout POST itself: a redirect is a non-delivery, never followed to some other 2xx. + services.AddHttpClient(OidcLogoutReconciler.HttpClientName, client => client.Timeout = OidcLogoutReconciler.RequestTimeout) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false }); services.AddHostedService(); } diff --git a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs index cdbea0f..c357507 100644 --- a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -319,26 +319,89 @@ public sealed class OidcLogoutTests } [Fact] - public async Task An_unexpected_exception_is_not_a_network_fault_and_a_cancelled_post_is_not_a_failed_delivery() + public async Task One_subject_that_throws_unexpectedly_does_not_stop_the_pass_and_keeps_its_authorizations() + { + using var factory = new OidcLogoutFactory(); + var log = new ListLogger(); + var reconciler = factory.NewReconciler(logger: log); + var (first, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var (second, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subjects = new[] { first, second }.Select(x => factory.AccountId(x).ToString("D")).ToArray(); + + // Whichever subject the pass reaches first throws; the other is still logged out and revoked. + factory.Mas.Script(FakeMasHandler.Unexpected); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + + Assert.Equal(2, factory.Mas.Bodies.Count); + var firstToken = factory.Mas.LogoutTokens()[0]; + var thrown = new JsonWebToken(firstToken).Subject; + var other = subjects.Single(x => x != thrown); + Assert.Equal(0, factory.ValidAuthorizations(other)); + Assert.True(factory.ValidAuthorizations(thrown) > 0); + var failure = Assert.Single(log.Entries, x => x.Level == LogLevel.Error); + Assert.Contains(thrown, failure.Message, StringComparison.Ordinal); + Assert.Contains(nameof(InvalidOperationException), failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("not a network fault", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain(firstToken, failure.Message, StringComparison.Ordinal); + + // The failed subject was not backed off: the next pass tries it again and delivers. + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(3, factory.Mas.Bodies.Count); + Assert.Equal(0, factory.ValidAuthorizations(thrown)); + } + + [Fact] + public async Task A_cancelled_post_stops_the_pass_and_is_not_a_failed_delivery() { using var factory = new OidcLogoutFactory(); var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); var subject = factory.AccountId(identity).ToString("D"); var reconciler = factory.Reconciler; - factory.Mas.Script(FakeMasHandler.Unexpected); - await Assert.ThrowsAsync(() => reconciler.ReconcileOnceAsync(CancellationToken.None)); - // Stopping while the post is in flight surfaces as cancellation and records no backoff: the next pass posts at once. factory.Mas.Script(FakeMasHandler.Hang); using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); await Assert.ThrowsAnyAsync(() => reconciler.ReconcileOnceAsync(stop.Token)); - Assert.Equal(2, factory.Mas.Bodies.Count); + Assert.Single(factory.Mas.Bodies); await reconciler.ReconcileOnceAsync(CancellationToken.None); - Assert.Equal(3, factory.Mas.Bodies.Count); + Assert.Equal(2, factory.Mas.Bodies.Count); Assert.Equal(0, factory.ValidAuthorizations(subject)); } + [Fact] + public async Task A_redirect_from_mas_is_not_followed_and_is_not_delivery() + { + using var mas = new RedirectingMas(); + using var factory = new OidcLogoutFactory(mas.LogoutUri); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + var before = factory.ValidAuthorizations(subject); + Assert.True(before > 0); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + Assert.Equal(["POST /logout"], mas.Requests); + Assert.Equal(before, factory.ValidAuthorizations(subject)); + + // A redirect is a non-delivery like any non-2xx: it backs off, then posts again. + factory.Time.Advance(TimeSpan.FromSeconds(30)); + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(["POST /logout", "POST /logout"], mas.Requests); + Assert.Equal(before, factory.ValidAuthorizations(subject)); + } + + [Fact] + public void The_logout_client_times_out_after_ten_seconds() + { + using var factory = new OidcLogoutFactory(); + using var client = factory.CreateClient(); + + Assert.Equal(TimeSpan.FromSeconds(10), OidcLogoutReconciler.RequestTimeout); + Assert.Equal( + TimeSpan.FromSeconds(10), + factory.Services.GetRequiredService().CreateClient(OidcLogoutReconciler.HttpClientName).Timeout); + } + [Fact] public async Task The_hosted_loop_ticks_again_after_each_interval_and_survives_a_failed_tick() { diff --git a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs index abb5a06..9b771fd 100644 --- a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs +++ b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs @@ -211,12 +211,134 @@ public sealed class FakeMasHandler : HttpMessageHandler } } +/// +/// A real HTTP listener on loopback standing in for a MAS that answers every POST with 302 to /login and GET /login with +/// 200: what a real HttpClient does with that answer is the registered handler's rule, which a fake handler cannot see. +/// +public sealed class RedirectingMas : IDisposable +{ + private readonly System.Net.Sockets.TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly List _requests = []; + private readonly CancellationTokenSource _stop = new(); + private readonly Task _accepting; + + public RedirectingMas() + { + _listener.Start(); + _accepting = Task.Run(AcceptAsync); + } + + public string LogoutUri => $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/logout"; + + /// "METHOD path" for every request received, in order. + public IReadOnlyList Requests + { + get + { + lock (_requests) + { + return _requests.ToArray(); + } + } + } + + private async Task AcceptAsync() + { + try + { + while (!_stop.IsCancellationRequested) + { + var connection = await _listener.AcceptTcpClientAsync(_stop.Token); + _ = Task.Run(() => ServeAsync(connection)); + } + } + catch (Exception) when (_stop.IsCancellationRequested) + { + } + } + + private async Task ServeAsync(System.Net.Sockets.TcpClient connection) + { + using var owned = connection; + try + { + var stream = connection.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); + var requestLine = await reader.ReadLineAsync(_stop.Token) ?? ""; + var length = 0; + while (await reader.ReadLineAsync(_stop.Token) is { Length: > 0 } header) + { + if (header.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) + { + length = int.Parse(header["Content-Length:".Length..].Trim()); + } + } + + var body = new char[length]; + var read = 0; + while (read < length) + { + var n = await reader.ReadAsync(body.AsMemory(read), _stop.Token); + if (n == 0) + { + break; + } + + read += n; + } + + var parts = requestLine.Split(' '); + lock (_requests) + { + _requests.Add($"{parts[0]} {parts[1]}"); + } + + var response = parts[0] == "POST" + ? "HTTP/1.1 302 Found\r\nLocation: /login\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + : "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + await stream.WriteAsync(Encoding.ASCII.GetBytes(response), _stop.Token); + } + catch (Exception error) when (error is IOException or OperationCanceledException or ObjectDisposedException) + { + } + } + + public void Dispose() + { + _stop.Cancel(); + _listener.Stop(); + try + { + _accepting.Wait(TimeSpan.FromSeconds(2)); + } + catch (AggregateException) + { + } + } +} + /// An OIDC host with MAS's back-channel logout URI set, a fake MAS behind it and a clock the test drives. public sealed class OidcLogoutFactory : OidcWebApplicationFactory { public const string LogoutUri = "https://mas.example.test/upstream/backchannel-logout/01TESTPROVIDER"; public static readonly Uri Origin = new("https://localhost"); + private readonly string _logoutUri; + private readonly bool _fakeTransport; + + public OidcLogoutFactory() + { + _logoutUri = LogoutUri; + _fakeTransport = true; + } + + /// Keeps the real registered HttpClient and its primary handler and posts to . + public OidcLogoutFactory(string logoutUri) + { + _logoutUri = logoutUri; + _fakeTransport = false; + } + public FakeMasHandler Mas { get; } = new(); public ManualClock Time { get; } = new(); @@ -226,12 +348,15 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory protected override void ConfigureWebHost(IWebHostBuilder builder) { base.ConfigureWebHost(builder); - builder.UseSetting("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri", LogoutUri); + builder.UseSetting("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri", _logoutUri); builder.ConfigureServices(services => { services.RemoveAll(); services.AddSingleton(Time); - services.AddHttpClient(OidcLogoutReconciler.HttpClientName).ConfigurePrimaryHttpMessageHandler(() => Mas); + if (_fakeTransport) + { + services.AddHttpClient(OidcLogoutReconciler.HttpClientName).ConfigurePrimaryHttpMessageHandler(() => Mas); + } }); } From 8340ee0e92ea1c410df31fc00172d53876f0906f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 01:50:54 +0100 Subject: [PATCH 21/40] Logout reconciler: one subject's unexpected exception does not stop the pass Caught per subject, logged at error with the subject id and exception type only, authorizations left valid; cancellation still stops the pass. Co-Authored-By: Claude Opus 5.5 --- .../Features/Oidc/OidcLogoutReconciler.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs index 621772a..ac6fa72 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs @@ -15,7 +15,8 @@ namespace Bifrost.Web.Features.Oidc; /// authorizations of the configured client; for each one the shared active-member reading refuses, it POSTs one signed /// OIDC back-channel logout token to MAS and, only after a 2xx, revokes that subject's authorizations. The /// authorizations table is the queue: nothing is stored, so a restart, a MAS outage or a later-set URI resumes from it. -/// Per-subject backoff lives in memory only. It never writes membership. +/// Per-subject backoff lives in memory only. One subject's unexpected exception is logged and skipped, never aborting the +/// pass. It never writes membership. /// public sealed class OidcLogoutReconciler( IServiceScopeFactory scopeFactory, @@ -80,7 +81,20 @@ public sealed class OidcLogoutReconciler( foreach (var subject in subjects) { cancellationToken.ThrowIfCancellationRequested(); - await ReconcileSubjectAsync(subject, cancellationToken); + try + { + await ReconcileSubjectAsync(subject, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception error) + { + // One subject's fault never stops the pass: its authorizations stay valid and the next tick retries it. + // The subject id and the exception type only: messages can carry connection strings or URLs. + logger.LogError("Back-channel logout for subject {Subject} failed: {ErrorType}", subject, error.GetType().Name); + } } } From 37c8fe1a72e0c10ba46fbfbc5f517ce489fad474 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 01:57:31 +0100 Subject: [PATCH 22/40] Logout tests: pin the loop's own tick-failure catch now that subject faults are isolated A pass that cannot read its subjects (a scope factory failing like a database outage) is logged by exception type only and retried after the interval, never in a tight loop. The earlier loop test reached this catch through a per-subject exception, which the isolation now absorbs. Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/OidcLogoutTests.cs | 30 +++++++++++++++++++ .../Support/OidcLogoutKit.cs | 25 ++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs index c357507..8aa5d15 100644 --- a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -439,6 +439,36 @@ public sealed class OidcLogoutTests } } + [Fact] + public async Task A_pass_that_cannot_read_its_subjects_is_logged_by_type_and_retried_after_the_interval() + { + using var factory = new OidcLogoutFactory(); + var clock = new ControlledClock(); + var log = new ListLogger(); + var flaky = new FlakyScopeFactory(factory.Services.GetRequiredService(), failures: 1); + var reconciler = factory.NewReconciler(clock, log, flaky); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + await reconciler.StartAsync(CancellationToken.None); + try + { + await WaitForAsync(() => log.Entries.Any(x => x.Level == LogLevel.Error) && clock.Waiting > 0); + var failure = Assert.Single(log.Entries, x => x.Level == LogLevel.Error); + Assert.Contains(nameof(InvalidOperationException), failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain(FlakyScopeFactory.Secret, failure.Message, StringComparison.Ordinal); + + // Never a tight loop: nothing posts until the interval passes, then the pass succeeds. + await Task.Delay(300); + Assert.Empty(factory.Mas.Bodies); + clock.FireTimers(); + await WaitForAsync(() => factory.Mas.Bodies.Count == 1); + await WaitForAsync(() => factory.ValidAuthorizations(factory.AccountId(identity).ToString("D")) == 0); + } + finally + { + await reconciler.StopAsync(CancellationToken.None); + } + } + private static async Task WaitForAsync(Func condition) { var deadline = DateTime.UtcNow.AddSeconds(10); diff --git a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs index 9b771fd..b7425f6 100644 --- a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs +++ b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs @@ -317,6 +317,24 @@ public sealed class RedirectingMas : IDisposable } } +/// A scope factory that fails the first scopes asked of it, as a database outage would. +public sealed class FlakyScopeFactory(IServiceScopeFactory inner, int failures) : IServiceScopeFactory +{ + public const string Secret = "scope-factory-secret-detail"; + + private int _failures = failures; + + public IServiceScope CreateScope() + { + if (Interlocked.Decrement(ref _failures) >= 0) + { + throw new InvalidOperationException(Secret); + } + + return inner.CreateScope(); + } +} + /// An OIDC host with MAS's back-channel logout URI set, a fake MAS behind it and a clock the test drives. public sealed class OidcLogoutFactory : OidcWebApplicationFactory { @@ -361,8 +379,11 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory } /// A reconciler with no memory, built over this host's database, as a restarted process would have. - public OidcLogoutReconciler NewReconciler(TimeProvider? time = null, ILogger? logger = null) => new( - Services.GetRequiredService(), + public OidcLogoutReconciler NewReconciler( + TimeProvider? time = null, + ILogger? logger = null, + IServiceScopeFactory? scopes = null) => new( + scopes ?? Services.GetRequiredService(), Services.GetRequiredService(), Services.GetRequiredService(), Services.GetRequiredService>(), From 7b630312e1b05bf70fb45a9ab0f57ead3a1d3c1a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 29 Oct 2026 19:41:30 +0100 Subject: [PATCH 23/40] persona-mouth: Matrix ear and mouth for Personas (brake-refusing sends, token only in the Authorization header, cursor after ack, one ring per idle window); delete the Discord posting actuator (squash of eureka-body/persona-mouth 1894f65..ab2bdc6; Soul s1) Co-Authored-By: Claude Opus 5.5 --- docs/bridge.md | 10 +- .../gamecult.persona.chat_ack.v1.msgpack | 1 + .../gamecult.persona.chat_pending.v1.msgpack | Bin 0 -> 142 bytes ...lt.persona.chat_pending_request.v1.msgpack | 1 + ...ecult.persona.chat_send_receipt.v1.msgpack | 1 + ...ecult.persona.chat_send_request.v1.msgpack | 1 + ...rsona-discord-crossing-rust-smoke.test.mjs | 27 -- tests/persona-discord-delivery.test.mjs | 36 -- tests/persona-discord-permit.test.mjs | 15 - ...rsona-discord-rudp-cross-language.test.mjs | 29 -- tests/persona-feedback-cli.test.mjs | 4 +- tests/persona-matrix-mouth-runtime.test.mjs | 149 ++++++++ tests/persona-matrix-mouth.test.mjs | 347 ++++++++++++++++++ tools/persona-chat-documents.mjs | 114 ++++++ tools/persona-discord-delivery-documents.mjs | 43 --- tools/persona-discord-delivery.mjs | 52 --- tools/persona-discord-permit.mjs | 39 -- tools/persona-discord-rudp.mjs | 31 -- tools/persona-feedback.mjs | 22 +- tools/persona-matrix-mouth.mjs | 303 +++++++++++++++ 20 files changed, 923 insertions(+), 302 deletions(-) create mode 100644 tests/fixtures/persona-chat-v1/gamecult.persona.chat_ack.v1.msgpack create mode 100644 tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending.v1.msgpack create mode 100644 tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack create mode 100644 tests/fixtures/persona-chat-v1/gamecult.persona.chat_send_receipt.v1.msgpack create mode 100644 tests/fixtures/persona-chat-v1/gamecult.persona.chat_send_request.v1.msgpack delete mode 100644 tests/persona-discord-crossing-rust-smoke.test.mjs delete mode 100644 tests/persona-discord-delivery.test.mjs delete mode 100644 tests/persona-discord-permit.test.mjs delete mode 100644 tests/persona-discord-rudp-cross-language.test.mjs create mode 100644 tests/persona-matrix-mouth-runtime.test.mjs create mode 100644 tests/persona-matrix-mouth.test.mjs create mode 100644 tools/persona-chat-documents.mjs delete mode 100644 tools/persona-discord-delivery-documents.mjs delete mode 100644 tools/persona-discord-delivery.mjs delete mode 100644 tools/persona-discord-permit.mjs delete mode 100644 tools/persona-discord-rudp.mjs create mode 100644 tools/persona-matrix-mouth.mjs diff --git a/docs/bridge.md b/docs/bridge.md index 683d7d2..0e5c56d 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -196,15 +196,7 @@ The command is a dry-run shape probe for public surfaces that are real enough to Bifrost's native Discord gateway is the conversation observer. A direct mention of the Bifrost bot, or a reply to one of its messages, becomes an immutable typed `bifrost.discord.persona_conversation_event.v0` only when the guild and channel have one exact Persona target binding. Bot-authored, ambient, empty, over-1200-character, unbound, and ambiguously bound messages do not enter the conversation path. The 1200-character cap is the shared signed alpha boundary with Epiphany. The observation store is Bifrost-private working state, never an actuator queue, and insert collisions must contain exactly equal content. VoidBot does not observe, schedule, or answer for the target Persona. -Persona replies cross back through two explicit CultMesh surfaces. Epiphany is -the sole writer of `epiphany.persona_discord_delivery_request.v0`; Bifrost -reads that surface, authenticates the dedicated Persona-mouth signer, journals -external execution before touching Discord, and is the sole writer of -`bifrost.persona_discord_delivery_receipt.v0`. Epiphany reads receipts but -never receives the Discord token or write access to Bifrost private state. -`unknown` is terminal for automatic transport retry: an external post may have -happened, so restart recovery must not manufacture a duplicate. Failed and -unknown outcomes remain Persona pressure for explicit resolution. +Persona speech goes out through Matrix, not Discord. `tools/persona-matrix-mouth.mjs` is the one process per Persona that holds the Persona's Matrix access token (a systemd credential) and nobody else does. It reads the configured rooms by `/sync`, keeps each message from another sender as a delivery, and speaks on a `gamecult.persona.chat_send_request.v1` over a loopback CultNet RUDP endpoint, only while the instance brake is released. Document shapes: `tools/persona-chat-documents.mjs`; golden bytes: `tests/fixtures/persona-chat-v1/`. The Discord posting actuator, its permit and its RUDP listener are removed. Bifrost admits an observation only when guild, channel, target runtime, canonical repository, Persona, producer, and producer runtime match a preconfigured `bifrost.persona_feedback.target_binding.v0` document in Bifrost's private provider store. That binding also owns source visibility, data classification, and the delivery-store target. Bifrost never infers public visibility from Discord identifiers. It classifies the author using its own `bifrost.discord.actor_link.v0` state: a match yields `linked_governance_feedback`; absence yields `unlinked_social_feedback`. Neither class grants adoption, work, release, or deployment authority. diff --git a/tests/fixtures/persona-chat-v1/gamecult.persona.chat_ack.v1.msgpack b/tests/fixtures/persona-chat-v1/gamecult.persona.chat_ack.v1.msgpack new file mode 100644 index 0000000..af22d1e --- /dev/null +++ b/tests/fixtures/persona-chat-v1/gamecult.persona.chat_ack.v1.msgpack @@ -0,0 +1 @@ +’¦eureka \ No newline at end of file diff --git a/tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending.v1.msgpack b/tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending.v1.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..0bdbdfa4f12ab390f58bc48935e9747290cb7103 GIT binary patch literal 142 zcmbQVEVZ;KH9K+QO!jTc`30#(i6!|(R_TeksmY}|C3^Wq=^GUjlaf(H7OSKhZgR*? zElETXUg8TBS(A~PlasFiG4wnG1LG{+hK&ppCvS0p2%+d$o|;&clbKosRkv0G01G@g AZU6uP literal 0 HcmV?d00001 diff --git a/tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack b/tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack new file mode 100644 index 0000000..0c326f5 --- /dev/null +++ b/tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack @@ -0,0 +1 @@ +’¦eureka \ No newline at end of file diff --git a/tests/fixtures/persona-chat-v1/gamecult.persona.chat_send_receipt.v1.msgpack b/tests/fixtures/persona-chat-v1/gamecult.persona.chat_send_receipt.v1.msgpack new file mode 100644 index 0000000..9b082a3 --- /dev/null +++ b/tests/fixtures/persona-chat-v1/gamecult.persona.chat_send_receipt.v1.msgpack @@ -0,0 +1 @@ +“¥req-1¥$sentÀ \ No newline at end of file diff --git a/tests/fixtures/persona-chat-v1/gamecult.persona.chat_send_request.v1.msgpack b/tests/fixtures/persona-chat-v1/gamecult.persona.chat_send_request.v1.msgpack new file mode 100644 index 0000000..44f6485 --- /dev/null +++ b/tests/fixtures/persona-chat-v1/gamecult.persona.chat_send_request.v1.msgpack @@ -0,0 +1 @@ +”¥req-1¦eureka¶#operator:gamecult.org¢hi \ No newline at end of file diff --git a/tests/persona-discord-crossing-rust-smoke.test.mjs b/tests/persona-discord-crossing-rust-smoke.test.mjs deleted file mode 100644 index cc9ed61..0000000 --- a/tests/persona-discord-crossing-rust-smoke.test.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import test from "node:test"; -import {existsSync} from "node:fs"; -import assert from "node:assert/strict"; -import {spawnSync} from "node:child_process"; -import {chmod,mkdtemp,writeFile} from "node:fs/promises"; -import {tmpdir} from "node:os"; -import {resolve} from "node:path"; -import {createRequire} from "node:module"; -import {enrollDomainIdentity} from "../tools/bifrost-signed-identity.mjs"; -import {receiptIdentityDomain} from "../tools/persona-discord-delivery-documents.mjs"; -import {processPersonaDiscordDeliveries} from "../tools/persona-discord-delivery.mjs"; - -const epiphany=resolve(import.meta.dirname,"..","..","Epiphany"),cult=resolve(import.meta.dirname,"..","..","CultLib"); -function runtime(){const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("@gamecult/cultcache-ts"),mp=nr("@msgpack/msgpack");return {CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,encode:mp.encode,decode:mp.decode};} - -const rustFixture=existsSync(resolve(epiphany,"epiphany-core","src","bin","epiphany-persona-discord-crossing-fixture.rs"))&&spawnSync("cargo",["--version"]).status===0; -test("Bifrost consumes a Rust-authored request store through its read-only crossing directory",{skip:rustFixture?false:"needs cargo and Epiphany's epiphany-persona-discord-crossing-fixture fixture (removed in Epiphany 387afe49)"},async()=>{ - const root=await mkdtemp(resolve(tmpdir(),"rust-bifrost-crossing-")),requestDir=resolve(root,"request"),receiptDir=resolve(root,"receipt"),requestStore=resolve(requestDir,"requests.cc"),anchor=resolve(root,"mouth-anchor.msgpack"),identity=resolve(root,"mouth.cc"),deliveryKey=resolve(root,"delivery.seed"),bridge=resolve(root,"bridge.mjs"); - const cargo=spawnSync("cargo",["run","--quiet","--manifest-path",resolve(epiphany,"epiphany-core","Cargo.toml"),"--bin","epiphany-persona-discord-crossing-fixture","--","--request-store",requestStore,"--identity-store",identity,"--request-anchor",anchor],{cwd:epiphany,encoding:"utf8",timeout:180000,windowsHide:true}); - assert.equal(cargo.status,0,cargo.stderr||cargo.stdout); - await chmod(requestStore,0o444); - await enrollDomainIdentity(deliveryKey,"delivery",receiptIdentityDomain); - await writeFile(bridge,`console.log(JSON.stringify({action:"discord-post",ok:true,channelId:"123",messageId:"rust-smoke-message",transport:"smoke",crossingReceiptId:"rust-smoke-receipt",url:"https://discord.invalid/rust-smoke"}))`); - const r=runtime(),results=await processPersonaDiscordDeliveries({runtime:r,requestStorePath:requestStore,receiptStorePath:resolve(receiptDir,"receipts.cc"),executionStorePath:resolve(root,"private","executions.cc"),epiphanyTrustAnchorPath:anchor,epiphanyRuntimeId:"epiphany-yggdrasil",privateKeyPath:deliveryKey,targetAdmitted:()=>true,requestPermit:async({requestPayloadSha256})=>({permitId:`permit:${requestPayloadSha256}`,expiresAt:new Date(Date.now()+5000).toISOString()}),bridgeCliPath:bridge,bifrostRoot:root}); - assert.equal(results[0]?.status,"completed"); - assert.equal(results[0]?.requestId,"rust-crossing-smoke-1"); -}); diff --git a/tests/persona-discord-delivery.test.mjs b/tests/persona-discord-delivery.test.mjs deleted file mode 100644 index ca4b65f..0000000 --- a/tests/persona-discord-delivery.test.mjs +++ /dev/null @@ -1,36 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import {createHash} from "node:crypto"; -import {createRequire} from "node:module"; -import {mkdtemp,writeFile} from "node:fs/promises"; -import {tmpdir} from "node:os"; -import {resolve} from "node:path"; -import {buildPersonaDiscordDeliveryDefinitions,executionSchema,parseReceipt,parseRequest,receiptDocumentTuple,receiptIdentityDomain,receiptSignatureDomain,receiptSigningPurpose,receiptSigningTuple,requestIdentityDomain,requestSignatureDomain,requestSigningPurpose,requestSigningTuple} from "../tools/persona-discord-delivery-documents.mjs"; -import {enrollPersonaDiscordDeliveryIdentity,exportPersonaDiscordPermitRequestAnchor,inspectPersonaDiscordDeliveryReadiness,processPersonaDiscordDeliveries} from "../tools/persona-discord-delivery.mjs"; -import {enrollDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "../tools/bifrost-signed-identity.mjs"; - -const signature=()=>new Uint8Array(64); -const request=()=>({schemaVersion:"epiphany.persona_discord_delivery_request.v0",requestId:"request-1",effectDocumentId:"effect-1",targetRuntimeId:"epiphany-yggdrasil",personaAgentId:"epiphany.Persona",channelId:"123",replyToMessageId:"456",content:"hello",contentSha256:`sha256-${"a".repeat(64)}`,issuedAt:"2026-07-21T12:00:00Z",expiresAt:"2026-07-21T12:01:00Z",privateStateExposed:false,signerIdentityId:"signer",signerSignature:signature()}); - -test("Persona delivery request has one exact signature tuple",()=>{const value=parseRequest(request());assert.deepEqual(requestSigningTuple(value),["epiphany.persona_discord_delivery_request.v0","request-1","effect-1","epiphany-yggdrasil","epiphany.Persona","123","456","hello",`sha256-${"a".repeat(64)}`,"2026-07-21T12:00:00Z","2026-07-21T12:01:00Z",false,"signer"]);}); - -test("Persona delivery receipt terminal states cannot counterfeit delivery",()=>{const base={schemaVersion:"bifrost.persona_discord_delivery_receipt.v0",receiptId:"request-1",requestId:"request-1",requestPayloadSha256:`sha256-${"b".repeat(64)}`,status:"unknown",channelId:"123",replyToMessageId:"456",messageId:"",transport:"",crossingReceiptId:"",receiptUrl:"",completedAt:"2026-07-21T12:00:02Z",providerIdentityId:"bifrost",privateStateExposed:false,providerSignature:signature()};assert.equal(receiptSigningTuple(parseReceipt(base)).length,14);assert.equal(receiptDocumentTuple(base).length,15);assert.deepEqual(parseReceipt(receiptDocumentTuple(base)),base);assert.throws(()=>parseReceipt({...base,messageId:"counterfeit"}),/may not claim delivery evidence/);assert.doesNotThrow(()=>parseReceipt({...base,status:"failed"}));assert.doesNotThrow(()=>parseReceipt({...base,status:"completed",messageId:"789",transport:"bifrost.discord-post",crossingReceiptId:"crossing-1",receiptUrl:"https://discord.com/channels/g/123/789"}));}); - -test("Persona delivery documents refuse privacy and identity substitutions",()=>{assert.throws(()=>parseRequest({...request(),privateStateExposed:true}),/must be false/);const receipt={schemaVersion:"bifrost.persona_discord_delivery_receipt.v0",receiptId:"receipt-2",requestId:"request-1",requestPayloadSha256:`sha256-${"b".repeat(64)}`,status:"failed",channelId:"123",replyToMessageId:"",messageId:"",transport:"",crossingReceiptId:"",receiptUrl:"",completedAt:"2026-07-21T12:00:02Z",providerIdentityId:"bifrost",privateStateExposed:false,providerSignature:signature()};assert.throws(()=>parseReceipt(receipt),/must equal requestId/);}); - -test("receipt signer and permit requester are distinct domain identities",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-identities-")),runtime=loadRuntime(),receiptKey=resolve(root,"receipt.seed"),permitKey=resolve(root,"permit.seed"),receiptAnchor=resolve(root,"receipt-anchor.msgpack"),permitAnchor=resolve(root,"permit-anchor.msgpack"),receipt=await enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath:receiptKey,trustAnchorOut:receiptAnchor}),permit=await exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath:permitKey,trustAnchorOut:permitAnchor});assert.notEqual(receiptKey,permitKey);assert.notEqual(receipt.identityId,permit.identityId);}); - -test("delivery listener admits absent typed stores when their owner directory is writable",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-readiness-")),runtime=loadRuntime(),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain),mouthAnchor=resolve(root,"mouth-anchor.msgpack"),permitAnchor=resolve(root,"permit-anchor.msgpack"),receiptKey=resolve(root,"receipt.seed"),permitKey=resolve(root,"permit.seed");await writeFile(mouthAnchor,runtime.encode(requestAnchor(requestIdentity)));await writeFile(permitAnchor,new Uint8Array([1]));await enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath:receiptKey,trustAnchorOut:resolve(root,"receipt-anchor.msgpack")});await exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath:permitKey,trustAnchorOut:resolve(root,"permit-request-anchor.msgpack")});const readiness=await inspectPersonaDiscordDeliveryReadiness({runtime,requestStorePath:resolve(root,"requests.cc"),receiptStorePath:resolve(root,"receipts.cc"),executionStorePath:resolve(root,"executions.cc"),epiphanyTrustAnchorPath:mouthAnchor,epiphanyRuntimeId:"epiphany-yggdrasil",epiphanyPermitAnchorPath:permitAnchor,epiphanyPermitEndpoint:"rudp://127.0.0.1:9",privateKeyPath:receiptKey,permitRequesterPrivateKeyPath:permitKey});assert.equal(readiness.ready,true);assert.deepEqual(readiness.reasons,[]);}); - -test("Bifrost authenticates one request, publishes one signed receipt, and replays without reposting",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestKey=resolve(root,"request.seed"),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"request-anchor.msgpack"),counterPath=resolve(root,"post-count"),bridgePath=resolve(root,"fake-bridge.mjs"),requestIdentity=await enrollDomainIdentity(requestKey,"request identity",requestIdentityDomain),deliveryIdentity=await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));await writeFile(bridgePath,`import{readFileSync,writeFileSync}from'node:fs';const p=${JSON.stringify(counterPath)},i=process.argv.indexOf('--cultmesh-command-id');if(i<0||process.argv[i+1]!=='request-1')process.exit(2);let n=0;try{n=Number(readFileSync(p,'utf8'))}catch{}writeFileSync(p,String(n+1));console.log(JSON.stringify({action:'discord-post',ok:true,channelId:'123',messageId:'789',transport:'bifrost.discord-post',crossingReceiptId:'crossing-1',url:'https://discord.com/channels/g/123/789'}));`);const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const options={runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,epiphanyRuntimeId:"epiphany-yggdrasil",privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:bridgePath,bifrostRoot:root,requestPermit:async()=>({permitId:"permit-1"})};let results=await processPersonaDiscordDeliveries(options);assert.equal(results[0].status,"completed");results=await processPersonaDiscordDeliveries(options);assert.equal(results.length,0);const receipts=await open(runtime,defs.receipt,receiptStore),receipt=receipts.get(defs.receipt,value.requestId);assert.equal(receipt.messageId,"789");assert.equal(verifyDomainPurpose(deliveryIdentity.publicKey,receiptSignatureDomain,receiptSigningPurpose,runtime.encode(receiptSigningTuple(receipt)),receipt.providerSignature),true);assert.equal(await import("node:fs/promises").then(fs=>fs.readFile(counterPath,"utf8")),"1");}); - -test("running journal recovers as terminal unknown without a second Discord post",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-mouth-crash-")),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"anchor.msgpack");await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);const digest=`sha256-${createHash("sha256").update(runtime.encode(requestSigningTuple(value))).digest("hex")}`,executions=await open(runtime,defs.execution,executionStore);await executions.put(defs.execution,value.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:value.requestId,requestPayloadSha256:digest,status:"running",recordedAt:new Date().toISOString(),detail:"crash fixture"});const results=await processPersonaDiscordDeliveries({runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,epiphanyRuntimeId:"epiphany-yggdrasil",privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath:resolve(root,"must-not-run.mjs"),bifrostRoot:root});assert.equal(results[0].status,"unknown");assert.equal(results[0].messageId,"");}); - -test("permit intent survives network failure and restart reuses its exact request tuple",async()=>{const f=await deliveryFixture("permit-retry-");const attempts=[];let results=await processPersonaDiscordDeliveries({...f.options,requestPermit:async value=>{attempts.push({...value});throw new Error("permit network unavailable");}});assert.equal(results[0].status,"permit_pending");const executions=await open(f.runtime,f.defs.execution,f.executionStore),pending=executions.get(f.defs.execution,f.value.requestId);assert.equal(pending.status,"permit_pending");assert.equal(pending.permitNonce,attempts[0].nonce);assert.equal(pending.permitIssuedAt,attempts[0].issuedAt);assert.equal(pending.permitExpiresAt,attempts[0].expiresAt);results=await processPersonaDiscordDeliveries({...f.options,requestPermit:async value=>{attempts.push({...value});return {permitId:"permit-after-restart"};}});assert.equal(results[0].status,"completed");assert.equal(attempts.length,2);for(const key of ["requestId","requestPayloadSha256","targetRuntimeId","nonce","issuedAt","expiresAt"])assert.equal(attempts[1][key],attempts[0][key]);assert.equal(await import("node:fs/promises").then(fs=>fs.readFile(f.counterPath,"utf8")),"1");}); - -test("expired permit intent restarts as terminal unknown without requesting or posting",async()=>{const f=await deliveryFixture("permit-expired-"),digest=`sha256-${createHash("sha256").update(f.runtime.encode(requestSigningTuple(f.value))).digest("hex")}`,executions=await open(f.runtime,f.defs.execution,f.executionStore),issuedAt=new Date(Date.now()-6000).toISOString(),expiresAt=new Date(Date.now()-1000).toISOString();await executions.put(f.defs.execution,f.value.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:f.value.requestId,requestPayloadSha256:digest,status:"permit_pending",recordedAt:issuedAt,permitNonce:"persisted-nonce",permitIssuedAt:issuedAt,permitExpiresAt:expiresAt,detail:"restart fixture"});let permitCalls=0;const results=await processPersonaDiscordDeliveries({...f.options,requestPermit:async()=>{permitCalls++;throw new Error("must not request");},bridgeCliPath:resolve(f.root,"must-not-post.mjs")});assert.equal(results[0].status,"unknown");assert.equal(results[0].messageId,"");assert.equal(permitCalls,0);await assert.rejects(import("node:fs/promises").then(fs=>fs.readFile(f.counterPath,"utf8")));}); - -function loadRuntime(){const cult=resolve(import.meta.dirname,"..","..","CultLib"),cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("@gamecult/cultcache-ts"),mp=nr("@msgpack/msgpack");return {CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,encode:mp.encode,decode:mp.decode};} -async function open(runtime,definition,path){const cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();return cache;} -function requestAnchor(identity){return ["gamecult.service_trust_anchor.v1","epiphany-persona-mouth:epiphany-yggdrasil:v0","epiphany-persona-mouth","epiphany-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",requestSigningPurpose,"epiphany.persona_discord_delivery_request.v0","root",Date.now(),null,false];} -async function deliveryFixture(prefix){const root=await mkdtemp(resolve(tmpdir(),prefix)),runtime=loadRuntime(),defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),executionStore=resolve(root,"private","executions.cc"),deliveryKey=resolve(root,"delivery.seed"),anchorPath=resolve(root,"request-anchor.msgpack"),counterPath=resolve(root,"post-count"),bridgeCliPath=resolve(root,"fake-bridge.mjs"),requestIdentity=await enrollDomainIdentity(resolve(root,"request.seed"),"request identity",requestIdentityDomain);await enrollDomainIdentity(deliveryKey,"delivery identity",receiptIdentityDomain);await writeFile(anchorPath,runtime.encode(requestAnchor(requestIdentity)));await writeFile(bridgeCliPath,`import{readFileSync,writeFileSync}from'node:fs';const p=${JSON.stringify(counterPath)};let n=0;try{n=Number(readFileSync(p,'utf8'))}catch{}writeFileSync(p,String(n+1));console.log(JSON.stringify({action:'discord-post',ok:true,channelId:'123',messageId:'789',transport:'bifrost.discord-post',crossingReceiptId:'crossing-1',url:'https://discord.com/channels/g/123/789'}));`);const now=Date.now(),value={...request(),issuedAt:new Date(now-1000).toISOString(),expiresAt:new Date(now+60000).toISOString(),signerIdentityId:requestIdentity.identityId};value.contentSha256=`sha256-${createHash("sha256").update(value.content).digest("hex")}`;value.signerSignature=new Uint8Array(signDomainPurpose(requestIdentity,requestSignatureDomain,requestSigningPurpose,runtime.encode(requestSigningTuple(value))));const requests=await open(runtime,defs.request,requestStore);await requests.put(defs.request,value.requestId,value);return {root,runtime,defs,value,executionStore,counterPath,options:{runtime,requestStorePath:requestStore,receiptStorePath:receiptStore,executionStorePath:executionStore,epiphanyTrustAnchorPath:anchorPath,epiphanyRuntimeId:"epiphany-yggdrasil",privateKeyPath:deliveryKey,targetAdmitted:()=>true,bridgeCliPath,bifrostRoot:root}};} diff --git a/tests/persona-discord-permit.test.mjs b/tests/persona-discord-permit.test.mjs deleted file mode 100644 index fbba89a..0000000 --- a/tests/persona-discord-permit.test.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { resolve } from "node:path"; -import { enrollDomainIdentity, signDomainPurpose } from "../tools/bifrost-signed-identity.mjs"; -import { permitIdentityDomain, permitPurpose, permitRequestSchema, permitRequestWireTuple, permitSchema, permitSignatureDomain, permitTuple, parseEpiphanyPermitAnchorTuple, parsePermitWireTuple, validatePermit } from "../tools/persona-discord-permit.mjs"; - -const requireCultNet=createRequire(resolve(import.meta.dirname,"..","..","CultLib","packages","cultnet-ts","package.json")); -const {encode}=requireCultNet("@msgpack/msgpack"); - -test("Rust-shaped permit anchors and signatures retain byte authority",async()=>{const root=await mkdtemp(resolve(tmpdir(),"persona-permit-rust-shape-")),identity=await enrollDomainIdentity(resolve(root,"permit.seed"),"permit identity",permitIdentityDomain),now=Date.now(),anchor=parseEpiphanyPermitAnchorTuple(["gamecult.service_trust_anchor.v1","anchor","epiphany-persona-discord-permit","epiphany-starfire",identity.identityId,Array.from(identity.publicKey),"ed25519",permitPurpose,permitSchema,"root",now-1000,null,false],"epiphany-starfire",now),request={requestId:"request-1",requestPayloadSha256:`sha256-${"a".repeat(64)}`,targetRuntimeId:"epiphany-starfire",nonce:"nonce-1",requesterIdentityId:"requester"},permit={schemaVersion:permitSchema,permitId:`permit:${request.requestPayloadSha256}`,requestId:request.requestId,requestPayloadSha256:request.requestPayloadSha256,targetRuntimeId:request.targetRuntimeId,nonce:request.nonce,requesterIdentityId:request.requesterIdentityId,brakeStateDocumentId:"brake",brakeStateDocumentSha256:`sha256-${"b".repeat(64)}`,brakeObservedAt:new Date(now-100).toISOString(),issuedAt:new Date(now).toISOString(),expiresAt:new Date(now+4000).toISOString(),providerIdentityId:identity.identityId,privateStateExposed:false,providerSignature:[]};permit.providerSignature=Array.from(signDomainPurpose(identity,permitSignatureDomain,permitPurpose,encode(permitTuple(permit))));const parsed=validatePermit(permit,request,anchor,encode);assert.ok(parsed.providerSignature instanceof Uint8Array);assert.equal(parsed.providerSignature.length,64);}); - -test("Bifrost and Rust exchange exact positional permit bodies",()=>{const request={schemaVersion:permitRequestSchema,requestId:"request-1",requestPayloadSha256:`sha256-${"a".repeat(64)}`,targetRuntimeId:"epiphany-starfire",nonce:"nonce-1",issuedAt:"2026-08-08T20:00:00.000Z",expiresAt:"2026-08-08T20:00:05.000Z",requesterIdentityId:"requester",privateStateExposed:false,requesterSignature:Uint8Array.from({length:64},(_,i)=>i)};const requestWire=permitRequestWireTuple(request);assert.equal(requestWire.length,10);assert.deepEqual(requestWire.slice(0,9),[request.schemaVersion,request.requestId,request.requestPayloadSha256,request.targetRuntimeId,request.nonce,request.issuedAt,request.expiresAt,request.requesterIdentityId,false]);assert.deepEqual(requestWire[9],request.requesterSignature);const permitWire=[permitSchema,`permit:${request.requestPayloadSha256}`,request.requestId,request.requestPayloadSha256,request.targetRuntimeId,request.nonce,request.requesterIdentityId,"brake",`sha256-${"b".repeat(64)}`,"2026-08-08T20:00:00.000Z","2026-08-08T20:00:00.000Z","2026-08-08T20:00:04.000Z","provider",false,Array.from({length:64},(_,i)=>63-i)];const permit=parsePermitWireTuple(permitWire);assert.equal(permit.providerIdentityId,"provider");assert.deepEqual(permit.providerSignature,permitWire[14]);}); diff --git a/tests/persona-discord-rudp-cross-language.test.mjs b/tests/persona-discord-rudp-cross-language.test.mjs deleted file mode 100644 index f22f93f..0000000 --- a/tests/persona-discord-rudp-cross-language.test.mjs +++ /dev/null @@ -1,29 +0,0 @@ -import test from "node:test"; -import {existsSync} from "node:fs"; -import assert from "node:assert/strict"; -import {spawn,spawnSync} from "node:child_process"; -import {createHash} from "node:crypto"; -import {createRequire} from "node:module"; -import {mkdtemp,readFile,writeFile} from "node:fs/promises"; -import {tmpdir} from "node:os"; -import {resolve} from "node:path"; -import {enrollDomainIdentity,signDomainPurpose} from "../tools/bifrost-signed-identity.mjs"; -import {receiptIdentityDomain,receiptSignatureDomain,receiptSigningPurpose,receiptSigningTuple,requestSigningTuple} from "../tools/persona-discord-delivery-documents.mjs"; -import {startPersonaDiscordDeliveryRudpServer} from "../tools/persona-discord-rudp.mjs"; - -const epiphany=resolve(import.meta.dirname,"..","..","Epiphany"); - -const rustFixture=existsSync(resolve(epiphany,"epiphany-core","src","bin","epiphany-persona-discord-rudp-client-fixture.rs"))&&spawnSync("cargo",["--version"]).status===0; -test("Rust Persona mouth and Node Bifrost exchange one signed request and terminal receipt over CultNet RUDP",{skip:rustFixture?false:"needs cargo and Epiphany's epiphany-persona-discord-rudp-client-fixture fixture (removed in Epiphany 387afe49)"},async()=>{ - const root=await mkdtemp(resolve(tmpdir(),"persona-rudp-cross-language-")),runtime=loadRuntime(),deliveryIdentity=await enrollDomainIdentity(resolve(root,"delivery.seed"),"delivery identity",receiptIdentityDomain),receiptAnchor=resolve(root,"receipt-anchor.msgpack"),requestAnchor=resolve(root,"request-anchor.msgpack"),requestStore=resolve(root,"requests.cc"),receiptStore=resolve(root,"receipts.cc"),identityStore=resolve(root,"mouth.cc"); - await writeFile(receiptAnchor,runtime.msgpack.encode(["gamecult.service_trust_anchor.v1","bifrost-persona-discord-delivery:bifrost-discord-yggdrasil:v0","bifrost-persona-discord-delivery","bifrost-discord-yggdrasil",deliveryIdentity.identityId,new Uint8Array(deliveryIdentity.publicKey),"ed25519",receiptSigningPurpose,"bifrost.persona_discord_delivery_receipt.v0","root",Date.now(),null,false])); - let admitted; - const server=await startPersonaDiscordDeliveryRudpServer({runtime,endpoint:"rudp://127.0.0.1:0",onRequest:async request=>{admitted=request;const digest=`sha256-${createHash("sha256").update(runtime.msgpack.encode(requestSigningTuple(request))).digest("hex")}`,receipt={schemaVersion:"bifrost.persona_discord_delivery_receipt.v0",receiptId:request.requestId,requestId:request.requestId,requestPayloadSha256:digest,status:"completed",channelId:request.channelId,replyToMessageId:request.replyToMessageId,messageId:"node-rudp-message",transport:"bifrost.discord-post",crossingReceiptId:"node-rudp-crossing",receiptUrl:`https://discord.com/channels/g/${request.channelId}/node-rudp-message`,completedAt:new Date().toISOString(),providerIdentityId:deliveryIdentity.identityId,privateStateExposed:false,providerSignature:new Uint8Array()};receipt.providerSignature=new Uint8Array(signDomainPurpose(deliveryIdentity,receiptSignatureDomain,receiptSigningPurpose,runtime.msgpack.encode(receiptSigningTuple(receipt))));return receipt;}}); - try{ - const output=await run("cargo",["run","--quiet","--manifest-path",resolve(epiphany,"epiphany-core","Cargo.toml"),"--bin","epiphany-persona-discord-rudp-client-fixture","--","--request-store",requestStore,"--receipt-store",receiptStore,"--identity-store",identityStore,"--request-anchor",requestAnchor,"--receipt-anchor",receiptAnchor,"--endpoint",`127.0.0.1:${server.address.port}`],{cwd:epiphany}); - assert.equal(output.code,0,`${output.stderr}\nserver diagnostics: ${JSON.stringify(server.diagnostics)}`);const result=JSON.parse(output.stdout.trim());assert.equal(result.status,"completed");assert.equal(result.messageId,"node-rudp-message");assert.equal(admitted.targetRuntimeId,"epiphany-starfire");assert.ok((await readFile(requestStore)).length>0);assert.ok((await readFile(receiptStore)).length>0); - }finally{server.close();} -}); - -function loadRuntime(){const cult=resolve(import.meta.dirname,"..","..","CultLib"),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cultnet=nr("cultnet-ts"),msgpack=nr("@msgpack/msgpack");return {cultnet,msgpack};} -function run(command,args,options){return new Promise((resolveRun,reject)=>{const child=spawn(command,args,{...options,stdio:["ignore","pipe","pipe"]});let stdout="",stderr="";child.stdout.on("data",chunk=>stdout+=chunk);child.stderr.on("data",chunk=>stderr+=chunk);child.once("error",reject);child.once("close",code=>resolveRun({code,stdout,stderr}));});} diff --git a/tests/persona-feedback-cli.test.mjs b/tests/persona-feedback-cli.test.mjs index ff6f749..cf9c026 100644 --- a/tests/persona-feedback-cli.test.mjs +++ b/tests/persona-feedback-cli.test.mjs @@ -56,8 +56,8 @@ test("Bifrost admits bound feedback as pressure only and exports CultNet",async( const beforeStatus=await Promise.all([store,observationStore,deliveryStore,key].map(path=>readFile(path))); const readiness=cli("status",store,"--observation-store",observationStore,"--private-key",key); assert.equal(readiness.schemaVersion,"bifrost.persona_feedback.readiness.v0");assert.equal(readiness.status,"ready");assert.equal(readiness.bindingCount,1);assert.equal(readiness.pendingFailedCount,0);assert.equal(readiness.privateStateExposed,false); - const staged=cli("status",store,"--observation-store",observationStore,"--private-key",key,"--epiphany-persona-mouth-trust-anchor",resolve(dir,"missing-mouth-anchor.msgpack"),"--epiphany-runtime-id","epiphany-yggdrasil","--epiphany-persona-permit-trust-anchor",resolve(dir,"missing-permit-anchor.msgpack"),"--epiphany-persona-permit-rudp","rudp://127.0.0.1:9","--persona-delivery-private-key",resolve(dir,"missing-delivery.seed"),"--persona-permit-request-private-key",resolve(dir,"missing-permit-request.seed")); - assert.equal(staged.ingressReady,true);assert.deepEqual(staged.ingressReasons,[]);assert.equal(staged.personaDelivery.ready,false);assert.equal(staged.status,"degraded"); + const staged=cli("status",store,"--observation-store",observationStore,"--private-key",key); + assert.equal(staged.ingressReady,true);assert.deepEqual(staged.ingressReasons,[]);assert.equal(staged.personaDelivery,undefined);assert.equal(staged.status,"ready"); const afterStatus=await Promise.all([store,observationStore,deliveryStore,key].map(path=>readFile(path)));assert.deepEqual(afterStatus,beforeStatus); const Store=cr("@gamecult/cultcache-ts").SingleFileMessagePackBackingStore; assert.deepEqual([...new Set((await new Store(observationStore).pullAll()).map(entry=>entry.type))],["bifrost.discord.persona_conversation_event"]); diff --git a/tests/persona-matrix-mouth-runtime.test.mjs b/tests/persona-matrix-mouth-runtime.test.mjs new file mode 100644 index 0000000..abf755f --- /dev/null +++ b/tests/persona-matrix-mouth-runtime.test.mjs @@ -0,0 +1,149 @@ +// The parts of the Matrix mouth that sit on CultLib: the chat_* wire documents, the +// CultCache cursor and brake stores, and the CultNet RUDP endpoint. Needs the built +// CultLib TypeScript packages (CULTLIB_ROOT, else ../CultLib), like the other Bifrost tests. +import test from "node:test"; +import assert from "node:assert/strict"; +import dgram from "node:dgram"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import * as docs from "../tools/persona-chat-documents.mjs"; +import { cultcacheState, connectionIdFor, createBrakeReader, loadRuntime, startChatEndpoint } from "../tools/persona-matrix-mouth.mjs"; + +const root = resolve(import.meta.dirname, ".."); +const runtime = loadRuntime(resolve(process.env.CULTLIB_ROOT ?? resolve(root, "..", "CultLib"))); +const { msgpack } = runtime; +const fixtureDir = resolve(import.meta.dirname, "fixtures", "persona-chat-v1"); + +const delivery = { + seq: 7, roomAlias: "#operator:gamecult.org", roomId: "!abc:gamecult.org", eventId: "$e1", sender: "@meta:gamecult.org", senderDisplay: "Meta", + text: "hello eureka", atUnixMs: 1761700000000, + context: [{ sender: "@eureka:gamecult.org", text: "earlier", atUnixMs: 1761699999000 }], +}; +const samples = { + [docs.kinds.pendingRequest]: { tuple: docs.pendingRequestTuple({ persona: "eureka", afterSeq: 3 }), parse: docs.parsePendingRequest }, + [docs.kinds.pending]: { tuple: docs.pendingTuple({ persona: "eureka", deliveries: [delivery] }), parse: docs.parsePending }, + [docs.kinds.ack]: { tuple: docs.ackTuple({ persona: "eureka", throughSeq: 7 }), parse: docs.parseAck }, + [docs.kinds.sendRequest]: { tuple: docs.sendRequestTuple({ requestId: "req-1", persona: "eureka", roomAlias: "#operator:gamecult.org", text: "hi" }), parse: docs.parseSendRequest }, + [docs.kinds.sendReceipt]: { tuple: docs.sendReceiptTuple({ requestId: "req-1", eventId: "$sent" }), parse: docs.parseSendReceipt }, +}; + +for (const [kind, { tuple, parse }] of Object.entries(samples)) { + test(`golden fixture: ${kind} is byte-stable and parses back`, async () => { + const bytes = Buffer.from(msgpack.encode(tuple)); + const file = resolve(fixtureDir, `${kind}.msgpack`); + if (process.env.UPDATE_FIXTURES === "1") { await mkdir(fixtureDir, { recursive: true }); await writeFile(file, bytes); } + assert.deepEqual(bytes, await readFile(file), "wire bytes changed: Huginn decodes these fixtures"); + assert.deepEqual(parse(msgpack.decode(bytes)), parse(tuple)); + }); +} + +test("the chat documents refuse malformed tuples", () => { + assert.throws(() => docs.parseAck(["eureka"])); + assert.throws(() => docs.parseAck(["eureka", -1])); + assert.throws(() => docs.parsePendingRequest(["", 0])); + assert.throws(() => docs.parseSendRequest(["r", "eureka", "#a:b", "x".repeat(4001)])); + assert.throws(() => docs.parseSendRequest(["r", "eureka", "#a:b", 5])); + assert.throws(() => docs.parseSendReceipt(["r", "$e", "Brake"]), /exactly one/); + assert.throws(() => docs.parseSendReceipt(["r", null, null]), /exactly one/); + assert.throws(() => docs.sendReceiptTuple({ requestId: "r", refusal: "Invented" })); + assert.deepEqual(docs.parseSendReceipt(["r", null, "Brake"]), { requestId: "r", eventId: null, refusal: "Brake" }); +}); + +test("the cursor is a CultCache file that survives a restart", async () => { + const path = resolve(await mkdtemp(resolve(tmpdir(), "mouth-cursor-")), "cursor.cc"); + assert.equal(await cultcacheState({ runtime, path }).load(), null); + await cultcacheState({ runtime, path }).save({ nextBatch: "s72_9", seq: 12 }); + const loaded = await cultcacheState({ runtime, path }).load(); + assert.deepEqual([loaded.nextBatch, loaded.seq], ["s72_9", 12]); +}); + +async function writeControl(path, brakeTuple) { + const store = new runtime.SingleFileMessagePackBackingStore(path); + await store.push({ key: "brake", type: "eureka.control.brake.v1", payload: msgpack.encode(brakeTuple), storedAt: new Date().toISOString() }); +} + +test("the brake reader reads a real CultCache control store", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "mouth-control-")); + const reader = path => createBrakeReader({ controlPath: path, readStore: runtime.readSingleFileStore, decode: msgpack.decode }); + assert.equal(await reader(resolve(dir, "missing.cc"))(), false); + await writeControl(resolve(dir, "held.cc"), [false, "2026-10-29T00:00:00Z", "op"]); + assert.equal(await reader(resolve(dir, "held.cc"))(), false); + await writeControl(resolve(dir, "released.cc"), [true, "2026-10-29T00:00:00Z", "op"]); + assert.equal(await reader(resolve(dir, "released.cc"))(), true); + await writeFile(resolve(dir, "garbage.cc"), "not a store"); + assert.equal(await reader(resolve(dir, "garbage.cc"))(), false); +}); + +test("the RUDP endpoint serves pending, ack and send over CultNet, and answers a held brake with a Brake refusal", async () => { + const calls = { acks: [], sends: [] }; + const mouth = { + pendingAfter: after => (after < 7 ? [delivery] : []), + ack: async through => { calls.acks.push(through); }, + send: async request => { calls.sends.push(request); return request.text === "muted" ? { requestId: request.requestId, eventId: null, refusal: "Brake" } : { requestId: request.requestId, eventId: "$sent", refusal: null }; }, + }; + const key = "connection-key-for-test"; + const rejected = []; + const server = await startChatEndpoint({ mouth, persona: "eureka", runtime, port: 0, connectionKey: key, runtimeId: "mouth-test", log: line => rejected.push(line) }); + const client = dgram.createSocket("udp4"); + await new Promise(ok => client.bind(0, "127.0.0.1", ok)); + const connection = new runtime.cultnet.CultNetRudpSocketTransportConnection({ + runtimeId: "huginn-test", socket: client, mode: "client", remoteHost: "127.0.0.1", remotePort: server.address.port, + connectionId: connectionIdFor(key), initialSequence: 1, resendDelayMs: 50, resendPollMs: 10, maxFragmentBytes: 1200, + }); + const replies = []; + const waiting = []; + connection.on("frame", value => { + const message = runtime.cultnet.parseCultNetMessage(msgpack.decode(value.payload), "cultnet.schema.v0"); + replies.push(message.document); + waiting.splice(0).forEach(w => w()); + }); + const nextReply = () => new Promise((ok, fail) => { const t = setTimeout(() => fail(new Error(`no reply from the mouth; it logged: ${rejected.join(" | ")}`)), 5000); waiting.push(() => { clearTimeout(t); ok(replies.at(-1)); }); }); + const put = (kind, recordKey, tuple) => connection.send("schema", msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire( + docs.envelope({ kind, recordKey, payload: msgpack.encode(tuple), sourceRuntimeId: "huginn-test", sourceAgentId: "eureka", sourceRole: "persona-organ" }), "cultnet.schema.v0"))); + try { + connection.connect(); + for (let i = 0; i < 100 && !connection.connected; i++) await new Promise(r => setTimeout(r, 20)); + assert.ok(connection.connected, "the RUDP session opens with the credential-derived connection id"); + let reply = nextReply(); + put(docs.kinds.pendingRequest, "eureka", docs.pendingRequestTuple({ persona: "eureka", afterSeq: 0 })); + let document = await reply; + assert.equal(document.schemaId, docs.kinds.pending); + const pending = docs.parsePending(msgpack.decode(document.payload)); + assert.deepEqual(pending.deliveries.map(d => d.eventId), ["$e1"]); + assert.deepEqual(pending.deliveries[0].context, delivery.context); + + put(docs.kinds.ack, "eureka:7", docs.ackTuple({ persona: "eureka", throughSeq: 7 })); + reply = nextReply(); + put(docs.kinds.sendRequest, "req-2", docs.sendRequestTuple({ requestId: "req-2", persona: "eureka", roomAlias: "#operator:gamecult.org", text: "muted" })); + document = await reply; + assert.equal(document.schemaId, docs.kinds.sendReceipt); + assert.deepEqual(docs.parseSendReceipt(msgpack.decode(document.payload)), { requestId: "req-2", eventId: null, refusal: "Brake" }); + put(docs.kinds.ack, "other:9", docs.ackTuple({ persona: "other", throughSeq: 9 })); + assert.deepEqual(calls.acks, [7]); + + reply = nextReply(); + put(docs.kinds.pendingRequest, "other", docs.pendingRequestTuple({ persona: "other", afterSeq: 0 })); + put(docs.kinds.sendRequest, "req-3", docs.sendRequestTuple({ requestId: "req-3", persona: "eureka", roomAlias: "#operator:gamecult.org", text: "speak" })); + document = await reply; + assert.equal(document.schemaId, docs.kinds.sendReceipt, "a request naming another Persona gets no answer; the next request is still served"); + assert.equal(docs.parseSendReceipt(msgpack.decode(document.payload)).eventId, "$sent"); + assert.deepEqual(calls.acks, [7], "an ack naming another Persona is not applied"); + } finally { + server.close(); + connection.close(); + try { client.close(); } catch { /* the connection already closed its socket */ } + } +}); + +test("the RUDP endpoint refuses to listen beyond loopback", async () => { + await assert.rejects(startChatEndpoint({ mouth: {}, persona: "eureka", runtime, port: 0, host: "0.0.0.0", connectionKey: "k", runtimeId: "m" }), /loopback/); +}); + +test("loopback means exactly an IPv4 127.x.y.z address", async () => { + const refuses = host => assert.rejects(startChatEndpoint({ mouth: {}, persona: "eureka", runtime, port: 0, host, connectionKey: "k", runtimeId: "m" }), /loopback/); + for (const host of ["9127.0.0.1", "127.0.0.1x", "10.127.0.0.1", "127.0.0", ""]) await refuses(host); + const ok = await startChatEndpoint({ mouth: {}, persona: "eureka", runtime, port: 0, host: "127.10.20.30", connectionKey: "k", runtimeId: "m" }).catch(e => e); + assert.ok(!/loopback/.test(ok?.message ?? ""), "a multi-digit loopback address is accepted by the check"); + ok.close?.(); +}); diff --git a/tests/persona-matrix-mouth.test.mjs b/tests/persona-matrix-mouth.test.mjs new file mode 100644 index 0000000..5f336eb --- /dev/null +++ b/tests/persona-matrix-mouth.test.mjs @@ -0,0 +1,347 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { createMouth, createBrakeReader, createRing, redact } from "../tools/persona-matrix-mouth.mjs"; + +const TOKEN = "syt_SECRET_TOKEN_9f3"; +const HS_NAME = "gamecult.test"; +const ROOMS = { [`#swarm:${HS_NAME}`]: "!swarm:gamecult.test", [`#operator:${HS_NAME}`]: "!operator:gamecult.test" }; +const SELF = `@eureka:${HS_NAME}`; + +const message = (id, sender, body, ts) => ({ type: "m.room.message", event_id: id, sender, origin_server_ts: ts, content: { msgtype: "m.text", body } }); +const tick = () => new Promise(r => setImmediate(r)); + +// A fake Matrix client-server API: just the endpoints the mouth uses. +async function fakeHomeserver({ sync, failWith } = {}) { + const seen = []; + const sent = new Map(); // txnId -> event_id + const server = createServer((req, res) => { + let raw = ""; + req.on("data", c => (raw += c)); + req.on("end", () => { + const url = new URL(req.url, "http://x"); + const record = { method: req.method, path: decodeURIComponent(url.pathname), query: url.searchParams, auth: req.headers.authorization, body: raw ? JSON.parse(raw) : null }; + seen.push(record); + const reply = (status, body) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(body)); }; + if (failWith) return reply(failWith, { errcode: "M_UNKNOWN_TOKEN", error: `bad token ${record.auth}` }); + const p = record.path.replace("/_matrix/client/v3", ""); + if (p === "/account/whoami") return reply(200, { user_id: SELF }); + if (p.startsWith("/directory/room/")) return reply(200, { room_id: ROOMS[p.slice("/directory/room/".length)] }); + if (p.startsWith("/join/")) return reply(200, { room_id: p.slice(6) }); + if (p === "/sync") return reply(200, sync(record.query.get("since"))); + const m = p.match(/^\/rooms\/(.+)\/send\/m\.room\.message\/(.+)$/); + if (m && req.method === "PUT") { + if (!sent.has(m[2])) sent.set(m[2], `$event${sent.size + 1}`); + return reply(200, { event_id: sent.get(m[2]) }); + } + reply(404, { errcode: "M_NOT_FOUND" }); + }); + }); + await new Promise(ok => server.listen(0, "127.0.0.1", ok)); + return { + url: `http://127.0.0.1:${server.address().port}`, + seen, + puts: () => seen.filter(r => r.method === "PUT"), + close: () => new Promise(ok => server.close(ok)), + }; +} + +function memoryState(initial = null) { + const box = { value: initial }; + return { box, load: async () => box.value, save: async v => { box.value = v; } }; +} + +const joined = events => ({ rooms: { join: events } }); +function mouthFor(hs, overrides = {}) { + return createMouth({ + persona: "eureka", homeserver: hs.url, rooms: Object.keys(ROOMS), token: TOKEN, state: memoryState(), + brake: async () => true, ring: async () => {}, log: () => {}, syncTimeoutMs: 1, ...overrides, + }); +} + +// The scripted timeline: the sync with no position is the baseline; later positions advance it. +const timeline = { + null: { next_batch: "b0", ...joined({ "!swarm:gamecult.test": { timeline: { events: [message("$old", `@alice:${HS_NAME}`, "history before the ear", 1)] } } }) }, + b0: { + next_batch: "b1", + ...joined({ + "!swarm:gamecult.test": { + state: { events: [{ type: "m.room.member", state_key: `@alice:${HS_NAME}`, content: { displayname: "Alice" } }] }, + timeline: { events: [ + message("$m1", `@alice:${HS_NAME}`, "hello eureka", 1000), + message("$m2", SELF, "my own words", 1001), + { type: "m.room.topic", event_id: "$t", sender: `@alice:${HS_NAME}`, content: { topic: "x" } }, + ] }, + }, + "!elsewhere:gamecult.test": { timeline: { events: [message("$x", `@bob:${HS_NAME}`, "not my room", 1002)] } }, + }), + }, + b1: { next_batch: "b2", ...joined({ "!operator:gamecult.test": { timeline: { events: [message("$m3", `@bob:${HS_NAME}`, "second batch", 2000)] } } }) }, +}; +const script = since => timeline[since] ?? { next_batch: since }; + +test("sync_message_becomes_delivery: another sender's message in a joined room is one delivery with context; own and foreign-room messages are none", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + const mouth = mouthFor(hs); + assert.deepEqual(await mouth.syncOnce(), [], "history at first sync is context, not mail"); + const made = await mouth.syncOnce(); + assert.equal(made.length, 1); + const [d] = made; + assert.deepEqual({ roomAlias: d.roomAlias, roomId: d.roomId, eventId: d.eventId, sender: d.sender, senderDisplay: d.senderDisplay, text: d.text, atUnixMs: d.atUnixMs }, { + roomAlias: `#swarm:${HS_NAME}`, roomId: "!swarm:gamecult.test", eventId: "$m1", sender: `@alice:${HS_NAME}`, senderDisplay: "Alice", text: "hello eureka", atUnixMs: 1000, + }); + assert.deepEqual(d.context, [{ sender: `@alice:${HS_NAME}`, text: "history before the ear", atUnixMs: 1 }]); + assert.equal(d.seq, 1); + assert.deepEqual(mouth.pendingAfter(0).map(x => x.eventId), ["$m1"]); + assert.deepEqual(mouth.pendingAfter(1), []); + } finally { await hs.close(); } +}); + +test("context holds the last 20 messages before the delivery, oldest first", async () => { + const many = Array.from({ length: 25 }, (_, i) => message(`$h${i}`, `@alice:${HS_NAME}`, `m${i}`, 10 + i)); + const hs = await fakeHomeserver({ + sync: since => since === null + ? { next_batch: "b0", ...joined({ "!swarm:gamecult.test": { timeline: { events: many } } }) } + : { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { timeline: { events: [message("$n", `@bob:${HS_NAME}`, "now", 99)] } } }) }, + }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); + const [d] = await mouth.syncOnce(); + assert.equal(d.context.length, 20); + assert.equal(d.context[0].text, "m5"); + assert.equal(d.context[19].text, "m24"); + } finally { await hs.close(); } +}); + +test("next_batch_persisted_only_after_ack: restart before ack re-delivers; after ack does not", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + const state = memoryState(); + const first = mouthFor(hs, { state }); + await first.syncOnce(); // baseline b0, nothing pending: persisted at once + assert.equal(state.box.value.nextBatch, "b0"); + const [d] = await first.syncOnce(); // delivery in the batch ending b1 + assert.equal(state.box.value.nextBatch, "b0", "b1 must not be persisted before an ack"); + + const restarted = mouthFor(hs, { state }); // crash before the ack + const [again] = await restarted.syncOnce(); + assert.equal(again.eventId, "$m1", "the unacked message arrives again"); + assert.ok(again.seq > d.seq, "sequence numbers never repeat across a restart"); + + await restarted.ack(again.seq); + assert.equal(state.box.value.nextBatch, "b1"); + + const afterAck = mouthFor(hs, { state }); + assert.deepEqual((await afterAck.syncOnce()).map(x => x.eventId), ["$m3"], "an acked batch is not delivered again: only the newer message arrives"); + assert.equal(hs.seen.filter(r => r.path.endsWith("/sync")).at(-1).query.get("since"), "b1"); + } finally { await hs.close(); } +}); + +test("an ack covers only the batches whose deliveries it reaches", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + const state = memoryState(); + const mouth = mouthFor(hs, { state }); + await mouth.syncOnce(); + const [a] = await mouth.syncOnce(); // batch ending b1 holds seq 1 + const [b] = await mouth.syncOnce(); // batch ending b2 holds seq 2 + assert.deepEqual([a.seq, b.seq], [1, 2]); + await mouth.ack(1); + assert.equal(state.box.value.nextBatch, "b1", "acking seq 1 persists the batch that ends at seq 1, not the later one"); + await mouth.ack(2); + assert.equal(state.box.value.nextBatch, "b2"); + assert.equal(mouth.pendingCount, 0); + } finally { await hs.close(); } +}); + +test("send_uses_request_id_txn: the PUT carries txnId = request_id and a repeat sends the same txnId", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + const mouth = mouthFor(hs); + const request = { requestId: "req 7f", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: "hello world" }; + const one = await mouth.send(request); + const two = await mouth.send(request); + assert.equal(one.refusal, null); + assert.equal(one.eventId, two.eventId, "the homeserver deduplicates by txnId, so a retry is the same event"); + const puts = hs.puts(); + assert.equal(puts.length, 2); + for (const put of puts) { + assert.equal(put.path, "/_matrix/client/v3/rooms/!swarm:gamecult.test/send/m.room.message/req 7f"); + assert.deepEqual(put.body, { msgtype: "m.text", body: "hello world" }); + } + assert.notEqual((await mouth.send({ ...request, requestId: "req-8" })).eventId, one.eventId, "a different request is a different txn"); + } finally { await hs.close(); } +}); + +test("send refuses another Persona's request, an unknown room and empty text, without a PUT", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + const mouth = mouthFor(hs); + assert.equal((await mouth.send({ requestId: "a", persona: "other", roomAlias: `#swarm:${HS_NAME}`, text: "x" })).refusal, "PersonaMismatch"); + assert.equal((await mouth.send({ requestId: "b", persona: "eureka", roomAlias: "#nowhere:gamecult.test", text: "x" })).refusal, "UnknownRoom"); + assert.equal((await mouth.send({ requestId: "c", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: " " })).refusal, "EmptyText"); + assert.equal(hs.puts().length, 0); + } finally { await hs.close(); } +}); + +const payload = value => Buffer.from(JSON.stringify(value)); +const store = (...envelopes) => ({ absent: false, envelopes }); +const brakeEnvelope = value => ({ type: "eureka.control.brake.v1", key: "brake", payload: payload(value) }); +const jsonDecode = b => JSON.parse(Buffer.from(b).toString()); +const readerFor = read => createBrakeReader({ controlPath: "x", readStore: () => read, decode: jsonDecode }); + +test("held_brake_refuses_send: held, throwing, missing and non-boolean brakes refuse with Brake and nothing reaches the homeserver", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + const request = { requestId: "r1", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: "speak" }; + const brakes = [ + async () => false, + async () => { throw new Error("control store unreadable"); }, + readerFor({ absent: true, envelopes: [] }), + readerFor(store(brakeEnvelope([false, "t", "op"]))), + async () => "yes", + ]; + for (const brake of brakes) { + const receipt = await mouthFor(hs, { brake }).send(request); + assert.deepEqual({ eventId: receipt.eventId, refusal: receipt.refusal }, { eventId: null, refusal: "Brake" }); + } + assert.equal(hs.puts().length, 0); + assert.equal(hs.seen.length, 0, "a refused send does not even touch the homeserver"); + const released = await mouthFor(hs, { brake: readerFor(store(brakeEnvelope([true, "t", "op"]))) }).send(request); + assert.equal(released.refusal, null, "the same request goes through once the brake is released"); + } finally { await hs.close(); } +}); + +test("brake reader: only a decoded released brake counts as released", async () => { + assert.equal(await readerFor(store(brakeEnvelope([true, "t", "op"])))(), true); + assert.equal(await readerFor(store(brakeEnvelope([false, "t", "op"])))(), false); + assert.equal(await readerFor(store(brakeEnvelope({ 0: true })))(), true, "integer-keyed map form"); + assert.equal(await readerFor(store(brakeEnvelope({ 0: false })))(), false); + assert.equal(await readerFor(store(brakeEnvelope(["true"])))(), false, "a non-boolean is not a release"); + assert.equal(await readerFor(store())(), false, "no brake document"); + assert.equal(await readerFor(store({ ...brakeEnvelope([true]), key: "dial" }))(), false, "wrong key"); + assert.equal(await readerFor(store({ ...brakeEnvelope([true]), type: "other.v1" }))(), false, "wrong type"); + assert.equal(await createBrakeReader({ controlPath: "x", readStore: () => { throw new Error("corrupt"); }, decode: jsonDecode })(), false); + assert.equal(await readerFor(store({ type: "eureka.control.brake.v1", key: "brake", payload: Buffer.from("not json") }))(), false, "undecodable bytes"); +}); + +test("ring_starts_persona_unit_once: two deliveries in one idle window cause one start; a drained mailbox rings again", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + let rings = 0; + const mouth = mouthFor(hs, { ring: async () => { rings++; } }); + await mouth.syncOnce(); // baseline + await mouth.syncOnce(); // delivery 1 rings + await mouth.syncOnce(); // delivery 2, same window, still unacked + await tick(); + assert.equal(rings, 1); + } finally { await hs.close(); } + + const every = await fakeHomeserver({ sync: since => ({ next_batch: `n${since}`, ...joined({ "!swarm:gamecult.test": { timeline: { events: [message(`$e${since}`, `@alice:${HS_NAME}`, "again", 5)] } } }) }) }); + try { + let clock = 0, rings = 0; + const mouth = mouthFor(every, { ring: async () => { rings++; }, now: () => clock }); + await mouth.syncOnce(); // baseline + await mouth.syncOnce(); await tick(); + assert.equal(rings, 1); + await mouth.syncOnce(); await tick(); + assert.equal(rings, 1, "inside the window and unacked: no second start"); + await mouth.ack(2); + await mouth.syncOnce(); await tick(); + assert.equal(rings, 2, "after a drain a new delivery rings again"); + clock = 6 * 60 * 1000; + await mouth.syncOnce(); await tick(); + assert.equal(rings, 3, "a delivery left unanswered past the window rings again"); + } finally { await every.close(); } +}); + +test("a failed ring is logged once and never retried", async () => { + const logs = []; + let calls = 0; + const hs = await fakeHomeserver({ sync: script }); + try { + const mouth = mouthFor(hs, { ring: async () => { calls++; throw new Error("systemctl refused"); }, log: l => logs.push(l) }); + await mouth.syncOnce(); await mouth.syncOnce(); await tick(); await tick(); + assert.equal(calls, 1); + assert.equal(logs.filter(l => l.includes("ring failed")).length, 1); + } finally { await hs.close(); } +}); + +test("ring runs exactly systemctl start --no-block on the instance's unit, with no token in the child's environment", async () => { + const calls = []; + await createRing({ instance: "eureka", run: (cmd, args, options, cb) => { calls.push({ cmd, args, options }); cb(null); } })(); + assert.equal(calls.length, 1); + assert.deepEqual([calls[0].cmd, calls[0].args], ["systemctl", ["start", "--no-block", "mind-persona@eureka.service"]]); + assert.deepEqual(Object.keys(calls[0].options.env), ["PATH"]); + const failed = []; + await createRing({ instance: "eureka", run: (c, a, o, cb) => cb(new Error("denied")), log: l => failed.push(l) })(); + assert.deepEqual(failed, ["ring failed: denied"]); +}); + +test("the Matrix token goes only in the Authorization header: never in logs, errors, deliveries or receipts", async () => { + const logs = []; + const bad = await fakeHomeserver({ sync: script, failWith: 401 }); + try { + const mouth = mouthFor(bad, { log: l => logs.push(l) }); + await assert.rejects(mouth.syncOnce(), err => { assert.ok(!err.message.includes(TOKEN)); return true; }); + const receipt = await mouth.send({ requestId: "r", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: "x" }).catch(e => ({ thrown: e.message })); + assert.ok(!JSON.stringify(receipt).includes(TOKEN)); + const controller = new AbortController(); + const running = mouthFor(bad, { log: l => logs.push(l) }).run(controller.signal); + await new Promise(r => setTimeout(r, 30)); + controller.abort(); + void running; // the run loop is left to its backoff; only its log lines matter here + } finally { await bad.close(); } + + const unreachable = mouthFor({ url: "http://127.0.0.1:1" }, { + log: l => logs.push(l), + fetch: async (url, init) => { throw new Error(`connect failed with ${init.headers.authorization} to ${url}`); }, + }); + await assert.rejects(unreachable.syncOnce(), err => { assert.ok(!err.message.includes(TOKEN), "transport errors are redacted"); return true; }); + + const hs = await fakeHomeserver({ sync: script }); + try { + const mouth = mouthFor(hs, { log: l => logs.push(l) }); + await mouth.syncOnce(); + const made = await mouth.syncOnce(); + const receipt = await mouth.send({ requestId: "ok", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: "hi" }); + assert.ok(!JSON.stringify([made, receipt, mouth.pendingAfter(0)]).includes(TOKEN)); + assert.ok(hs.seen.every(r => r.auth === `Bearer ${TOKEN}`), "the token is presented to the homeserver on every call"); + } finally { await hs.close(); } + + assert.ok(logs.length > 0, "failures were logged"); + assert.ok(logs.every(l => !l.includes(TOKEN)), `a log line carried the token: ${logs.find(l => l.includes(TOKEN))}`); + assert.equal(redact(`x ${TOKEN} y`, TOKEN), "x [redacted] y"); +}); + +test("a homeserver error is reported with its status and none of its body", async () => { + const hs = await fakeHomeserver({ sync: script, failWith: 500 }); + try { + await assert.rejects(mouthFor(hs).syncOnce(), err => /answered 500/.test(err.message) && !/bad token/.test(err.message)); + } finally { await hs.close(); } +}); + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === ".git" || name === "node_modules" || name === "bin" || name === "obj") continue; + const full = join(dir, name); + statSync(full).isDirectory() ? walk(full, out) : out.push(full); + } + return out; +} + +test("the Discord posting actuator is gone and the Matrix path names no Discord", () => { + const root = resolve(import.meta.dirname, ".."); + for (const file of ["tools/persona-matrix-mouth.mjs", "tools/persona-chat-documents.mjs"]) { + assert.ok(!/discord/i.test(readFileSync(resolve(root, file), "utf8")), `${file} mentions Discord`); + } + const survivors = walk(root).filter(f => /\.(mjs|cs|json|ya?ml|ps1|sh)$/.test(f) && /persona-discord-delivery/.test(readFileSync(f, "utf8")) && !/persona-matrix-mouth\.test\.mjs$/.test(f)); + assert.deepEqual(survivors, []); + for (const gone of ["persona-discord-delivery.mjs", "persona-discord-rudp.mjs", "persona-discord-delivery-documents.mjs", "persona-discord-permit.mjs"]) { + assert.throws(() => statSync(resolve(root, "tools", gone))); + } +}); diff --git a/tools/persona-chat-documents.mjs b/tools/persona-chat-documents.mjs new file mode 100644 index 0000000..3778379 --- /dev/null +++ b/tools/persona-chat-documents.mjs @@ -0,0 +1,114 @@ +// gamecult.persona.chat_*.v1: the documents Huginn and the Matrix mouth exchange. +// Each kind is a positional tuple (the CultCache payload shape); this module owns +// the field order, the parsers and the envelope check. It does no I/O and loads no +// runtime, so the wire encoder (MessagePack) stays with the caller. + +export const kinds = Object.freeze({ + pendingRequest: "gamecult.persona.chat_pending_request.v1", + pending: "gamecult.persona.chat_pending.v1", + ack: "gamecult.persona.chat_ack.v1", + sendRequest: "gamecult.persona.chat_send_request.v1", + sendReceipt: "gamecult.persona.chat_send_receipt.v1", +}); + +export const refusals = Object.freeze(["Brake", "PersonaMismatch", "UnknownRoom", "EmptyText", "SendFailed"]); + +const maxTextBytes = 4000; +const maxContext = 20; + +export function pendingRequestTuple({ persona, afterSeq }) { + return [text(persona, "persona"), seq(afterSeq, "after_seq")]; +} +export function parsePendingRequest(t) { + const [persona, afterSeq] = tuple(t, 2, "chat_pending_request"); + return { persona: text(persona, "persona"), afterSeq: seq(afterSeq, "after_seq") }; +} + +export function ackTuple({ persona, throughSeq }) { + return [text(persona, "persona"), seq(throughSeq, "through_seq")]; +} +export function parseAck(t) { + const [persona, throughSeq] = tuple(t, 2, "chat_ack"); + return { persona: text(persona, "persona"), throughSeq: seq(throughSeq, "through_seq") }; +} + +export function sendRequestTuple({ requestId, persona, roomAlias, text: body }) { + return [text(requestId, "request_id"), text(persona, "persona"), text(roomAlias, "room_alias"), String(body)]; +} +export function parseSendRequest(t) { + const [requestId, persona, roomAlias, body] = tuple(t, 4, "chat_send_request"); + if (typeof body !== "string") throw new Error("chat_send_request text must be a string"); + if (Buffer.byteLength(body, "utf8") > maxTextBytes) throw new Error("chat_send_request text is over the byte bound"); + return { requestId: text(requestId, "request_id"), persona: text(persona, "persona"), roomAlias: text(roomAlias, "room_alias"), text: body }; +} + +export function sendReceiptTuple({ requestId, eventId = null, refusal = null }) { + if ((eventId === null) === (refusal === null)) throw new Error("a send receipt carries exactly one of event_id and refusal"); + if (refusal !== null && !refusals.includes(refusal)) throw new Error(`unknown refusal ${refusal}`); + return [text(requestId, "request_id"), eventId, refusal]; +} +export function parseSendReceipt(t) { + const [requestId, eventId, refusal] = tuple(t, 3, "chat_send_receipt"); + if ((eventId === null) === (refusal === null)) throw new Error("a send receipt carries exactly one of event_id and refusal"); + return { requestId: text(requestId, "request_id"), eventId: eventId === null ? null : text(eventId, "event_id"), refusal }; +} + +export function deliveryTuple(d) { + return [ + seq(d.seq, "seq"), text(d.roomAlias, "room_alias"), text(d.roomId, "room_id"), text(d.eventId, "event_id"), + text(d.sender, "sender"), text(d.senderDisplay, "sender_display"), String(d.text), seq(d.atUnixMs, "at_unix_ms"), + d.context.slice(-maxContext).map(c => [text(c.sender, "sender"), String(c.text), seq(c.atUnixMs, "at_unix_ms")]), + ]; +} +export function parseDelivery(t) { + const [s, roomAlias, roomId, eventId, sender, senderDisplay, body, atUnixMs, context] = tuple(t, 9, "delivery"); + if (!Array.isArray(context)) throw new Error("delivery context must be a list"); + return { + seq: seq(s, "seq"), roomAlias: text(roomAlias, "room_alias"), roomId: text(roomId, "room_id"), eventId: text(eventId, "event_id"), + sender: text(sender, "sender"), senderDisplay: text(senderDisplay, "sender_display"), text: String(body), atUnixMs: seq(atUnixMs, "at_unix_ms"), + context: context.map(c => { const [cs, ct, ca] = tuple(c, 3, "context"); return { sender: text(cs, "sender"), text: String(ct), atUnixMs: seq(ca, "at_unix_ms") }; }), + }; +} + +export function pendingTuple({ persona, deliveries }) { + return [text(persona, "persona"), deliveries.map(deliveryTuple)]; +} +export function parsePending(t) { + const [persona, deliveries] = tuple(t, 2, "chat_pending"); + if (!Array.isArray(deliveries)) throw new Error("chat_pending deliveries must be a list"); + return { persona: text(persona, "persona"), deliveries: deliveries.map(parseDelivery) }; +} + +// The CultNet document_put_raw envelope every chat_* document travels in. +export function envelope({ kind, recordKey, payload, sourceRuntimeId, sourceAgentId, sourceRole }) { + return { + schemaVersion: "cultnet.document_put_raw.v0", + messageId: `${kind}:${recordKey}`, + document: { + schemaId: kind, recordKey, storedAt: new Date().toISOString(), payloadEncoding: "messagepack", payload, + sourceRuntimeId, sourceAgentId, sourceRole, tags: ["cultnet.transport.rudp.v0"], + }, + }; +} + +export function checkEnvelope(message, allowedKinds) { + const d = message?.document; + if (message?.schemaVersion !== "cultnet.document_put_raw.v0" || !d) throw new Error("not a document_put_raw message"); + if (!allowedKinds.includes(d.schemaId)) throw new Error(`document kind ${String(d.schemaId)} is not accepted here`); + if (d.payloadEncoding !== "messagepack") throw new Error("chat documents are MessagePack"); + if (!Array.isArray(d.tags) || d.tags.length !== 1 || d.tags[0] !== "cultnet.transport.rudp.v0") throw new Error("chat document tags substituted authority"); + return d; +} + +function tuple(t, n, what) { + if (!Array.isArray(t) || t.length !== n) throw new Error(`${what} must be a ${n}-field tuple`); + return t; +} +function text(v, what) { + if (typeof v !== "string" || v === "") throw new Error(`${what} must be a non-empty string`); + return v; +} +function seq(v, what) { + if (!Number.isSafeInteger(v) || v < 0) throw new Error(`${what} must be a non-negative integer`); + return v; +} diff --git a/tools/persona-discord-delivery-documents.mjs b/tools/persona-discord-delivery-documents.mjs deleted file mode 100644 index a99ff3b..0000000 --- a/tools/persona-discord-delivery-documents.mjs +++ /dev/null @@ -1,43 +0,0 @@ -export const requestType = "epiphany.persona_discord_delivery_request"; -export const requestSchema = "epiphany.persona_discord_delivery_request.v0"; -export const requestSigningPurpose = "epiphany.persona-discord.delivery-request.v0"; -export const requestIdentityDomain = "epiphany.persona-discord-delivery-request.identity.v0\0"; -export const requestSignatureDomain = "epiphany.persona-discord-delivery-request.signature.v0\0"; -export const receiptType = "bifrost.persona_discord_delivery_receipt"; -export const receiptSchema = "bifrost.persona_discord_delivery_receipt.v0"; -export const receiptSigningPurpose = "bifrost.persona-discord.delivery-receipt.v0"; -export const receiptIdentityDomain = "bifrost.persona-discord-delivery-receipt.identity.v0\0"; -export const receiptSignatureDomain = "bifrost.persona-discord-delivery-receipt.signature.v0\0"; -export const executionType = "bifrost.persona_discord_delivery_execution"; -export const executionSchema = "bifrost.persona_discord_delivery_execution.v0"; - -export function buildPersonaDiscordDeliveryDefinitions(defineDocumentType) { - const make=(type,schema,name,parse,members)=>defineDocumentType({type,schemaName:type,schemaId:schema,schemaVersion:schema,contentHash:schema,global:false,name,schema:{parse},members}); - return { - request:make(requestType,requestSchema,"requestId",parseRequest,requestMembers), - receipt:make(receiptType,receiptSchema,"receiptId",parseReceipt,receiptMembers), - execution:make(executionType,executionSchema,"requestId",parseExecution), - }; -} - -const requestMembers = ["schemaVersion","requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","replyToMessageId","content","contentSha256","issuedAt","expiresAt","privateStateExposed","signerIdentityId","signerSignature"].map((memberName,slot)=>({slot,memberName,typeName:memberName==="privateStateExposed"?"boolean":memberName==="signerSignature"?"byte[]":"string",isName:memberName==="requestId"})); -const receiptMembers = ["schemaVersion","receiptId","requestId","requestPayloadSha256","status","channelId","replyToMessageId","messageId","transport","crossingReceiptId","receiptUrl","completedAt","providerIdentityId","privateStateExposed","providerSignature"].map((memberName,slot)=>({slot,memberName,typeName:memberName==="privateStateExposed"?"boolean":memberName==="providerSignature"?"byte[]":"string",isName:memberName==="receiptId"})); - -export function requestSigningTuple(v){return [v.schemaVersion,v.requestId,v.effectDocumentId,v.targetRuntimeId,v.personaAgentId,v.channelId,v.replyToMessageId,v.content,v.contentSha256,v.issuedAt,v.expiresAt,v.privateStateExposed,v.signerIdentityId];} -export function receiptSigningTuple(v){return [v.schemaVersion,v.receiptId,v.requestId,v.requestPayloadSha256,v.status,v.channelId,v.replyToMessageId,v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl,v.completedAt,v.providerIdentityId,v.privateStateExposed];} -export function receiptDocumentTuple(v){return [...receiptSigningTuple(v),v.providerSignature];} - -export function parseRequest(value){const v=typed(value,requestMembers,"Persona Discord delivery request");closed(v,["schemaName","schemaVersion","requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","replyToMessageId","content","contentSha256","issuedAt","expiresAt","privateStateExposed","signerIdentityId","signerSignature"],"request");exact(v.schemaVersion,requestSchema,"request schema");for(const key of ["requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","contentSha256","issuedAt","expiresAt","signerIdentityId"])text(v[key],key);content(v.content);optionalText(v.replyToMessageId,"replyToMessageId");if(v.privateStateExposed!==false)throw new Error("request privateStateExposed must be false.");bytes64(v.signerSignature,"signerSignature");sha(v.contentSha256,"contentSha256");const issued=date(v.issuedAt,"issuedAt"),expires=date(v.expiresAt,"expiresAt");if(expires<=issued||expires-issued>120000)throw new Error("request lifetime must be positive and at most 120 seconds.");return v;} -export function parseReceipt(value){const v=typed(value,receiptMembers,"Persona Discord delivery receipt");exact(v.schemaVersion,receiptSchema,"receipt schema");for(const key of ["receiptId","requestId","requestPayloadSha256","status","channelId","completedAt","providerIdentityId"])text(v[key],key);for(const key of ["replyToMessageId","messageId","transport","crossingReceiptId","receiptUrl"])optionalText(v[key],key);if(v.receiptId!==v.requestId)throw new Error("receiptId must equal requestId.");if(!["completed","failed","unknown"].includes(v.status))throw new Error("receipt status is invalid.");if(v.status==="completed"&&[v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl].some(x=>!x))throw new Error("completed receipt lacks exact delivery evidence.");if(v.status!=="completed"&&[v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl].some(Boolean))throw new Error("non-completed receipt may not claim delivery evidence.");if(v.privateStateExposed!==false)throw new Error("receipt privateStateExposed must be false.");bytes64(v.providerSignature,"providerSignature");sha(v.requestPayloadSha256,"requestPayloadSha256");date(v.completedAt,"completedAt");return v;} -export function parseExecution(value){const v=record(value,"Persona Discord execution journal");exact(v.schemaVersion,executionSchema,"execution schema");for(const key of ["requestId","requestPayloadSha256","status","recordedAt"])text(v[key],key);if(!["permit_pending","running","completed","failed","unknown"].includes(v.status))throw new Error("execution status is invalid.");if(v.status==="permit_pending"){text(v.permitNonce,"permitNonce");date(v.permitIssuedAt,"permitIssuedAt");date(v.permitExpiresAt,"permitExpiresAt");if(Date.parse(v.permitExpiresAt)<=Date.parse(v.permitIssuedAt)||Date.parse(v.permitExpiresAt)-Date.parse(v.permitIssuedAt)>5000)throw new Error("permit intent lifetime is invalid");}return v;} -function record(v,label){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error(`${label} must be an object.`);return v;} -function typed(v,members,label){if(Array.isArray(v)){if(v.length!==members.length)throw new Error(`${label} tuple has the wrong arity.`);return Object.fromEntries(members.map(member=>[member.memberName,member.typeName==="byte[]"&&Array.isArray(v[member.slot])?Uint8Array.from(v[member.slot]):v[member.slot]]));}return record(v,label);} -function text(v,label){bounded(v,label,256);} -function bounded(v,label,maxBytes){if(typeof v!=="string"||!v.trim()||Buffer.byteLength(v,"utf8")>maxBytes||/\p{Cc}/u.test(v))throw new Error(`${label} is empty, oversized, or contains control characters.`);} -function content(v){if(typeof v!=="string"||!v.trim()||Buffer.byteLength(v,"utf8")>1900||v.includes("\0"))throw new Error("content is empty, exceeds 1900 UTF-8 bytes, or contains NUL.");} -function optionalText(v,label){if(typeof v!=="string")throw new Error(`${label} must be a string.`);} -function exact(v,w,label){if(v!==w)throw new Error(`${label} must be ${w}.`);} -function bytes64(v,label){if(!(v instanceof Uint8Array)||v.length!==64)throw new Error(`${label} must be 64 bytes.`);} -function sha(v,label){if(!/^sha256-[0-9a-f]{64}$/.test(v))throw new Error(`${label} must be lowercase sha256.`);} -function date(v,label){const parsed=Date.parse(v);if(Number.isNaN(parsed))throw new Error(`${label} must be RFC3339.`);return parsed;} -function closed(v,keys,label){const allowed=new Set(keys);for(const key of Object.keys(v))if(!allowed.has(key))throw new Error(`${label} contains unknown field ${key}.`);} diff --git a/tools/persona-discord-delivery.mjs b/tools/persona-discord-delivery.mjs deleted file mode 100644 index 832bf66..0000000 --- a/tools/persona-discord-delivery.mjs +++ /dev/null @@ -1,52 +0,0 @@ -import {createHash,randomBytes} from "node:crypto"; -import {spawnSync} from "node:child_process"; -import {access,readFile,writeFile,mkdir} from "node:fs/promises"; -import {constants} from "node:fs"; -import {dirname,resolve} from "node:path"; -import {enrollDomainIdentity,loadDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "./bifrost-signed-identity.mjs"; -import {buildPersonaDiscordDeliveryDefinitions,executionSchema,receiptIdentityDomain,receiptSchema,receiptSignatureDomain,receiptSigningPurpose,receiptSigningTuple,requestSignatureDomain,requestSigningPurpose,requestSigningTuple} from "./persona-discord-delivery-documents.mjs"; -import {requestPersonaDiscordPermit} from "./persona-discord-permit.mjs"; -import {permitRequestIdentityDomain,permitRequestPurpose,permitRequestSchema} from "./persona-discord-permit.mjs"; - -export async function enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath,trustAnchorOut}){const identity=await enrollDomainIdentity(privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain),anchor=["gamecult.service_trust_anchor.v1","bifrost-persona-discord-delivery:bifrost-discord-yggdrasil:v0","bifrost-persona-discord-delivery","bifrost-discord-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",receiptSigningPurpose,"bifrost.persona_discord_delivery_receipt.v0","root",Date.now(),null,false];await mkdir(dirname(trustAnchorOut),{recursive:true});await writeFile(trustAnchorOut,runtime.encode(anchor),{flag:"wx",mode:0o640});return {identityId:identity.identityId,privateKeyPath,trustAnchorOut};} -export async function exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath,trustAnchorOut}){let identity;try{await access(privateKeyPath,constants.F_OK);identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;identity=await enrollDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain);}const anchor=["gamecult.service_trust_anchor.v1","bifrost-persona-discord-delivery-permit-request:bifrost-discord-yggdrasil:v0","bifrost-persona-discord-delivery","bifrost-discord-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",permitRequestPurpose,permitRequestSchema,"root",Date.now(),null,false];await mkdir(dirname(trustAnchorOut),{recursive:true});await writeFile(trustAnchorOut,runtime.encode(anchor),{flag:"wx",mode:0o640});return {identityId:identity.identityId,privateKeyPath,trustAnchorOut};} - -export async function processPersonaDiscordDeliveries(options){ - const {runtime,requestStorePath,receiptStorePath,executionStorePath,epiphanyTrustAnchorPath,privateKeyPath,targetAdmitted}=options,defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType); - requireSeparate([requestStorePath,receiptStorePath,executionStorePath]); - const requests=await open(runtime,defs.request,requestStorePath),receipts=await open(runtime,defs.receipt,receiptStorePath),executions=await open(runtime,defs.execution,executionStorePath),anchor=await readAnchor(runtime,epiphanyTrustAnchorPath,options.epiphanyRuntimeId),identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain); - const results=[]; - for(const request of requests.cache.getAll(defs.request).sort((a,b)=>String(a.issuedAt).localeCompare(String(b.issuedAt)))){ - const unsigned=runtime.encode(requestSigningTuple(request)),digest=`sha256-${sha(unsigned)}`,existingReceipt=unwrap(receipts.cache.get(defs.receipt,request.requestId)),existingExecution=unwrap(executions.cache.get(defs.execution,request.requestId)); - if(existingReceipt){if(existingReceipt.requestPayloadSha256!==digest)throw new Error(`Persona Discord request ${request.requestId} conflicts with its terminal receipt.`);continue;} - if(existingExecution){if(existingExecution.requestPayloadSha256!==digest)throw new Error(`Persona Discord request ${request.requestId} conflicts with its execution journal.`);if(existingExecution.status==="running"){results.push(await terminal("unknown",request,digest,"delivery outcome was indeterminate after service interruption",{runtime,defs,receipts,executions,identity}));continue;}if(existingExecution.status!=="permit_pending")continue;} - let failure=""; - try{validateRequest(request,unsigned,anchor,targetAdmitted);}catch(error){failure=error instanceof Error?error.message:String(error);} - if(failure){results.push(await terminal("failed",request,digest,failure,{runtime,defs,receipts,executions,identity}));continue;} - if(existingExecution&&Date.parse(existingExecution.permitExpiresAt)now+30_000)throw new Error("request issuance is in the future");if(expires<=issued||expires120_000)throw new Error("request lifetime exceeds 120 seconds");if(!targetAdmitted(request))throw new Error("request target is not an exact configured Bifrost Persona binding");} -function post(request,options){const args=[options.bridgeCliPath,"discord-post","--cultmesh-command-id",request.requestId,"--channel-id",request.channelId,"--content",request.content,"--identity",request.personaAgentId,"--source-kind","epiphany_persona_speech","--source-id",request.effectDocumentId,"--authority-ref","epiphany.persona_discord_delivery_request.v0","--epiphany-lane-id","Persona","--epiphany-agent-identity",request.personaAgentId,"--receipt-store",options.crossingReceiptStorePath??resolve(dirname(options.executionStorePath),"persona-discord-crossing-receipts.cc")];if(request.replyToMessageId)args.push("--reply-to-message-id",request.replyToMessageId);const capability=process.env.HEIMDALL_CAPABILITY_REF||process.env.BIFROST_HEIMDALL_CAPABILITY_REF;if(capability)args.push("--heimdall-capability-ref",capability);const result=spawnSync(process.execPath,args,{cwd:options.bifrostRoot,encoding:"utf8",windowsHide:true,env:process.env});if(result.status!==0||result.error)throw new Error(`Bifrost Discord transport returned indeterminate outcome: ${result.error?.message||result.stderr||result.stdout}`);const value=JSON.parse(result.stdout);if(value.action!=="discord-post"||value.ok!==true||value.channelId!==request.channelId||!value.messageId||!value.crossingReceiptId||!value.url)throw new Error("Bifrost Discord transport did not return exact delivery evidence");return value;} -async function terminal(status,request,digest,detail,c){const now=new Date().toISOString(),posted=c.posted??{},receipt={schemaName:"bifrost.persona_discord_delivery_receipt",schemaVersion:receiptSchema,receiptId:request.requestId,requestId:request.requestId,requestPayloadSha256:digest,status,channelId:request.channelId,replyToMessageId:request.replyToMessageId,messageId:status==="completed"?posted.messageId:"",transport:status==="completed"?posted.transport:"",crossingReceiptId:status==="completed"?posted.crossingReceiptId:"",receiptUrl:status==="completed"?posted.url:"",completedAt:now,providerIdentityId:c.identity.identityId,privateStateExposed:false,providerSignature:new Uint8Array()};receipt.providerSignature=new Uint8Array(signDomainPurpose(c.identity,receiptSignatureDomain,receiptSigningPurpose,c.runtime.encode(receiptSigningTuple(receipt))));await c.receipts.cache.put(c.defs.receipt,receipt.receiptId,receipt);await c.executions.cache.put(c.defs.execution,request.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:request.requestId,requestPayloadSha256:digest,status,recordedAt:now,detail});return receipt;} -async function open(runtime,definition,path){await mkdir(dirname(path),{recursive:true});const cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();return {cache};} -async function accessStoreOrWritableParent(path,mode){try{await access(path,mode);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;await access(dirname(path),constants.W_OK);}} -async function readAnchor(runtime,path,expectedRuntimeId){if(typeof expectedRuntimeId!=="string"||!expectedRuntimeId)throw new Error("Epiphany runtime identity is required for Persona mouth trust");const value=runtime.decode(await readFile(path));if(!Array.isArray(value)||value.length!==13)throw new Error("Epiphany Persona mouth trust anchor must be the exact service-contract tuple");const [schemaVersion,trustAnchorId,serviceId,runtimeId,identityId,rawPublicKey,algorithm,purpose,signedSchema,bindingAuthority,boundAt,expiresAt,privateStateExposed]=value,publicKey=rawPublicKey instanceof Uint8Array?rawPublicKey:Array.isArray(rawPublicKey)?Uint8Array.from(rawPublicKey):null,now=Date.now();if(schemaVersion!=="gamecult.service_trust_anchor.v1"||typeof trustAnchorId!=="string"||serviceId!=="epiphany-persona-mouth"||runtimeId!==expectedRuntimeId||typeof identityId!=="string"||!publicKey||publicKey.length!==32||algorithm!=="ed25519"||purpose!==requestSigningPurpose||signedSchema!=="epiphany.persona_discord_delivery_request.v0"||bindingAuthority!=="root"||!Number.isSafeInteger(boundAt)||boundAt<=0||boundAt>now||(expiresAt!==null&&(!Number.isSafeInteger(expiresAt)||expiresAt<=boundAt||expiresAt<=now))||privateStateExposed!==false)throw new Error("Epiphany Persona mouth service trust anchor is invalid");return {identityId,publicKey};} -function requireSeparate(paths){const keys=paths.map(p=>resolve(p).toLowerCase());if(new Set(keys).size!==keys.length)throw new Error("Persona Discord crossing and private execution stores must be distinct.");} -function sha(value){return createHash("sha256").update(value).digest("hex");} -function unwrap(value){return Array.isArray(value)?value[0]:value?.value??value;} diff --git a/tools/persona-discord-permit.mjs b/tools/persona-discord-permit.mjs deleted file mode 100644 index 9d14e77..0000000 --- a/tools/persona-discord-permit.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import {createHash,randomBytes} from "node:crypto"; -import {createSocket} from "node:dgram"; -import {createRequire} from "node:module"; -import {existsSync} from "node:fs"; -import {readFile} from "node:fs/promises"; -import {resolve} from "node:path"; -import {loadDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "./bifrost-signed-identity.mjs"; - -export const permitRequestSchema="bifrost.persona_discord_delivery_permit_request.v0"; -export const permitSchema="epiphany.persona_discord_delivery_permit.v0"; -export const permitRequestPurpose="bifrost.persona-discord.delivery-permit-request.v0"; -export const permitPurpose="epiphany.persona-discord.delivery-permit.v0"; -export const permitRequestIdentityDomain="bifrost.persona-discord-delivery-permit-request.identity.v0\0"; -export const permitRequestSignatureDomain="bifrost.persona-discord-delivery-permit-request.signature.v0\0"; -export const permitIdentityDomain="epiphany.persona-discord-delivery-permit.identity.v0\0"; -export const permitSignatureDomain="epiphany.persona-discord-delivery-permit.signature.v0\0"; -const connectionId=0xe91f0002; - -export const permitRequestTuple=v=>[v.schemaVersion,v.requestId,v.requestPayloadSha256,v.targetRuntimeId,v.nonce,v.issuedAt,v.expiresAt,v.requesterIdentityId,v.privateStateExposed]; -export const permitTuple=v=>[v.schemaVersion,v.permitId,v.requestId,v.requestPayloadSha256,v.targetRuntimeId,v.nonce,v.requesterIdentityId,v.brakeStateDocumentId,v.brakeStateDocumentSha256,v.brakeObservedAt,v.issuedAt,v.expiresAt,v.providerIdentityId,v.privateStateExposed]; -export const permitRequestWireTuple=v=>[...permitRequestTuple(v),v.requesterSignature]; -export function parsePermitWireTuple(v){if(!Array.isArray(v)||v.length!==15)throw new Error("Epiphany permit must be an exact Rust wire tuple");const [schemaVersion,permitId,requestId,requestPayloadSha256,targetRuntimeId,nonce,requesterIdentityId,brakeStateDocumentId,brakeStateDocumentSha256,brakeObservedAt,issuedAt,expiresAt,providerIdentityId,privateStateExposed,providerSignature]=v;return {schemaVersion,permitId,requestId,requestPayloadSha256,targetRuntimeId,nonce,requesterIdentityId,brakeStateDocumentId,brakeStateDocumentSha256,brakeObservedAt,issuedAt,expiresAt,providerIdentityId,privateStateExposed,providerSignature};} - -export async function requestPersonaDiscordPermit({endpoint,requestId,requestPayloadSha256,targetRuntimeId,nonce,issuedAt,expiresAt,privateKeyPath,epiphanyPermitAnchorPath,timeoutMs=5000,runtime:provided,socketFactory=createSocket,connectionFactory}){ - const runtime=provided??loadRuntime(),identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain),anchor=await readAnchor(runtime,epiphanyPermitAnchorPath,targetRuntimeId),now=Date.now(); - const request={schemaVersion:permitRequestSchema,requestId,requestPayloadSha256,targetRuntimeId,nonce:nonce??randomBytes(16).toString("hex"),issuedAt:issuedAt??new Date(now).toISOString(),expiresAt:expiresAt??new Date(now+5000).toISOString(),requesterIdentityId:identity.identityId,privateStateExposed:false,requesterSignature:new Uint8Array()}; - request.requesterSignature=new Uint8Array(signDomainPurpose(identity,permitRequestSignatureDomain,permitRequestPurpose,runtime.msgpack.encode(permitRequestTuple(request)))); - const {host,port}=parseEndpoint(endpoint),socket=socketFactory("udp4");await bind(socket);const connection=(connectionFactory??(o=>new runtime.cultnet.CultNetRudpSocketTransportConnection(o)))({runtimeId:"bifrost-discord-yggdrasil",socket,mode:"client",remoteHost:host,remotePort:port,connectionId,resendDelayMs:50,resendPollMs:10}); - try{connection.connect();await connected(connection,timeoutMs);const response=wait(connection,runtime.msgpack,timeoutMs,request);const message={schemaVersion:"cultnet.document_put_raw.v0",messageId:`permit-request-${requestId}`,document:{schemaId:permitRequestSchema,recordKey:requestId,storedAt:request.issuedAt,payloadEncoding:"messagepack",payload:runtime.msgpack.encode(permitRequestWireTuple(request)),sourceRuntimeId:"bifrost-discord-yggdrasil",sourceAgentId:identity.identityId,sourceRole:"bifrost-persona-discord-delivery",tags:["cultnet.transport.rudp.v0"]}};connection.send("schema",runtime.msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire(message,"cultnet.schema.v0")));const permit=await response;validatePermit(permit,request,anchor,runtime.msgpack.encode);return permit;}finally{connection.close();} -} - -export function validatePermit(value,request,anchor,encode){const v=Array.isArray(value)?parsePermitWireTuple(value):value,providerSignature=v?.providerSignature instanceof Uint8Array?v.providerSignature:Array.isArray(v?.providerSignature)?Uint8Array.from(v.providerSignature):null;if(!v||v.schemaVersion!==permitSchema||v.requestId!==request.requestId||v.requestPayloadSha256!==request.requestPayloadSha256||v.targetRuntimeId!==request.targetRuntimeId||v.nonce!==request.nonce||v.requesterIdentityId!==request.requesterIdentityId||v.providerIdentityId!==anchor.identityId||v.privateStateExposed!==false||!/^sha256-[0-9a-f]{64}$/.test(v.brakeStateDocumentSha256)||!providerSignature||providerSignature.length!==64)throw new Error("Epiphany permit substituted delivery authority");const issued=Date.parse(v.issuedAt),expires=Date.parse(v.expiresAt),observed=Date.parse(v.brakeObservedAt),now=Date.now();if(!Number.isFinite(issued)||!Number.isFinite(expires)||!Number.isFinite(observed)||expires<=issued||expires-issued>5000||expiresissued||issued>now+1000)throw new Error("Epiphany permit is stale or has an invalid lifetime");if(!verifyDomainPurpose(anchor.publicKey,permitSignatureDomain,permitPurpose,encode(permitTuple(v)),providerSignature))throw new Error("Epiphany permit signature is invalid");return {...v,providerSignature};} -export function parseEpiphanyPermitAnchorTuple(a,expectedRuntimeId,now=Date.now()){if(!Array.isArray(a)||a.length!==13)throw new Error("Epiphany permit anchor must be an exact service anchor");const [schemaVersion,,serviceId,runtimeId,identityId,rawPublicKey,algorithm,purpose,signedSchema,authority,boundAt,expiresAt,privateStateExposed]=a,publicKey=rawPublicKey instanceof Uint8Array?rawPublicKey:Array.isArray(rawPublicKey)?Uint8Array.from(rawPublicKey):null;if(schemaVersion!=="gamecult.service_trust_anchor.v1"||serviceId!=="epiphany-persona-discord-permit"||runtimeId!==expectedRuntimeId||!publicKey||publicKey.length!==32||algorithm!=="ed25519"||purpose!==permitPurpose||signedSchema!==permitSchema||authority!=="root"||boundAt>now||(expiresAt!==null&&expiresAt<=now)||privateStateExposed!==false)throw new Error("Epiphany permit anchor is invalid");return {identityId,publicKey};} -async function readAnchor(runtime,path,expectedRuntimeId){return parseEpiphanyPermitAnchorTuple(runtime.msgpack.decode(await readFile(path)),expectedRuntimeId);} -function wait(c,m,t,r){return new Promise((ok,no)=>{const done=(f,v)=>{clearTimeout(timer);c.off?.("frame",frame);c.off?.("error",error);f(v)},error=e=>done(no,e),frame=x=>{try{if(x.channelId!=="schema")throw new Error("permit arrived on foreign channel");const w=m.decode(x.payload),d=w?.document,p=parsePermitWireTuple(m.decode(d?.payload));if(w?.schemaVersion!=="cultnet.document_put_raw.v0"||d?.schemaId!==permitSchema||d?.recordKey!==`permit:${r.requestPayloadSha256}`||d?.payloadEncoding!=="messagepack"||d?.sourceRuntimeId!==r.targetRuntimeId||d?.sourceRole!=="epiphany-persona-discord-permit"||d?.sourceAgentId!==p.providerIdentityId)throw new Error("permit envelope substituted authority");done(ok,p)}catch(e){done(no,e)}};const timer=setTimeout(()=>done(no,new Error("Timed out waiting for Epiphany Persona Discord permit")),t);c.on("frame",frame);c.on("error",error)})} -async function connected(c,t){const s=Date.now();while(!c.connected){if(Date.now()-s>=t)throw new Error("Timed out establishing permit RUDP session");await new Promise(r=>setTimeout(r,5));}} -function bind(s){return new Promise((ok,no)=>{s.once("error",no);s.once("listening",ok);s.bind(0,"0.0.0.0")})} -function parseEndpoint(v){const u=new URL(v);if(u.protocol!=="rudp:"||!u.hostname||!u.port)throw new Error("permit endpoint must be rudp://host:port");return {host:u.hostname,port:Number(u.port)}} -function loadRuntime(){const root=resolve(import.meta.dirname,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(root,"..","CultLib")),pkg=resolve(cult,"packages","cultnet-ts","package.json");if(!existsSync(pkg))throw new Error("CultNet runtime unavailable");const req=createRequire(pkg);return {cultnet:req("cultnet-ts"),msgpack:req("@msgpack/msgpack")};} diff --git a/tools/persona-discord-rudp.mjs b/tools/persona-discord-rudp.mjs deleted file mode 100644 index d3ebfe6..0000000 --- a/tools/persona-discord-rudp.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import {createSocket} from "node:dgram"; -import {parseRequest,receiptDocumentTuple} from "./persona-discord-delivery-documents.mjs"; - -export const personaDiscordDeliveryConnectionId=0xe91f0003; - -export async function startPersonaDiscordDeliveryRudpServer({runtime,endpoint,runtimeId="bifrost-discord-yggdrasil",onRequest,socketFactory=createSocket,connectionFactory}){ - const {host,port}=parseEndpoint(endpoint),socket=socketFactory("udp4"); - await bind(socket,host,port); - const connection=(connectionFactory??(options=>new runtime.cultnet.CultNetRudpSocketTransportConnection(options)))({runtimeId,socket,mode:"server",connectionId:personaDiscordDeliveryConnectionId,initialSequence:100,resendDelayMs:50,resendPollMs:10}); - let busy=false;const diagnostics={datagrams:0,frames:0,responses:0,errors:[]};socket.on("message",()=>diagnostics.datagrams++); - const frame=async value=>{ - diagnostics.frames++; - if(busy)return; - busy=true; - try{ - if(value.channelId!=="schema")throw new Error("Persona delivery request arrived on a foreign channel"); - const message=runtime.cultnet.parseCultNetMessage(runtime.msgpack.decode(value.payload),"cultnet.schema.v0"),document=message.document; - if(message.schemaVersion!=="cultnet.document_put_raw.v0"||document.schemaId!=="epiphany.persona_discord_delivery_request.v0"||document.payloadEncoding!=="messagepack"||document.sourceRole!=="epiphany-persona-mouth"||document.sourceRuntimeId!=="epiphany-starfire"||!Array.isArray(document.tags)||document.tags.length!==1||document.tags[0]!=="cultnet.transport.rudp.v0")throw new Error("Persona delivery request envelope substituted authority"); - const request=parseRequest(runtime.msgpack.decode(document.payload)); - if(document.recordKey!==request?.requestId||document.sourceAgentId!==request?.signerIdentityId||request?.targetRuntimeId!=="epiphany-starfire")throw new Error("Persona delivery request envelope is not bound to its signed payload"); - const receipt=await onRequest(request); - const response={schemaVersion:"cultnet.document_put_raw.v0",messageId:`persona-delivery-receipt-${receipt.requestId}`,document:{schemaId:"bifrost.persona_discord_delivery_receipt.v0",recordKey:receipt.requestId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:runtime.msgpack.encode(receiptDocumentTuple(receipt)),sourceRuntimeId:runtimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"bifrost-persona-discord-delivery",tags:["cultnet.transport.rudp.v0"]}}; - connection.send("schema",runtime.msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire(response,"cultnet.schema.v0")));diagnostics.responses++; - }catch(error){const message=error instanceof Error?error.message:String(error);diagnostics.errors.push(message);console.error(`Bifrost Persona delivery RUDP rejected frame: ${message}`);}finally{busy=false;} - }; - connection.on("frame",frame); - return {address:socket.address(),diagnostics,close(){connection.off?.("frame",frame);connection.close();}}; -} - -function parseEndpoint(value){const url=new URL(value);if(url.protocol!=="rudp:"||!url.hostname||!url.port)throw new Error("Persona delivery listener must be rudp://host:port");return {host:url.hostname,port:Number(url.port)};} -function bind(socket,host,port){return new Promise((resolve,reject)=>{socket.once("error",reject);socket.once("listening",resolve);socket.bind(port,host);});} diff --git a/tools/persona-feedback.mjs b/tools/persona-feedback.mjs index b586a58..ea8e185 100644 --- a/tools/persona-feedback.mjs +++ b/tools/persona-feedback.mjs @@ -8,18 +8,12 @@ import { buildFeedbackDefinitions, actorLinkSchema, feedbackBindingSchema, feedb import { defaultFeedbackIdentityPath, enrollBifrostFeedbackIdentity, loadBifrostFeedbackIdentity, providerHealthTrustAnchor, signFeedbackAdmission, trustAnchor } from "./bifrost-feedback-identity.mjs"; import { createPersonaFeedbackHealthPublisher, publishPersonaFeedbackHealth } from "./persona-feedback-idunn-health.mjs"; import { discordGatewayReady, serveDiscordPersonaIngress } from "./bifrost-discord-persona-ingress.mjs"; -import { enrollPersonaDiscordDeliveryIdentity, exportPersonaDiscordPermitRequestAnchor, inspectPersonaDiscordDeliveryReadiness, processPersonaDiscordDeliveries } from "./persona-discord-delivery.mjs"; -import { startPersonaDiscordDeliveryRudpServer } from "./persona-discord-rudp.mjs"; -import { buildPersonaDiscordDeliveryDefinitions, parseRequest as parsePersonaDiscordDeliveryRequest } from "./persona-discord-delivery-documents.mjs"; const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib")); const [command,...rawArgs]=process.argv.slice(2),options=parseArgs(rawArgs); const privateStorePath=resolve(options.store||(process.platform==="win32"?resolve(root,".bifrost","provider-store.cc"):"/var/lib/gamecult/bifrost/persona-feedback/provider.cc")); const observationStorePath=resolve(options["observation-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-conversation-observations.cc"):"/srv/bifrost/persona-feedback/observations.cc")); const deliveryStorePath=resolve(options["delivery-store"]||(process.platform==="win32"?resolve(root,".bifrost","epiphany-persona-feedback.cc"):"/srv/bifrost/persona-feedback/deliveries.cc")); -const personaDeliveryRequestStorePath=resolve(options["persona-delivery-request-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-discord-requests.cc"):"/srv/bifrost/persona-discord-crossing/requests.cc")); -const personaDeliveryReceiptStorePath=resolve(options["persona-delivery-receipt-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-discord-receipts.cc"):"/srv/bifrost/persona-discord-crossing/receipts.cc")); -const personaDeliveryExecutionStorePath=resolve(options["persona-delivery-execution-store"]||(process.platform==="win32"?resolve(root,".bifrost","private","persona-discord-executions.cc"):"/var/lib/gamecult/bifrost/persona-feedback/persona-discord-executions.cc")); const runtime=loadRuntime(),defs=buildFeedbackDefinitions(runtime.defineDocumentType); try{await main();}catch(error){console.error(error instanceof Error?error.message:String(error));process.exitCode=1;} @@ -30,14 +24,11 @@ async function main(){ if(command==="process")return processEvent(requireOption("event-id")); if(command==="serve")return serve(); if(command==="serve-discord")return serveDiscord(); - if(command==="serve-persona-delivery")return servePersonaDelivery(); if(command==="export")return exportSnapshot(); if(command==="enroll-identity")return enrollIdentity(); - if(command==="enroll-persona-delivery-identity")return enrollPersonaDeliveryIdentity(); - if(command==="export-persona-permit-request-anchor")return exportPersonaPermitRequestAnchor(); if(command==="export-provider-health-anchor")return exportProviderHealthAnchor(); if(command==="status")return status(); - throw new Error("Usage: persona-feedback.mjs bind-target|link-actor|process|serve|serve-discord|serve-persona-delivery|status|export|enroll-identity|enroll-persona-delivery-identity|export-provider-health-anchor"); + throw new Error("Usage: persona-feedback.mjs bind-target|link-actor|process|serve|serve-discord|status|export|enroll-identity|export-provider-health-anchor"); } async function privateNode(){return runtime.CultMesh.createNode(privateStorePath,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery,defs.pendingFailure]});} async function observationNode(){return runtime.CultMesh.createNode(observationStorePath,{documents:[defs.event]});} @@ -77,19 +68,14 @@ async function serveDiscord(){ const publishHealth=async()=>{const value=await readiness(),gatewayReady=discordGatewayReady(gatewayState),active=value.status==="ready"&&gatewayReady;await publishPersonaFeedbackHealth({endpoint:healthEndpoint,daemonId:"yggdrasil-bifrost-persona-feedback",healthContract:"bifrost.cultnet-rudp-persona-feedback-health",state:active?"active":"degraded",detail:active?"ready":"readiness-degraded",publisher:healthPublisher,publishUnsignedDiagnostic:true});return {...value,gatewayReady,gatewayState};}; const initial=await readiness();if(!initial.ingressReady)throw new Error(`Bifrost Discord Persona ingress is not ready: ${initial.ingressReasons.join("; ")}`); const recovered=await drainPendingObservations();if(recovered.some(value=>!value.ok))console.error(`Bifrost Persona ingress startup recovery retained ${recovered.filter(value=>!value.ok).length} failed observation(s) for reconnect retry.`); - await drainPersonaDeliveries().catch(error=>console.error(`Bifrost Persona delivery is staged but not yet ready: ${error instanceof Error?error.message:String(error)}`)); await publishHealth(); const healthTimer=setInterval(()=>publishHealth().catch(error=>console.error(`Bifrost Persona-feedback health publication failed: ${error instanceof Error?error.message:String(error)}`)),10000); let observationBusy=false;const observationTimer=setInterval(async()=>{if(observationBusy)return;observationBusy=true;try{const retried=await drainPendingObservations();if(retried.some(value=>!value.ok))console.error(`Bifrost Persona ingress retained ${retried.filter(value=>!value.ok).length} failed pending observation(s).`);}catch(error){console.error(`Bifrost Persona observation reconciliation failed: ${error instanceof Error?error.message:String(error)}`);}finally{observationBusy=false;}},positiveInteger(options["observation-retry-interval-ms"]||"5000","observation-retry-interval-ms")); - let deliveryBusy=false;const deliveryTimer=setInterval(async()=>{if(deliveryBusy)return;deliveryBusy=true;try{await drainPersonaDeliveries();}catch(error){console.error(`Bifrost Persona delivery poll failed: ${error instanceof Error?error.message:String(error)}`);}finally{deliveryBusy=false;}},positiveInteger(options["persona-delivery-interval-ms"]||"500","persona-delivery-interval-ms")); - try{await serveDiscordPersonaIngress({token,runtimeId:producerRuntimeId,onConversation:conversation=>admitDiscordConversation(conversation,producerRuntimeId),onReady:async state=>{const drained=await drainPendingObservations();print({schemaVersion:"bifrost.discord.persona_ingress_status.v0",status:drained.every(value=>value.ok)?"ready":"degraded",runtimeId:state.runtimeId,botUserId:state.botUserId,recoveredObservationCount:drained.length,privateStateExposed:false});},onState:state=>{gatewayState=state;},onDisconnect:error=>console.error(`Bifrost Discord Persona ingress reconnecting: ${error instanceof Error?error.message:String(error)}`)});}finally{gatewayState={connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"stopped"};await publishHealth().catch(()=>{});clearInterval(healthTimer);clearInterval(observationTimer);clearInterval(deliveryTimer);} + try{await serveDiscordPersonaIngress({token,runtimeId:producerRuntimeId,onConversation:conversation=>admitDiscordConversation(conversation,producerRuntimeId),onReady:async state=>{const drained=await drainPendingObservations();print({schemaVersion:"bifrost.discord.persona_ingress_status.v0",status:drained.every(value=>value.ok)?"ready":"degraded",runtimeId:state.runtimeId,botUserId:state.botUserId,recoveredObservationCount:drained.length,privateStateExposed:false});},onState:state=>{gatewayState=state;},onDisconnect:error=>console.error(`Bifrost Discord Persona ingress reconnecting: ${error instanceof Error?error.message:String(error)}`)});}finally{gatewayState={connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"stopped"};await publishHealth().catch(()=>{});clearInterval(healthTimer);clearInterval(observationTimer);} } -async function servePersonaDelivery(){const readinessState=await readiness();if(!readinessState.personaDelivery.ready)throw new Error(`Bifrost Persona delivery is not ready: ${readinessState.personaDelivery.reasons.join("; ")}`);const server=await startPersonaDiscordDeliveryRudpServer({runtime,endpoint:requireOption("persona-delivery-rudp-listen"),onRequest:acceptPersonaDeliveryRequest});print({schemaVersion:"bifrost.persona_discord_delivery_listener.v0",status:"ready",runtimeId:"bifrost-discord-yggdrasil",host:server.address.address,port:server.address.port,requestSignerIdentityId:readinessState.personaDelivery.requestSignerIdentityId,receiptSignerIdentityId:readinessState.personaDelivery.receiptSignerIdentityId,privateStateExposed:false});await new Promise(()=>{});} -async function drainPersonaDeliveries(){const n=await privateNode();await n.cache?.pullAllBackingStores?.();const bindings=n.cache.getAll(defs.binding),epiphanyRuntimeId=requireOption("epiphany-runtime-id");return processPersonaDiscordDeliveries({runtime,permitRuntime:{cultnet:runtime.cultnet,msgpack:runtime.msgpack},requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyRuntimeId,epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key")),permitRequesterPrivateKeyPath:resolve(requireOption("persona-permit-request-private-key")),bridgeCliPath:resolve(root,"tools","bifrost-bridge.mjs"),bifrostRoot:root,targetAdmitted:request=>request.targetRuntimeId===epiphanyRuntimeId&&request.personaAgentId==="epiphany.Persona"&&bindings.some(binding=>binding.targetRuntimeId===request.targetRuntimeId&&binding.targetPersonaId==="epiphany"&&binding.channelId===request.channelId)});} -async function acceptPersonaDeliveryRequest(request){const deliveryDefs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requests=runtime.CultCache.builder().withDocumentType(deliveryDefs.request).withGenericStore(new runtime.SingleFileMessagePackBackingStore(personaDeliveryRequestStorePath)).build();await requests.pullAllBackingStores();const parsed=parsePersonaDiscordDeliveryRequest(request),existing=unwrap(requests.get(deliveryDefs.request,parsed.requestId));if(existing&&stable(existing)!==stable(parsed))throw new Error(`Persona delivery request ${parsed.requestId} conflicts with durable endpoint state.`);if(!existing)await requests.put(deliveryDefs.request,parsed.requestId,parsed);await drainPersonaDeliveries();const receipts=runtime.CultCache.builder().withDocumentType(deliveryDefs.receipt).withGenericStore(new runtime.SingleFileMessagePackBackingStore(personaDeliveryReceiptStorePath)).build();await receipts.pullAllBackingStores();const receipt=unwrap(receipts.get(deliveryDefs.receipt,parsed.requestId));if(!receipt)throw new Error(`Persona delivery request ${parsed.requestId} has no terminal Bifrost receipt yet.`);return receipt;} async function admitDiscordConversation(conversation,producerRuntimeId){ const privateNodeValue=await privateNode();await privateNodeValue.cache?.pullAllBackingStores?.(); @@ -104,11 +90,9 @@ async function admitDiscordConversation(conversation,producerRuntimeId){ } async function enrollIdentity(){const keyPath=resolve(options["private-key"]||defaultFeedbackIdentityPath()),identity=await enrollBifrostFeedbackIdentity(keyPath),createdAt=new Date().toISOString(),anchor=trustAnchor(identity,createdAt),out=resolve(requireOption("trust-anchor-out")),providerOut=options["provider-health-trust-anchor-out"]?resolve(options["provider-health-trust-anchor-out"]):null;await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode(anchorTuple(anchor)));if(providerOut){if(providerOut===out)throw new Error("Feedback and provider-health trust anchors must use distinct paths.");const providerAnchor=providerHealthTrustAnchor(identity,createdAt);await mkdir(dirname(providerOut),{recursive:true});await writeFile(providerOut,runtime.encode(anchorTuple(providerAnchor)));}print({privateKey:keyPath,trustAnchor:out,identityId:identity.identityId,providerHealthTrustAnchor:providerOut??""});} -async function enrollPersonaDeliveryIdentity(){print(await enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath:resolve(requireOption("private-key")),trustAnchorOut:resolve(requireOption("trust-anchor-out"))}));} -async function exportPersonaPermitRequestAnchor(){print(await exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath:resolve(requireOption("permit-request-private-key")),trustAnchorOut:resolve(requireOption("trust-anchor-out"))}));} async function exportProviderHealthAnchor(){const keyPath=resolve(options["private-key"]||defaultFeedbackIdentityPath()),identity=await loadBifrostFeedbackIdentity(keyPath),out=resolve(requireOption("trust-anchor-out")),anchor=providerHealthTrustAnchor(identity,new Date().toISOString());await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode(anchorTuple(anchor)),{flag:"wx",mode:0o640});print({privateKey:keyPath,trustAnchor:out,identityId:anchor.identityId});} async function exportSnapshot(){const n=await privateNode();await n.cache?.pullAllBackingStores?.();const out=resolve(requireOption("out")),scratch=`${out}.scratch.cc`,cache=runtime.CultCache.builder().withDocumentType(defs.delivery).withDocumentType(defs.receipt).withGenericStore(new runtime.SingleFileMessagePackBackingStore(scratch)).build();for(const value of n.cache.getAll(defs.delivery))await cache.put(defs.delivery,value.admissionId,value);for(const value of n.cache.getAll(defs.receipt))await cache.put(defs.receipt,value.receiptId,value);const registry=new runtime.CultNetDocumentRegistry([runtime.defineCultNetDocumentBinding({definition:defs.delivery,payloadSchemaVersion:feedbackDeliverySchema}),runtime.defineCultNetDocumentBinding({definition:defs.receipt,payloadSchemaVersion:feedbackReceiptSchema})]),message=registry.createRawSnapshotResponse(cache,options["message-id"]||`persona-feedback-${randomUUID()}`);await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode(message));await rm(scratch,{force:true});print({schemaVersion:message.schemaVersion,messageId:message.messageId,documentCount:message.documents.length,out});} -async function readiness(){const ingressReasons=[];let identity,bindingCount=0,pendingFailedCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){ingressReasons.push(`identity:${error instanceof Error?error.message:String(error)}`);}let bindings=[];try{const info=await stat(privateStorePath);if(!info.isFile())throw new Error("private provider store is not a regular file");const cache=runtime.CultCache.builder().withDocumentType(defs.binding).withDocumentType(defs.actorLink).withDocumentType(defs.receipt).withDocumentType(defs.delivery).withDocumentType(defs.pendingFailure).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;const admitted=new Set(cache.getAll(defs.receipt).map(value=>value.eventId));pendingFailedCount=cache.getAll(defs.pendingFailure).filter(value=>!admitted.has(value.eventId)).length;if(pendingFailedCount>0)ingressReasons.push(`pendingFailures:${pendingFailedCount}`);if(bindingCount===0)ingressReasons.push("bindings:none");}catch(error){ingressReasons.push(`privateStore:${error instanceof Error?error.message:String(error)}`);}for(const binding of bindings){try{await assertStoreSeparation(binding.deliveryStorePath);try{const info=await stat(binding.deliveryStorePath);if(!info.isFile())throw new Error("delivery path is not a regular file");await access(binding.deliveryStorePath,constants.W_OK);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;await access(dirname(binding.deliveryStorePath),constants.W_OK);}}catch(error){ingressReasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}const personaConfigured=command==="serve-discord"||command==="serve-persona-delivery"||Boolean(options["epiphany-persona-mouth-trust-anchor"]);let personaDelivery={ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",permitRequesterIdentityId:"",reasons:["not configured"]};if(personaConfigured){personaDelivery=await inspectPersonaDiscordDeliveryReadiness({runtime,requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyRuntimeId:requireOption("epiphany-runtime-id"),epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key")),permitRequesterPrivateKeyPath:resolve(requireOption("persona-permit-request-private-key"))});}const reasons=[...ingressReasons,...(personaConfigured?personaDelivery.reasons.map(reason=>`personaDelivery:${reason}`):[])],ingressReady=ingressReasons.length===0;return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,pendingFailedCount,ingressReady,ingressReasons,personaDelivery,status:ingressReady&&(!personaConfigured||personaDelivery.ready)?"ready":"degraded",reasons,privateStateExposed:false};} +async function readiness(){const ingressReasons=[];let identity,bindingCount=0,pendingFailedCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){ingressReasons.push(`identity:${error instanceof Error?error.message:String(error)}`);}let bindings=[];try{const info=await stat(privateStorePath);if(!info.isFile())throw new Error("private provider store is not a regular file");const cache=runtime.CultCache.builder().withDocumentType(defs.binding).withDocumentType(defs.actorLink).withDocumentType(defs.receipt).withDocumentType(defs.delivery).withDocumentType(defs.pendingFailure).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;const admitted=new Set(cache.getAll(defs.receipt).map(value=>value.eventId));pendingFailedCount=cache.getAll(defs.pendingFailure).filter(value=>!admitted.has(value.eventId)).length;if(pendingFailedCount>0)ingressReasons.push(`pendingFailures:${pendingFailedCount}`);if(bindingCount===0)ingressReasons.push("bindings:none");}catch(error){ingressReasons.push(`privateStore:${error instanceof Error?error.message:String(error)}`);}for(const binding of bindings){try{await assertStoreSeparation(binding.deliveryStorePath);try{const info=await stat(binding.deliveryStorePath);if(!info.isFile())throw new Error("delivery path is not a regular file");await access(binding.deliveryStorePath,constants.W_OK);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;await access(dirname(binding.deliveryStorePath),constants.W_OK);}}catch(error){ingressReasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}const reasons=[...ingressReasons],ingressReady=ingressReasons.length===0;return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,pendingFailedCount,ingressReady,ingressReasons,status:ingressReady?"ready":"degraded",reasons,privateStateExposed:false};} async function status(){print(await readiness());} function loadRuntime(){const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),mr=createRequire(resolve(cult,"packages","cultmesh-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("@gamecult/cultcache-ts"),cn=nr("cultnet-ts"),mp=nr("@msgpack/msgpack");return {CultMesh:mr("cultmesh-ts").CultMesh,CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,CultNetDocumentRegistry:cn.CultNetDocumentRegistry,defineCultNetDocumentBinding:cn.defineCultNetDocumentBinding,cultnet:cn,msgpack:mp,encode:mp.encode,decode:mp.decode};} diff --git a/tools/persona-matrix-mouth.mjs b/tools/persona-matrix-mouth.mjs new file mode 100644 index 0000000..22429cb --- /dev/null +++ b/tools/persona-matrix-mouth.mjs @@ -0,0 +1,303 @@ +// The Matrix ear and mouth of one Persona. One process per Persona. +// +// Ear: /sync the configured rooms; every message from another sender becomes a +// delivery kept in memory. The /sync position (next_batch) is persisted only when +// an ack covers it, so a restart before the ack re-delivers instead of losing. +// Mouth: a send request becomes one PUT with txnId = request_id (a retry is +// idempotent) and only while the instance brake is released. +// Ring: the first delivery of an idle window starts the Persona's one-shot unit. +// Starting is a request; the unit's own gate decides. +// +// The Matrix access token is read here, from a systemd credential, and nowhere +// else. It is never put in a document, an environment passed to a child, or a log. +// +// Everything outside this process is a port (fetch, state, brake, ring, clock), so +// the core runs against a fake homeserver with no CultLib and no daemon. +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { createSocket } from "node:dgram"; +import { resolve, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import * as docs from "./persona-chat-documents.mjs"; + +const API = "/_matrix/client/v3"; +const BRAKE_TYPE = "eureka.control.brake.v1"; +const CONTEXT = 20; +const RING_WINDOW_MS = 5 * 60 * 1000; + +export function createMouth({ + persona, homeserver, rooms, token, fetch: httpFetch = globalThis.fetch, state, brake, ring, + now = Date.now, log = () => {}, syncTimeoutMs = 30000, +}) { + if (!persona || !homeserver || !token || !rooms?.length) throw new Error("persona, homeserver, rooms and token are required"); + const say = message => log(redact(String(message), token)); + const roomIdByAlias = new Map(); + const aliasByRoomId = new Map(); + const history = new Map(); // room_id -> last CONTEXT messages, oldest first + const displayNames = new Map(); + let selfId = null; + let cursor = null; // the /sync position we are reading from + let seqCounter = 0; + let pending = []; // deliveries not yet acked + let batches = []; // { token, maxSeq }: positions awaiting an ack + let committed = null; // the position that is persisted + let lastRingAt = -Infinity; + let started = false; + + async function matrix(method, path, body) { + let response; + try { + response = await httpFetch(`${homeserver}${API}${path}`, { + method, + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (error) { + throw new Error(`homeserver unreachable: ${redact(error?.message ?? String(error), token)}`); + } + if (!response.ok) throw new Error(`homeserver answered ${response.status} for ${method} ${path.split("?")[0]}`); + return response.json(); + } + + async function start() { + const saved = await state.load(); + cursor = committed = saved?.nextBatch ?? null; + seqCounter = saved?.seq ?? 0; + selfId = (await matrix("GET", "/account/whoami")).user_id; + for (const alias of rooms) { + const { room_id: roomId } = await matrix("GET", `/directory/room/${encodeURIComponent(alias)}`); + await matrix("POST", `/join/${encodeURIComponent(roomId)}`, {}); + roomIdByAlias.set(alias, roomId); + aliasByRoomId.set(roomId, alias); + } + started = true; + } + + // One /sync round. Returns the deliveries it made. + async function syncOnce() { + if (!started) await start(); + const filter = encodeURIComponent(JSON.stringify({ room: { timeline: { limit: CONTEXT } } })); + const since = cursor === null ? "" : `&since=${encodeURIComponent(cursor)}`; + const reply = await matrix("GET", `/sync?timeout=${cursor === null ? 0 : syncTimeoutMs}&filter=${filter}${since}`); + const baseline = cursor === null; // first ever sync: history is context, not mail + const made = []; + for (const [roomId, room] of Object.entries(reply.rooms?.join ?? {})) { + const alias = aliasByRoomId.get(roomId); + if (!alias) continue; + for (const event of [...(room.state?.events ?? []), ...(room.timeline?.events ?? [])]) { + if (event.type === "m.room.member" && event.content?.displayname) displayNames.set(event.state_key, event.content.displayname); + } + for (const event of room.timeline?.events ?? []) { + if (event.type !== "m.room.message" || typeof event.content?.body !== "string") continue; + const entry = { sender: event.sender, text: event.content.body, atUnixMs: event.origin_server_ts ?? now() }; + const context = (history.get(roomId) ?? []).slice(-CONTEXT); + if (!baseline && event.sender !== selfId) { + made.push({ + seq: ++seqCounter, roomAlias: alias, roomId, eventId: event.event_id, sender: event.sender, + senderDisplay: displayNames.get(event.sender) ?? localpart(event.sender), text: entry.text, atUnixMs: entry.atUnixMs, + context: context.map(c => ({ ...c })), + }); + } + history.set(roomId, [...context, entry].slice(-CONTEXT)); + } + } + const wasIdle = pending.length === 0; + pending.push(...made); + cursor = reply.next_batch; + batches.push({ token: reply.next_batch, maxSeq: seqCounter }); + await commit(); + if (made.length > 0) await state.save({ nextBatch: committed, seq: seqCounter }); // sequence numbers never repeat across a restart + if (made.length > 0 && (wasIdle || now() - lastRingAt >= RING_WINDOW_MS)) { + lastRingAt = now(); + Promise.resolve().then(ring).catch(error => say(`ring failed: ${error?.message ?? error}`)); + } + return made; + } + + // The persisted position is the newest batch whose deliveries are all acked. + async function commit(throughSeq = pending.length ? pending[0].seq - 1 : seqCounter) { + let covered = null; + for (const b of batches) { if (b.maxSeq <= throughSeq) covered = b; else break; } + if (covered && covered.token !== committed) { + committed = covered.token; + await state.save({ nextBatch: committed, seq: seqCounter }); + } + batches = batches.filter(b => b.maxSeq > throughSeq); + } + + async function ack(throughSeq) { + pending = pending.filter(d => d.seq > throughSeq); + await commit(pending.length ? pending[0].seq - 1 : seqCounter); + } + + function pendingAfter(afterSeq) { + return pending.filter(d => d.seq > afterSeq).map(d => ({ ...d })); + } + + async function send({ requestId, persona: asked, roomAlias, text }) { + const refuse = refusal => ({ requestId, eventId: null, refusal }); + if (asked !== persona) return refuse("PersonaMismatch"); + let released = false; + try { released = (await brake()) === true; } catch { released = false; } + if (!released) return refuse("Brake"); + if (typeof text !== "string" || text.trim() === "") return refuse("EmptyText"); + if (!started) await start(); + const roomId = roomIdByAlias.get(roomAlias); + if (!roomId) return refuse("UnknownRoom"); + try { + const sent = await matrix("PUT", `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(requestId)}`, { msgtype: "m.text", body: text }); + return { requestId, eventId: sent.event_id, refusal: null }; + } catch (error) { + say(`send failed: ${error.message}`); + return refuse("SendFailed"); + } + } + + async function run(signal) { + while (!signal?.aborted) { + try { await syncOnce(); } catch (error) { + say(`sync failed: ${error.message}`); + await new Promise(r => { const t = setTimeout(r, 5000); signal?.addEventListener("abort", () => { clearTimeout(t); r(); }, { once: true }); }); + } + } + } + + return { start, syncOnce, ack, pendingAfter, send, run, get pendingCount() { return pending.length; } }; +} + +export function redact(message, token) { + return token ? message.split(token).join("[redacted]") : message; +} + +function localpart(userId) { + return userId.replace(/^@/, "").split(":")[0]; +} + +// Ring: start the Persona's one-shot unit. No retry loop, no token in its environment. +export function createRing({ instance, run = execFile, log = () => {} }) { + return () => new Promise(done => { + run("systemctl", ["start", "--no-block", `mind-persona@${instance}.service`], { env: { PATH: process.env.PATH ?? "" } }, error => { + if (error) log(`ring failed: ${error.message}`); + done(); + }); + }); +} + +// Brake: released only when the instance's control store decodes to a released +// brake. Absent, undecodable or held reads are all "not released". +export function createBrakeReader({ controlPath, readStore, decode }) { + return async () => { + try { + const read = readStore(controlPath); + if (read.absent) return false; + const envelope = read.envelopes.find(e => e.type === BRAKE_TYPE && e.key === "brake"); + if (!envelope) return false; + const value = decode(new Uint8Array(envelope.payload)); + const released = Array.isArray(value) ? value[0] : value instanceof Map ? value.get(0) ?? value.get("released") : value?.[0] ?? value?.["0"] ?? value?.released; + return released === true; + } catch { + return false; + } + }; +} + +// The RUDP connection id is derived from the credential, so a peer without it cannot open the session. +export function connectionIdFor(connectionKey) { + return createHash("sha256").update(connectionKey).digest().readUInt32BE(0); +} + +// CultNet RUDP endpoint on loopback. Requests arrive as document_put_raw messages. +export async function startChatEndpoint({ mouth, persona, runtime, port, connectionKey, runtimeId, socketFactory = createSocket, host = "127.0.0.1", log = () => {} }) { + if (!/^127\.\d+\.\d+\.\d+$/.test(host)) throw new Error("the chat endpoint listens on loopback only"); + const { msgpack, cultnet } = runtime; + const socket = socketFactory("udp4"); + await new Promise((ok, fail) => { socket.once("error", fail); socket.once("listening", ok); socket.bind(port, host); }); + const connectionId = connectionIdFor(connectionKey); + const connection = new cultnet.CultNetRudpSocketTransportConnection({ runtimeId, socket, mode: "server", connectionId, initialSequence: 100, resendDelayMs: 50, resendPollMs: 10, maxFragmentBytes: 1200 }); + const respond = (kind, recordKey, tuple) => connection.send("schema", msgpack.encode(cultnet.encodeCultNetMessageForWire( + docs.envelope({ kind, recordKey, payload: msgpack.encode(tuple), sourceRuntimeId: runtimeId, sourceAgentId: persona, sourceRole: "bifrost-persona-matrix-mouth" }), "cultnet.schema.v0"))); + let busy = Promise.resolve(); + const handle = async value => { + try { + if (value.channelId !== "schema") throw new Error("chat request arrived on a foreign channel"); + const message = cultnet.parseCultNetMessage(msgpack.decode(value.payload), "cultnet.schema.v0"); + const document = docs.checkEnvelope(message, [docs.kinds.pendingRequest, docs.kinds.ack, docs.kinds.sendRequest]); + const tuple = msgpack.decode(document.payload); + if (document.schemaId === docs.kinds.pendingRequest) { + const request = docs.parsePendingRequest(tuple); + if (request.persona !== persona) throw new Error("chat request names another Persona"); + respond(docs.kinds.pending, persona, docs.pendingTuple({ persona, deliveries: mouth.pendingAfter(request.afterSeq) })); + } else if (document.schemaId === docs.kinds.ack) { + const request = docs.parseAck(tuple); + if (request.persona !== persona) throw new Error("chat ack names another Persona"); + await mouth.ack(request.throughSeq); + } else { + const request = docs.parseSendRequest(tuple); + const receipt = await mouth.send(request); + respond(docs.kinds.sendReceipt, receipt.requestId, docs.sendReceiptTuple(receipt)); + } + } catch (error) { + log(`chat endpoint rejected a frame: ${error?.message ?? error}`); + } + }; + const frame = value => { busy = busy.then(() => handle(value)); }; + connection.on("frame", frame); + return { address: socket.address(), close() { connection.off?.("frame", frame); connection.close(); } }; +} + +export function cultcacheState({ runtime, path }) { + const def = runtime.defineDocumentType({ + type: "gamecult.persona.chat_cursor.v1", schemaName: "gamecult.persona.chat_cursor.v1", schemaId: "gamecult.persona.chat_cursor.v1", + schemaVersion: "gamecult.persona.chat_cursor.v1", contentHash: "gamecult.persona.chat_cursor.v1", global: false, name: "key", + schema: { parse: v => ({ key: String(v.key), nextBatch: v.nextBatch ?? null, seq: Number(v.seq) }) }, + }); + const cache = runtime.CultCache.builder().withDocumentType(def).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build(); + const unwrap = v => (Array.isArray(v) ? v[0] : v?.value ?? v); + return { + async load() { await cache.pullAllBackingStores(); return unwrap(cache.get(def, "cursor")) ?? null; }, + async save({ nextBatch, seq }) { await cache.put(def, "cursor", { key: "cursor", nextBatch, seq }); }, + }; +} + +export function loadRuntime(cultRoot) { + const at = name => createRequire(resolve(cultRoot, "packages", name, "package.json")); + const cc = at("cultcache-ts")("@gamecult/cultcache-ts"), cn = at("cultnet-ts")("cultnet-ts"), mp = at("cultnet-ts")("@msgpack/msgpack"); + return { CultCache: cc.CultCache, SingleFileMessagePackBackingStore: cc.SingleFileMessagePackBackingStore, defineDocumentType: cc.defineDocumentType, readSingleFileStore: cc.readSingleFileStore, cultnet: cn, msgpack: mp }; +} + +function required(env, name) { + if (!env[name]) throw new Error(`${name} is required`); + return env[name]; +} +function credential(env, name) { + return readFileSync(join(required(env, "CREDENTIALS_DIRECTORY"), name), "utf8").trim(); +} + +export async function main(env = process.env) { + const persona = required(env, "PERSONA"); + const instance = env.MIND_INSTANCE ?? persona; // the unit template is instanced by the Persona's mind + if (!/^[A-Za-z0-9_-]{1,64}$/.test(instance)) throw new Error("MIND_INSTANCE is not a slug"); + const token = credential(env, "matrix-token"); + const runtime = loadRuntime(resolve(env.CULTLIB_ROOT ?? resolve(import.meta.dirname, "..", "..", "CultLib"))); + const log = line => console.error(line); + const controlPath = join(env.CONTROL_ROOT ?? "/etc/gamecult/minds", instance, "control.cc"); + const mouth = createMouth({ + persona, token, log, + homeserver: required(env, "MATRIX_HOMESERVER").replace(/\/$/, ""), + rooms: required(env, "MATRIX_ROOMS").split(",").map(s => s.trim()).filter(Boolean), + state: cultcacheState({ runtime, path: env.STATE_PATH ?? join(required(env, "STATE_DIRECTORY"), "cursor.cc") }), + brake: createBrakeReader({ controlPath, readStore: runtime.readSingleFileStore, decode: runtime.msgpack.decode }), + ring: createRing({ instance, log }), + }); + await mouth.start(); + const listen = required(env, "CHAT_RUDP_LISTEN"); // host:port, loopback + const colon = listen.lastIndexOf(":"); + await startChatEndpoint({ mouth, persona, runtime, host: listen.slice(0, colon), port: Number(listen.slice(colon + 1)), connectionKey: credential(env, "chat-key"), runtimeId: `bifrost-persona-mouth-${persona}`, log }); + await mouth.run(); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(error => { console.error(error?.message ?? String(error)); process.exitCode = 1; }); +} From eee86193c0190ce36d8509cfd26939e77808af62 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 29 Oct 2026 21:48:14 +0100 Subject: [PATCH 24/40] Research: swarm governance on Bifrost, readiness and prior art (follow-up swarm-governance-on-bifrost) Co-Authored-By: Claude Opus 5.5 --- .../2026-10-29-swarm-governance-prior-art.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/research/2026-10-29-swarm-governance-prior-art.md diff --git a/docs/research/2026-10-29-swarm-governance-prior-art.md b/docs/research/2026-10-29-swarm-governance-prior-art.md new file mode 100644 index 0000000..2d0c329 --- /dev/null +++ b/docs/research/2026-10-29-swarm-governance-prior-art.md @@ -0,0 +1,119 @@ +# Swarm governance on Bifrost: readiness and prior art (2026-10-29) + +Serves follow-up `eureka-body:follow_up:swarm-governance-on-bifrost` and ruling `eureka-body:ruling:self-run-result-to-classifier` (classifier tuning by swarm vote). Two Eyes passes, facts only. Part 1 reads Bifrost at 7b63031; part 2 is prior art from the web, with confidence tags. + +## Part 1: Bifrost governance readiness + +Motions (Postgres/EF, Razor + Eve surface): Domain/BifrostModels.cs:665-721 (Motion, Vote), enums :86-115; Features/Motions/MotionGovernanceService.cs (create :13, vote :47, close :95-131); MotionCategoryPolicy.cs thresholds Bugs .15, Cosmetics .30, Balance .40, Features .50, NewContent .50, FundamentalDesign .66. +Pass rule: For weight / (For+Against weight) >= ApprovalThreshold; Abstain excluded; no quorum field or code anywhere (grep "quorum" empty). Close is manual by creator or CanModerateMotions (:105); ResolveStatusLabel also derives Passed/Failed display after ClosesAtUtc. +Weight: Membership.EffectiveVotingWeight = 0 unless Status Active, else max(1, sum current TierSnapshot weights) (BifrostModels.cs:357-361). TierSnapshotKind Patron|Contributor. +Membership: MembershipStatus Authenticated|PendingApproval|Active|Suspended; MemberRole StandardMember|Producer|Maintainer|LedgerReviewer|PlatformAdmin (RoleAssignment); flags IsPlatformAdmin, CanManageProjects, CanManageLedger, CanModerateMotions. Activation: bootstrap admin, admin approval (Pages/App/Members), Heimdall member claim. Native registration POST /auth/bifrost/register is anonymous, yields Authenticated only. /App is ActiveMember-gated (Program.cs:57). +Personas: no Persona account/voter class. BridgeActorKind Member|Agent|Persona|Service exists for bridge actions only (BifrostModels.cs:178). A Persona can only vote if it holds a UserAccount with Active membership (nothing in code makes one). Persona identity names are "reserved in Bifrost's identity namespace" per gamecult-ops/docs/gamecult-chat-target.md:84,179: Persona Matrix user "Registered by the mouth when Bifrost governance approves the Persona" -- documented; no code for that approval found. +Typed-document exposure: motions/votes are Postgres rows, not CultCache docs. GET /eve/governance/surface (Program.cs:513, ActiveMember-gated) returns Eve JSON surface (Eve is deprecated per F:\Projects\CLAUDE.md). MotionEveSurfaceService names cultmesh://asgard.starfire.bifrost/commands/motion and motion.create/vote/close commands; no handler for that route found in code (grep). verse-service-contract.md:60,185-196 documents schema gamecult.bifrost.governance and the command route; schema ids for motion/vote listed as todo (:256). +Governance topics/comments: tools/governance-threads.mjs, CultCache file .bifrost/governance-threads.cc, types bifrost.governance.topic / topic-comment; statuses open->consensus_ready->approved->dispatched->closed; support/objection are comment stances, not votes; the web app never reads it (docs/concept-mill-votes-target.md facts 1-5). Eyes pass 2026-10-10 found no such store on Yggdrasil. Web keeps only GovernanceActivityReceipt rows. +Concept Mill votes (the one different vote system): ConceptVoteModels.cs, Features/ConceptVotes/*, Pages/Mill/Vote; VoterClass Anonymous|Account|Member; public tally export /public/concepts with CORS (ConceptTallyExport.cs). Per-subject appraisals, not motions; deployed in 67072b2 lineage. +Chat integration: persona-matrix-mouth.mjs (Persona Matrix speech, main 7b63031), OIDC issuer + back-channel logout on membership leave (main 1894f65); persona feedback ingress/delivery as signed CultCache docs (docs/bridge.md:195-217). No motion/vote surface in chat found. Discord/Reddit voting prompts documented as future (full-implementation-strategy.md:78-79). +UI: Razor /App/Motions (Pages/App/Motions/Index.cshtml[.cs]), /App/Members; no tally history page beyond list. +Docs: docs/verse-service-contract.md, full-implementation-strategy.md (Phase 3 governance and revenue share), bridge.md, agent-transport.md, concept-mill-votes-target.md, jurisdiction-map.md. +Ruling read: eureka-body:ruling:self-run-result-to-classifier (InForce, Operator, 2026-10-29): "tuning authority belongs to a swarm decision process, pending that process existing." +Not read: Bifrost tests, jurisdiction-map.md, live DB contents (motion count unknown), campaign rulings beyond the one above. + +## Part 2: Prior art + +## 1. Quorum and close timing + +Apache (https://www.apache.org/foundation/voting.html) [P] +- Votes should generally run at least 72 hours so all time zones can take part. +- Lazy consensus = silence gives assent; not usable for code changes under review-then-commit unless declared at vote start. +- Types: procedural = simple majority (more +1 than -1); code modification = consensus approval (3 +1, no -1) unless lazy consensus declared; release = majority approval (3 binding +1, more +1 than -1). +- Veto only on code modifications: a -1 needs technical justification, else invalid; it blocks until withdrawn. Releases cannot be vetoed. +- Binding votes: PMC members; others advisory. Only explicit votes count. +- Page does not define "lazy majority" or "2/3 majority" (those terms are in other ASF docs, not checked). + +IETF rough consensus, RFC 7282 (https://www.rfc-editor.org/rfc/rfc7282.html) [P] +- Explicitly not voting; the chair judges whether every technical objection was considered and answered; call is appealable. +- Named failure modes: capitulation (objector gives up unanswered), horse-trading, vote stuffing (recruiting voices to swamp objections), polls treated as decisions, ignored objections. + +Loomio (https://www.loomio.com/docs/en/user_manual/polls/vote_share_requirements, https://help.loomio.com/en/user_manual/polls/quorum) [P/S] +- Quorum is optional, a percentage of eligible voters; set per poll or per poll template. +- Vote-share requirements are separate: threshold measured against eligible voters or votes cast; weighted voting uses weight instead of headcount; a Block option with "no more than 0%" lets one block vote prevent passage. +- Docs example: 5 eligible, 75% agreement of eligible plus 60% quorum; 2 agreeing votes (40% of eligible) fail. +- Closing: deadline can be extended or proposal closed early; no built-in minimum open period found; group-written rules vary (one group: >=50% of reps participate, open two weeks) (https://www.loomio.com/d/tmaQYcM5/proposed-changes-to-the-loomio-voting-process-for-e-nable-proposals) [S]. + +Debian constitution (https://www.debian.org/devel/constitution) [P] +- GR: discussion period min 2 weeks, max 3 (App. A.1.1); voting period 2 weeks, Leader may vary by 1 week (4.2.3); quorum 3Q where Q = half sqrt(number of Developers); casting vote for Leader; default option "None of the above" (4.2.4); ranked ballot, Condorcet/Schwartz-set counting (App. A.5). +- Sponsorship needed to propose a GR: K = min(Q, 5) (4.2.1, 4.2.7). 2K sponsors can put a Leader/Delegate decision on hold (4.2.2). +- Supermajorities: 3:1 for constitutional amendments and foundation documents (4.1.2, 4.1.5.3); 2:1 to override a Technical Committee decision (4.1.4). + +Robert's Rules small boards [S] +- Default quorum is a majority of the membership unless bylaws say otherwise (https://www.dummies.com/article/business-careers-money/business/general-business/roberts-rules-for-defining-a-quorum-189712, https://mtas.tennessee.edu/system/files/mrln/mknowledge/main/Quorum_Hodge_2018_public_legop.pdf). +- "Small board" relaxed procedure applies to bodies of about 12 or fewer (https://www.wv-wmd.org/polity/smallboards.html). The search did not confirm any finer threshold. + +Decidim (https://docs.decidim.org/en/develop/admin/components/proposals) [S] +- Proposals component settings include per-participant vote cap, minimum votes per user (below it votes do not count), acceptance threshold per proposal, and an option to keep collecting votes after threshold. Version-dependent. Decidim's closing/quorum semantics for formal decisions were not found. + +Low-turnout evidence (DAOs, large N, not small groups): +- Average DAO participation 6.3% (review, source study unnamed) and ~17% of voting tokens delegated, clustered on popular delegates, across 18 Ethereum DAOs (https://www.frontiersin.org/journals/blockchain/articles/10.3389/fbloc.2025.1598283/epub, https://arxiv.org/pdf/2510.05830) [S]. +- Feichtinger, Fritsch, Vonlanthen, Wattenhofer, "The Hidden Shortcomings of (D)AOs", 21 DAOs: high voting-right concentration (https://arxiv.org/pdf/2302.12125v2.pdf) [S]. +- Compound Proposal 289 (July 2024) passed 682,191 to 633,636 with <7% of COMP supply; an earlier proposal 279 failed with 88% against; reported as an alleged governance attack, resolved by a negotiated cancellation (https://thedefiant.io/news/defi/compound-governance-attack-reveals-inherent-vulnerabilities-of-daos, https://www.theblock.co/post/307943/24-million-compound-finance-proposal-passed-by-whale-over-dao-objections, https://coindesk.com/business/2024/07/30/comp-token-rises-as-whale-backs-down-on-supposed-governance-attack-on-compound) [S]. +- No source located that specifically studies stalled/quorum-busting motions in groups of ~10-20. Not found, not proven absent. + +## 2. Vote weighting; mixed human/AI voting + +One-member-one-vote vs weighted: +- Beanstalk (Apr 2022): flash-loaned voting power passed BIP-18/19 and drained ~$76M actually stolen (headline $182M); causes cited: no flash-loan-resistant vote measurement, no execution delay (https://www.bleepingcomputer.com/news/security/beanstalk-defi-platform-loses-182-million-in-flash-loan-attack/, https://rekt.news/beanstalk-rekt, https://www.merklescience.com/blog/hack-track-analysis-of-beanstalk-flash-loan-attack) [S]. +- OpenZeppelin Governor: voting power read from a past snapshot (ERC20Votes checkpoints), voting delay lets members exit before the snapshot matters; quorum as a fraction of supply at snapshot, only For+Abstain count (https://docs.openzeppelin.com/contracts/5.x/governance) [P]. + +Quadratic voting: +- Lalley and Weyl, "Quadratic Voting: How Mechanism Design Can Radicalize Democracy" (https://papers.ssrn.com; also https://bfi.uchicago.edu/wp-content/uploads/2016-14-1.pdf); Posner and Weyl 2017 survey (https://www.Gwern.net/doc/economics/mechanism-design/quadratic-voting/2017-posner.pdf) [S]. +- Weyl argues collusion/fraud is deterred except at very large scale (https://www.microsoft.com/en-us/research/?p=893088) [S]; Rivest's note argues QV is inherently susceptible to collusion [S]. +- Sybil/wallet splitting defeats the quadratic cost without proof of personhood (https://www.spark.money/glossary/quadratic-voting) [S]. +- "Concave is the New Linear: The Impossibility of Anti-Plutocratic DAO Governance" (https://arxiv.org/pdf/2605.18990) [A, 2026]: title states an impossibility claim; body not read. + +AI agents in governance (2025-2026): +- DAO-AI, arXiv 2510.21117 (https://arxiv.org/html/2510.21117v2) [A]: multi-agent analysis of proposals/forum/voting; reports interpretable, auditable signals; agentic AI as decision-maker study. +- QOC DAO, arXiv 2511.08641 (https://arxiv.org/html/2511.08641v1) [A]: roadmap human-only -> agents simulate stakeholders with humans holding final authority -> AI-driven DAO. +- "Who Controls the Collective AI Vote? Real-World Evidence and Adversarial Testing of Multi-Agent Delegation in DAO Governance" (https://neurips.cc/virtual/2026/165281) [A]: several visible agents may share one actor controlling instructions, evidence, software, signing or reversal; combines analytical model, public-record audit of three deployed systems, tests of three voting rules, open-model replay; reports separating untrusted proposal text from governing instructions cut targeted vote changes in one fixed model; controller separation reduced common-input attacks but not direct compromise of one agent; layered safeguards lowered all four channels. +- Blockful ENS AI Delegate experiment: agents on own servers vote from bias prompt + DAO constitution + proposal (https://blockful.notion.site/AI-Delegate-8296ff74623a4078b1ab7e7536adfabf) [S]. +- Arbitrum RFC on "Agentic Governance Pivot": community asked for how agents work and a public vote-history dashboard (https://forum.arbitrum.foundation/t/rfc-proposal-to-adjust-the-voting-power-of-the-arbitrum-community-pool-ratifying-the-agentic-governance-pivot/29280/11) [S]. +- Epistemic delegation paper mentions a possible block of AI "Condorcet agents" as imagination-stage (https://arxiv.org/pdf/2505.04136) [S]. +- Trustless Autonomy, arXiv 2505.09757: interview study, governance dilemma of unreliable agents (https://www.arxiv.org/pdf/2505.09757) [S]. +- No study found of small mixed human/AI-agent boards voting on operational settings of their own system. + +## 3. Sybil and correlated voters + +- Kohli, "Nine Judges, Two Effective Votes: Correlated Errors Undermine LLM Evaluation Panels", arXiv 2605.29800 (https://arxiv.org/abs/2605.29800; Apple ML research page https://machinelearning.apple.com/research/correlated-llm-evaluation-panels) [P abstract]: 9 frontier LLMs from 7 families on three NLI datasets carry ~2 independent votes (Kish n_eff); panel accuracy 8-22 points below independent-voting expectation; best single judge matches or beats panel; aggregation methods close at most 11% of the gap; holds across prompts, temperature, CoT, a pairwise-preference task. Abstract does not split same-family vs cross-family; narrow task domain; preprint, review status unverified. +- STRIVE, arXiv 2608.04567 (https://arxiv.org/pdf/2608.04567) [S]: failing-group jury 33% vs best member 47%; some items missed by every configuration. +- Theory: Condorcet jury theorem assumes independent voters; Austen-Smith and Banks (1996) showed independence is necessary for the aggregation benefit (cited in Kohli) [S]. Explainer: https://towardsdatascience.com/your-six-ai-models-are-really-two-the-math-of-correlated-mistakes/ [S]. +- Controller concentration across visible agents: the NeurIPS "Who Controls..." abstract above [A]. +- IETF names vote stuffing as a failure mode of voting-style consensus (RFC 7282, above). +- Compound 289: delegated voting power concentrated in one delegate authored successive proposals (sources in section 1) [S]. +- Not found: any study of one operator running several personas on the same base model as voters specifically (closest: Kohli, NeurIPS abstract). + +## 4. Delegated or tiered authority + +Apache (voting.html above) [P]: PMC binding; non-PMC advisory; decision type determines threshold; code vetoes need technical justification. + +Rust: +- RFC 1068 governance (https://github.com/rust-lang/rfcs/blob/master/text/1068-rust-governance.md) [P via summary]: subteams own RFCs in their area (shepherd, accept/reject, policy on what needs an RFC); core team sets direction, creates/shuts subteams; consensus is the norm; for minor disputes shepherd or leader decides; deep disagreement: leader decides after consulting core team; final comment period one week in that RFC text; new technical arguments raised in FCP trigger another comment period; each subteam sets its own rules for when an RFC is required. +- Compiler FCP page (https://forge.rust-lang.org/compiler/proposals-and-stabilization.html) [P]: FCP needs team sign-off, then a ten-day wait; outstanding concerns block completion (`@rustbot concern` / `resolve`), countdown restarts when concerns resolve; FCP can be skipped for easily reversed MCPs. The page did not describe the disposition (merge/close/postpone) or majority-of-team checkbox mechanism; those were not verified. + +Debian (constitution above) [P]: +- Individual developers decide their own work (3); Leader delegates (5.1.1, 8); delegation irrevocable once a specific decision is made (5.1.1); Leader cannot override a Delegate's made decision (8.2); GR can override Leader/Delegate (4.1.3) and TC (2:1, 4.1.4); TC decides last-resort, overrule Developer by 3:1 (6.1.4, 6.3.5); Secretary runs votes and interprets (7.1). + +Kubernetes KEP (https://www.k8s.dev/resources/keps/5241/, https://kubernetes.io/blog/2020/08/21/moving-forward-from-beta/) [S]: owning-SIG approvers move a KEP provisional -> implementable; significant later changes need every approver; graduation criteria are written into the KEP; stages alpha (off by default), beta (usually on, can be disabled), GA (always on). + +Loomio: block option as per-option veto (above). + +## 5. Binding a decision to a parameter change + +- Compound Governor (https://docs.compound.finance/v2/governance/, https://wiki.tally.xyz/docs/compound-governor) [S]: passed proposal queued in a Timelock, executable after 2 days; voting delay and period are themselves parameters changeable through governance; a Pause Guardian can act without a vote; Olympus's Bravo deployment adds a veto guardian who may veto any time before execution (https://docs.olympusdao.finance/main/contracts/docs/src/external/governance/GovernorBravoDelegate.sol/contract.GovernorBravoDelegate) [S]. +- OpenZeppelin Governor + TimelockController [P]: delay "allows users to exit the system if they disagree with a decision before it is executed"; roles proposer (the Governor only), executor (may be anyone), canceller, admin (to be renounced after setup); extra proposers/cancellers flagged as a risk of bypassing or blocking governance; optional proposal threshold limits who can propose. Guardian extension lets a guardian cancel at any stage (https://docs.openzeppelin.com/contracts/api/governance) [S]. +- Beanstalk had no execution delay (section 2 sources) [S]. +- MakerDAO Governance Security Module delay and executive-vs-poll split: fetch redirected to https://developers.skyeco.com/ and was not read; not verified here. +- Compound 290 plan: team multisig deploying a new Governor to strip a delegate's power (reported intent, later cancelled) (sources in section 1) [S]. +- Kubernetes: parameters change via feature gates whose default flips at KEP graduation stages, decided by SIG approvers, not vote (section 4) [S]. +- Feature-flag change-control (LaunchDarkly approvals, etc.): not searched. + +## Not covered or unverified +- Maker GSM, ASF "lazy majority"/2:3 definitions, Rust FCP disposition and majority-checkbox rule, Decidim formal-decision close semantics, feature-flag approval workflows, full text of every [A] paper, same-family LLM correlation numbers, anything on stalled-motion rates in small boards. From 3f241c0fe47e81bfa2bdf072655f456c14a6f8b6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 29 Oct 2026 22:07:10 +0100 Subject: [PATCH 25/40] Research: estate tally game theory, power indices and simulations (Fork Desk 39, estate-internal-tally) Co-Authored-By: Claude Opus 5.5 --- .../README.md | 174 ++++++++++++++++++ .../sims/growth.out.md | 110 +++++++++++ .../sims/growth.py | 73 ++++++++ .../sims/lone.out.md | 28 +++ .../sims/lone.py | 41 +++++ .../sims/mc.out.md | 38 ++++ .../sims/mc.py | 82 +++++++++ .../sims/noshow.out.md | 117 ++++++++++++ .../sims/noshow.py | 70 +++++++ .../sims/power.out.md | 41 +++++ .../sims/power.py | 110 +++++++++++ .../sims/strat.out.md | 43 +++++ .../sims/strat.py | 86 +++++++++ .../sims/weights.out.md | 76 ++++++++ .../sims/weights.py | 99 ++++++++++ 15 files changed, 1188 insertions(+) create mode 100644 docs/research/2026-10-29-estate-tally-game-theory/README.md create mode 100644 docs/research/2026-10-29-estate-tally-game-theory/sims/growth.out.md create mode 100644 docs/research/2026-10-29-estate-tally-game-theory/sims/growth.py create mode 100644 docs/research/2026-10-29-estate-tally-game-theory/sims/lone.out.md create mode 100644 docs/research/2026-10-29-estate-tally-game-theory/sims/lone.py create mode 100644 docs/research/2026-10-29-estate-tally-game-theory/sims/mc.out.md create mode 100644 docs/research/2026-10-29-estate-tally-game-theory/sims/mc.py create mode 100644 docs/research/2026-10-29-estate-tally-game-theory/sims/noshow.out.md create mode 100644 docs/research/2026-10-29-estate-tally-game-theory/sims/noshow.py create mode 100644 docs/research/2026-10-29-estate-tally-game-theory/sims/power.out.md create mode 100644 docs/research/2026-10-29-estate-tally-game-theory/sims/power.py create mode 100644 docs/research/2026-10-29-estate-tally-game-theory/sims/strat.out.md create mode 100644 docs/research/2026-10-29-estate-tally-game-theory/sims/strat.py create mode 100644 docs/research/2026-10-29-estate-tally-game-theory/sims/weights.out.md create mode 100644 docs/research/2026-10-29-estate-tally-game-theory/sims/weights.py diff --git a/docs/research/2026-10-29-estate-tally-game-theory/README.md b/docs/research/2026-10-29-estate-tally-game-theory/README.md new file mode 100644 index 0000000..f84097d --- /dev/null +++ b/docs/research/2026-10-29-estate-tally-game-theory/README.md @@ -0,0 +1,174 @@ +# Eyes: estate internal tally, game-theory probes (2026-10-29) + +Fork: Fork Desk question 39, `eureka-body:question:estate-internal-tally` (InForce; options proportional | winner-take-all; Self's recommended: proportional). Context ruling `eureka-body:ruling:governance-estate-blocks`. Session self-2026-10-29-eureka. Facts and measured results only, no recommendation. +Operator additions folded in (her words relayed by the Self): within-bloc vote-power curves ("Patrons vote power scales with donation total, but it's logarithmic... Member vote should scale with contribution... via a lower exponent"); present case ("I'm the only active member"); renormalization ruling ("normalize it by who actually voted"). + +## Code, outputs, model +Dir `C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\c4b0441b-f176-4f06-b58b-75efa6163f29\scratchpad\estate-sims\` (numpy 2.5.4, Python 3.13, `python -I