From 8c3827b36d7464ff9af95bb4435c20c2e44861bc Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 11:22:22 +0000 Subject: [PATCH 1/4] Consent: optional at registration, a consent page with accept and withdraw, notice version 2 Registration without acceptedNoticeVersion (JSON) or with the box unticked (form) creates the account with no receipt; a version sent must still equal the one in force. GET /auth/bifrost/consent shows the notice and the account's state with the one applicable button. POST accept and withdraw are forms behind the strict RequestOriginGuard.Allows (403 before any read or write), idempotent, and withdraw is no longer JSON. The ConsentReceipt unique index becomes filtered to live rows so a person can agree again after withdrawing. TrainingNotice.Version = 2 with the ruled text. Co-Authored-By: Claude Sonnet 5.5 --- src/Bifrost.Web/Data/BifrostDbContext.cs | 3 +- ...100000_ConsentReceiptsOneLivePerVersion.cs | 36 ++ .../BifrostDbContextModelSnapshot.cs | 3 +- .../Membership/BifrostIdentityService.cs | 17 +- .../Features/Registration/ConsentEndpoints.cs | 180 ++++++++ .../Features/Registration/ConsentPage.cs | 99 +++++ .../Registration/NativeRegistration.cs | 12 +- .../Features/Registration/RegistrationPage.cs | 9 +- .../Features/Registration/TrainingNotice.cs | 34 +- src/Bifrost.Web/Program.cs | 42 +- tests/Bifrost.Web.Tests/ConsentTests.cs | 407 ++++++++++++++++++ .../MigrationDiscoveryTests.cs | 1 + .../RegistrationFormTests.cs | 52 ++- .../RegistrationGateTests.cs | 102 +---- 14 files changed, 844 insertions(+), 153 deletions(-) create mode 100644 src/Bifrost.Web/Data/Migrations/20261111100000_ConsentReceiptsOneLivePerVersion.cs create mode 100644 src/Bifrost.Web/Features/Registration/ConsentEndpoints.cs create mode 100644 src/Bifrost.Web/Features/Registration/ConsentPage.cs create mode 100644 tests/Bifrost.Web.Tests/ConsentTests.cs diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 57f5788..d227c42 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -325,7 +325,8 @@ public sealed class BifrostDbContext(DbContextOptions options) modelBuilder.Entity(entity => { - entity.HasIndex(x => new { x.UserAccountId, x.NoticeVersion }).IsUnique(); + // One live receipt per account and version; a withdrawn row stays as history and a later agreement is a new row. + entity.HasIndex(x => new { x.UserAccountId, x.NoticeVersion }).IsUnique().HasFilter("\"WithdrawnAtUtc\" IS NULL"); entity.HasOne(x => x.UserAccount) .WithMany() .HasForeignKey(x => x.UserAccountId) diff --git a/src/Bifrost.Web/Data/Migrations/20261111100000_ConsentReceiptsOneLivePerVersion.cs b/src/Bifrost.Web/Data/Migrations/20261111100000_ConsentReceiptsOneLivePerVersion.cs new file mode 100644 index 0000000..a1176b5 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261111100000_ConsentReceiptsOneLivePerVersion.cs @@ -0,0 +1,36 @@ +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + // Hand-written like AddConsentReceipts: no dotnet-ef on the authoring host; the snapshot was edited to match. + // A person may withdraw and agree again to the same version, so uniqueness narrows to the live receipt. + [DbContext(typeof(BifrostDbContext))] + [Migration("20261111100000_ConsentReceiptsOneLivePerVersion")] + public partial class ConsentReceiptsOneLivePerVersion : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex(name: "IX_ConsentReceipts_UserAccountId_NoticeVersion", table: "ConsentReceipts"); + migrationBuilder.CreateIndex( + name: "IX_ConsentReceipts_UserAccountId_NoticeVersion", + table: "ConsentReceipts", + columns: new[] { "UserAccountId", "NoticeVersion" }, + unique: true, + filter: "\"WithdrawnAtUtc\" IS NULL"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex(name: "IX_ConsentReceipts_UserAccountId_NoticeVersion", table: "ConsentReceipts"); + migrationBuilder.CreateIndex( + name: "IX_ConsentReceipts_UserAccountId_NoticeVersion", + table: "ConsentReceipts", + columns: new[] { "UserAccountId", "NoticeVersion" }, + unique: true); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 63f6a40..851318c 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -1461,7 +1461,8 @@ namespace Bifrost.Web.Data.Migrations b.HasKey("Id"); b.HasIndex("UserAccountId", "NoticeVersion") - .IsUnique(); + .IsUnique() + .HasFilter("\"WithdrawnAtUtc\" IS NULL"); b.ToTable("ConsentReceipts"); }); diff --git a/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs b/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs index 4a70995..aa1bf8a 100644 --- a/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs +++ b/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs @@ -35,7 +35,7 @@ public sealed partial class BifrostIdentityService( public async Task RegisterNativeAsync( string identity, string? displayName, - int noticeVersion, + int? noticeVersion, CancellationToken cancellationToken) { var normalizedIdentity = await CheckAvailableAsync(identity, cancellationToken); @@ -66,14 +66,17 @@ public sealed partial class BifrostIdentityService( } }; - // One SaveChanges: no account exists without the receipt of the notice it accepted. + // One SaveChanges: an account that agreed never exists without its receipt. No version, no receipt. dbContext.UserAccounts.Add(userAccount); - dbContext.ConsentReceipts.Add(new ConsentReceipt + if (noticeVersion is { } agreed) { - UserAccount = userAccount, - NoticeVersion = noticeVersion, - AcceptedAtUtc = now - }); + dbContext.ConsentReceipts.Add(new ConsentReceipt + { + UserAccount = userAccount, + NoticeVersion = agreed, + AcceptedAtUtc = now + }); + } try { await dbContext.SaveChangesAsync(cancellationToken); diff --git a/src/Bifrost.Web/Features/Registration/ConsentEndpoints.cs b/src/Bifrost.Web/Features/Registration/ConsentEndpoints.cs new file mode 100644 index 0000000..da300e3 --- /dev/null +++ b/src/Bifrost.Web/Features/Registration/ConsentEndpoints.cs @@ -0,0 +1,180 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.Registration; + +/// +/// The consent page and its two form posts, for any signed-in account. The receipt rows are the only state: a receipt is +/// live while is null, and an account's messages count only while a live +/// receipt for the version in force exists. Both posts are refused by before +/// anything is read or written, since a signed-in session is a cookie and these carry no antiforgery token. +/// +public static class ConsentEndpoints +{ + public const string Path = "/auth/bifrost/consent"; + + public static void MapConsent(this IEndpointRouteBuilder app) + { + app.MapGet(Path, async (ICurrentBifrostActorAccessor actors, BifrostDbContext db, CancellationToken ct) => + { + var actor = await actors.GetAsync(ct); + if (!actor.IsAuthenticated || actor.UserAccount is null) + { + return Results.Redirect($"/auth/sign-in?returnUrl={Uri.EscapeDataString(LocalReturnPath.Parse(Path).Value)}"); + } + + return await PageAsync(db, actor.UserAccount.Id, null, StatusCodes.Status200OK, ct); + }).AllowAnonymous(); + + app.MapPost(Path + "/accept", async ( + HttpContext http, + ICurrentBifrostActorAccessor actors, + BifrostDbContext db, + TimeProvider clock, + IOptions host, + CancellationToken ct) => + { + if (!RequestOriginGuard.Allows(http.Request, host.Value)) + { + return Refused(); + } + + var actor = await actors.GetAsync(ct); + if (!actor.IsAuthenticated || actor.UserAccount is null) + { + return Results.Unauthorized(); + } + + var form = await ReadFormAsync(http.Request, ct); + if (form is null || form["version"].ToString() != TrainingNotice.Version.ToString(System.Globalization.CultureInfo.InvariantCulture)) + { + return await PageAsync(db, actor.UserAccount.Id, $"Send the version in force, {TrainingNotice.Version}, to agree.", StatusCodes.Status400BadRequest, ct); + } + + var accountId = actor.UserAccount.Id; + if (!await HasLiveReceiptAsync(db, accountId, ct)) + { + db.ConsentReceipts.Add(new ConsentReceipt + { + UserAccountId = accountId, + NoticeVersion = TrainingNotice.Version, + AcceptedAtUtc = clock.GetUtcNow() + }); + try + { + await db.SaveChangesAsync(ct); + } + catch (DbUpdateException) + { + // A concurrent accept won the filtered unique index; only that race is the same outcome, anything else is a fault. + db.ChangeTracker.Clear(); + if (!await HasLiveReceiptAsync(db, accountId, ct)) + { + throw; + } + } + } + + return Back(); + }).AllowAnonymous(); + + app.MapPost(Path + "/withdraw", async ( + HttpContext http, + ICurrentBifrostActorAccessor actors, + BifrostDbContext db, + TimeProvider clock, + IOptions host, + CancellationToken ct) => + { + if (!RequestOriginGuard.Allows(http.Request, host.Value)) + { + return Refused(); + } + + var actor = await actors.GetAsync(ct); + if (!actor.IsAuthenticated || actor.UserAccount is null) + { + return Results.Unauthorized(); + } + + var live = await db.ConsentReceipts + .Where(x => x.UserAccountId == actor.UserAccount.Id && x.WithdrawnAtUtc == null) + .ToListAsync(ct); + if (live.Count > 0) + { + var now = clock.GetUtcNow(); + foreach (var receipt in live) + { + receipt.WithdrawnAtUtc = now; + } + + await db.SaveChangesAsync(ct); + } + + return Back(); + }).AllowAnonymous(); + } + + private static IResult Refused() => Results.Text("Cross-site consent changes are refused.", statusCode: StatusCodes.Status403Forbidden); + + private static IResult Back() => new SeeOther(Path); + + private static Task HasLiveReceiptAsync(BifrostDbContext db, Guid accountId, CancellationToken ct) => + db.ConsentReceipts.AnyAsync(x => x.UserAccountId == accountId && x.NoticeVersion == TrainingNotice.Version && x.WithdrawnAtUtc == null, ct); + + private static async Task ReadFormAsync(HttpRequest request, CancellationToken ct) + { + const int MaxFormBytes = 1024; + if (!request.HasFormContentType || request.ContentLength > MaxFormBytes) + { + return null; + } + + if (request.HttpContext.Features.Get() is { IsReadOnly: false } limit) + { + limit.MaxRequestBodySize = MaxFormBytes; + } + + try + { + return await request.ReadFormAsync(ct); + } + catch (Exception error) when (error is InvalidDataException or BadHttpRequestException) + { + return null; + } + } + + private sealed class SeeOther(string location) : IResult + { + public Task ExecuteAsync(HttpContext httpContext) + { + httpContext.Response.StatusCode = StatusCodes.Status303SeeOther; + httpContext.Response.Headers.Location = location; + httpContext.Response.Headers.CacheControl = "no-store"; + return Task.CompletedTask; + } + } + + private static async Task PageAsync(BifrostDbContext db, Guid accountId, string? error, int status, CancellationToken ct) + { + var receipts = await db.ConsentReceipts.AsNoTracking().Where(x => x.UserAccountId == accountId).ToListAsync(ct); + return new HtmlPage(ConsentPage.Render(ConsentPage.StateOf(receipts), error), status); + } + + private sealed class HtmlPage(string html, int status) : IResult + { + public async Task ExecuteAsync(HttpContext httpContext) + { + httpContext.Response.StatusCode = status; + httpContext.Response.ContentType = "text/html; charset=utf-8"; + httpContext.Response.Headers.CacheControl = "no-store"; + await httpContext.Response.WriteAsync(html); + } + } +} diff --git a/src/Bifrost.Web/Features/Registration/ConsentPage.cs b/src/Bifrost.Web/Features/Registration/ConsentPage.cs new file mode 100644 index 0000000..306b014 --- /dev/null +++ b/src/Bifrost.Web/Features/Registration/ConsentPage.cs @@ -0,0 +1,99 @@ +using System.Net; +using Bifrost.Web.Domain; + +namespace Bifrost.Web.Features.Registration; + +/// Where an account stands against the notice in force. Exactly one holds. +public abstract record ConsentState +{ + public sealed record None : ConsentState; + + public sealed record Current(DateTimeOffset AgreedAtUtc) : ConsentState; + + public sealed record Older(int Version, DateTimeOffset AgreedAtUtc) : ConsentState; + + public sealed record Withdrawn(DateTimeOffset WithdrawnAtUtc) : ConsentState; +} + +/// +/// The server-rendered consent page: the notice in force, the account's state and the one button that applies. No script, +/// no external asset, no cookie of its own. +/// +public static class ConsentPage +{ + /// A live receipt for the version in force wins, then a live older one, then the latest withdrawal, else none. + public static ConsentState StateOf(IReadOnlyCollection receipts) + { + var live = receipts.Where(x => x.WithdrawnAtUtc is null).ToList(); + if (live.FirstOrDefault(x => x.NoticeVersion == TrainingNotice.Version) is { } current) + { + return new ConsentState.Current(current.AcceptedAtUtc); + } + + if (live.OrderByDescending(x => x.NoticeVersion).FirstOrDefault() is { } older) + { + return new ConsentState.Older(older.NoticeVersion, older.AcceptedAtUtc); + } + + return receipts.Count > 0 + ? new ConsentState.Withdrawn(receipts.Max(x => x.WithdrawnAtUtc!.Value)) + : new ConsentState.None(); + } + + private static string Iso(DateTimeOffset time) => time.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", System.Globalization.CultureInfo.InvariantCulture); + + public static string Render(ConsentState state, string? error) + { + static string E(string? text) => WebUtility.HtmlEncode(text ?? ""); + var version = TrainingNotice.Version.ToString(System.Globalization.CultureInfo.InvariantCulture); + var (message, withdraw) = state switch + { + ConsentState.Current current => ($"You agreed to version {version} on {Iso(current.AgreedAtUtc)}. Your messages may be used as the notice says.", true), + ConsentState.Older older => ($"You agreed to version {older.Version} on {Iso(older.AgreedAtUtc)}; version {version} is in force and your messages are not used until you agree to it.", false), + ConsentState.Withdrawn withdrawn => ($"You withdrew on {Iso(withdrawn.WithdrawnAtUtc)}. Your messages are not used.", false), + _ => ("You have not agreed. Your messages are not used.", false) + }; + var summary = error is null ? "" : $""""""; + var button = withdraw + ? """
""" + : $"""
"""; + + return $$""" + + + + + + Training consent - Bifrost + + + +
+ Your account +

Training consent

+ {{summary}} +

{{E(message)}}

+ {{button}} +
+ Training notice, version {{version}} + {{TrainingNotice.Html("notice")}} +
+
+ + + """; + } +} diff --git a/src/Bifrost.Web/Features/Registration/NativeRegistration.cs b/src/Bifrost.Web/Features/Registration/NativeRegistration.cs index a4e61b5..042b615 100644 --- a/src/Bifrost.Web/Features/Registration/NativeRegistration.cs +++ b/src/Bifrost.Web/Features/Registration/NativeRegistration.cs @@ -35,8 +35,8 @@ public sealed record RegistrationOutcome( /// /// The one registration gate, run by the JSON endpoint and the form alike after each has spent the general address budget. -/// Order: consent, one proof only, the identity rules (which spend nothing), the challenge (proof of work, or the -/// time-lock plus its tighter budget), then the account and receipt in one save, then the cookie. A refusal at any step +/// Order: consent version (when sent), one proof only, the identity rules (which spend nothing), the challenge (proof of work, or the +/// time-lock plus its tighter budget), then the account (and its receipt when consent was sent) in one save, then the cookie. A refusal at any step /// creates nothing, and a refusal before the challenge step spends neither the challenge nor a fallback permit. /// public sealed class NativeRegistration( @@ -46,12 +46,12 @@ public sealed class NativeRegistration( { public async Task RunAsync(HttpContext http, RegistrationAttempt attempt, CancellationToken cancellationToken) { - // Consent first, then the proof; a refusal here creates nothing. - if (attempt.AcceptedNoticeVersion != TrainingNotice.Version) + // Consent is optional: none sends no receipt. A version that is sent must be the one in force; a refusal here creates nothing. + if (attempt.AcceptedNoticeVersion is { } sent && sent != TrainingNotice.Version) { return RegistrationOutcome.Reject( StatusCodes.Status400BadRequest, - $"Registration requires accepting the training notice, current version {TrainingNotice.Version}.", + $"The training notice in force is version {TrainingNotice.Version}; send that version to agree, or send none to register without agreeing.", RegistrationField.Consent); } @@ -111,7 +111,7 @@ public sealed class NativeRegistration( try { - var account = await identities.RegisterNativeAsync(attempt.Identity ?? "", attempt.DisplayName, TrainingNotice.Version, cancellationToken); + var account = await identities.RegisterNativeAsync(attempt.Identity ?? "", attempt.DisplayName, attempt.AcceptedNoticeVersion, cancellationToken); await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, identities.BuildPrincipal(account)); return new(account, null, StatusCodes.Status200OK, null, RegistrationField.None, false); } diff --git a/src/Bifrost.Web/Features/Registration/RegistrationPage.cs b/src/Bifrost.Web/Features/Registration/RegistrationPage.cs index 4483f89..5fcb252 100644 --- a/src/Bifrost.Web/Features/Registration/RegistrationPage.cs +++ b/src/Bifrost.Web/Features/Registration/RegistrationPage.cs @@ -99,7 +99,7 @@ public static class RegistrationPage var link = model.ErrorField switch { RegistrationField.Identity => """""", - RegistrationField.Consent => """""", + RegistrationField.Consent => """""", _ => "" }; summary.Append($""""""); @@ -153,8 +153,9 @@ public static class RegistrationPage
Training notice, version {{notice}} -

{{E(TrainingNotice.Text)}}

- + {{TrainingNotice.Html("notice")}} + +

Optional. You can create an account without agreeing; then none of your messages are used. You can agree later on your consent page.

@@ -165,7 +166,7 @@ public static class RegistrationPage -

Without a script, or if the automatic check is slow, wait {{wait}} seconds after the page opened and press the button to skip it (or Create account when there is no script). Pressed sooner, the page comes back saying how many seconds remain and keeps your names; the consent box comes back unchecked, so tick the box again before you submit. Without the automatic check only a few registrations per hour are accepted from one address.

+

Without a script, or if the automatic check is slow, wait {{wait}} seconds after the page opened and press the button to skip it (or Create account when there is no script). Pressed sooner, the page comes back saying how many seconds remain and keeps your names; the consent box comes back unchecked, so tick it again if you want to agree. Without the automatic check only a few registrations per hour are accepted from one address.

After registering, Bifrost returns you to {{E(model.ReturnPath)}}.

diff --git a/src/Bifrost.Web/Features/Registration/TrainingNotice.cs b/src/Bifrost.Web/Features/Registration/TrainingNotice.cs index 7aaa679..770919f 100644 --- a/src/Bifrost.Web/Features/Registration/TrainingNotice.cs +++ b/src/Bifrost.Web/Features/Registration/TrainingNotice.cs @@ -1,17 +1,35 @@ +using System.Net; +using System.Text; + namespace Bifrost.Web.Features.Registration; /// -/// The training notice a registrant accepts. DRAFT wording: Eureka replaces . Changing the wording -/// in substance means a new (a positive integer, no leading zeros), because a receipt records the -/// version the person accepted. +/// The training notice a person may agree to. Changing the wording in substance means a new (a +/// positive integer, no leading zeros), because a receipt records the version the person agreed to; a receipt for an +/// older version no longer counts and the person is asked again on the consent page. /// public static class TrainingNotice { - public const int Version = 1; + public const int Version = 2; public const string Text = - "Chat messages and activity on GameCult chat may be used to train and evaluate AI models. " + - "No Discord material is used. " + - "Your consent is voluntary. It is recorded with this notice version and the time you give it, " + - "and you can withdraw it at any time. Withdrawing stops future use; it cannot recall use that already happened."; + "GameCult chat training notice, version 2\n\n" + + "GameCult may use messages you post in GameCult chat to train and evaluate AI models that GameCult builds and runs.\n\n" + + "What counts. The text of your messages, with their time and room, tied to your account name. Not your email address, your sign-in details or anything you did not post. Nothing that came from Discord is used, in any form.\n\n" + + "It is your choice. You can use GameCult chat without agreeing. If you do not agree, none of your messages are used.\n\n" + + "If you agree, we record your account, this notice's version number and the time. Nothing else is stored with it.\n\n" + + "If you withdraw, we leave all your messages out of every training set made after that moment. Withdrawing deletes nothing: your messages stay in chat and on our servers, anything already used to train a model stays used, and a dataset already built is not recalled. We keep the record that you agreed and when you withdrew.\n\n" + + "You can agree or withdraw at any time on your consent page while signed in. If the notice changes, its version changes and we ask again; until you agree to the new version, your messages are not used."; + + /// The text as HTML-encoded paragraphs inside a div with the given id. + public static string Html(string id) + { + var html = new StringBuilder($"
"); + foreach (var paragraph in Text.Split("\n\n")) + { + html.Append("

").Append(WebUtility.HtmlEncode(paragraph)).Append("

"); + } + + return html.Append("
").ToString(); + } } diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index eea7e7b..9facd1a 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -497,10 +497,11 @@ app.MapPost("/auth/bifrost/register/form", async ( string Field(string name) => form[name].FirstOrDefault() ?? ""; var returnPath = LocalReturnPath.Parse(Field("returnUrl")); - // The checkbox is the consent act: without it the posted notice version is not taken as accepted. - int? accepted = Field("consent") == "yes" && int.TryParse(Field("acceptedNoticeVersion"), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var version) - ? version - : null; + // The checkbox is the consent act and is optional: unticked, the posted notice version is ignored and no receipt is made. + // An unparsable version with the box ticked is sent as -1 and refused, never read as "did not agree". + int? accepted = Field("consent") != "yes" + ? null + : int.TryParse(Field("acceptedNoticeVersion"), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var version) ? version : -1; var identity = Field("identity"); var displayName = Field("displayName"); var challenge = Field("challenge"); @@ -554,38 +555,7 @@ app.MapGet("/auth/bifrost/challenge", (HttpContext httpContext, RegistrationChal app.MapGet("/auth/bifrost/notice", () => JsonResult(new { version = TrainingNotice.Version, text = TrainingNotice.Text }, appJsonSerializerOptions)).AllowAnonymous(); -// Withdrawal stops future use; the receipt row stays. Idempotent: an already-withdrawn receipt keeps its first timestamp. -app.MapPost("/auth/bifrost/consent/withdraw", async ( - ICurrentBifrostActorAccessor actors, - BifrostDbContext dbContext, - TimeProvider clock, - CancellationToken cancellationToken) => -{ - var actor = await actors.GetAsync(cancellationToken); - if (!actor.IsAuthenticated || actor.UserAccount is null) - { - return Results.Unauthorized(); - } - - var receipts = await dbContext.ConsentReceipts - .Where(x => x.UserAccountId == actor.UserAccount.Id) - .ToListAsync(cancellationToken); - if (receipts.Count == 0) - { - return Results.Text("No consent receipt is recorded for this account.", statusCode: StatusCodes.Status404NotFound); - } - - var now = clock.GetUtcNow(); - foreach (var receipt in receipts.Where(x => x.WithdrawnAtUtc is null)) - { - receipt.WithdrawnAtUtc = now; - } - - await dbContext.SaveChangesAsync(cancellationToken); - return JsonResult( - new { withdrawnAtUtc = receipts.Min(x => x.WithdrawnAtUtc) }, - appJsonSerializerOptions); -}); +app.MapConsent(); app.MapGet("/auth/heimdall/{provider}", async ( string provider, diff --git a/tests/Bifrost.Web.Tests/ConsentTests.cs b/tests/Bifrost.Web.Tests/ConsentTests.cs new file mode 100644 index 0000000..b17a6e8 --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConsentTests.cs @@ -0,0 +1,407 @@ +using System.Net; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Registration; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +// The consent page and its posts, through HTTP, against receipt rows in the database. +public sealed class ConsentTests(RegistrationGateFactory factory) : IClassFixture +{ + private const string Origin = "https://localhost"; + private static readonly DateTimeOffset Agreed = new(2026, 11, 1, 12, 30, 5, TimeSpan.Zero); + private static readonly DateTimeOffset Withdrew = new(2026, 11, 2, 8, 0, 9, TimeSpan.Zero); + + private static string NewIdentity() => $"consent-{Guid.NewGuid():N}"; + + private HttpClient Client() => factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + private void Seed(string identity, params (int Version, DateTimeOffset Accepted, DateTimeOffset? Withdrawn)[] receipts) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = new UserAccount { BifrostIdentity = identity, NormalizedBifrostIdentity = identity, DisplayName = identity }; + db.UserAccounts.Add(account); + foreach (var (version, accepted, withdrawn) in receipts) + { + db.ConsentReceipts.Add(new ConsentReceipt { UserAccount = account, NoticeVersion = version, AcceptedAtUtc = accepted, WithdrawnAtUtc = withdrawn }); + } + + db.SaveChanges(); + } + + private List Receipts(string identity) + { + using var scope = factory.Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService().ConsentReceipts.AsNoTracking() + .Where(x => x.UserAccount!.NormalizedBifrostIdentity == identity).OrderBy(x => x.AcceptedAtUtc).ToList(); + } + + private async Task GetPageAsync(HttpClient client, string? identity) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/auth/bifrost/consent"); + Sign(request, identity); + return await client.SendAsync(request); + } + + private static void Sign(HttpRequestMessage request, string? identity) + { + if (identity is null) + { + request.Headers.Add("X-Test-Anonymous", "true"); + } + else + { + request.Headers.Add("X-Test-Bifrost-Identity", identity); + } + } + + private static async Task PostAsync( + HttpClient client, string action, string? identity, Dictionary? fields = null, string? origin = Origin, string? fetchSite = null) + { + using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/" + action) + { + Content = new FormUrlEncodedContent(fields ?? new Dictionary { ["version"] = TrainingNotice.Version.ToString() }) + }; + Sign(request, identity); + if (origin is not null) + { + request.Headers.Add("Origin", origin); + } + + if (fetchSite is not null) + { + request.Headers.Add("Sec-Fetch-Site", fetchSite); + } + + return await client.SendAsync(request); + } + + private async Task PageHtmlAsync(string identity) + { + using var client = Client(); + using var response = await GetPageAsync(client, identity); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return await response.Content.ReadAsStringAsync(); + } + + [Fact] + public async Task Anonymous_is_sent_to_the_sign_in_chooser_with_the_page_as_return_target() + { + using var client = Client(); + using var response = await GetPageAsync(client, null); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/auth/sign-in?returnUrl=%2Fauth%2Fbifrost%2Fconsent", response.Headers.Location!.OriginalString); + } + + [Fact] + public async Task The_page_shows_the_notice_in_force_and_is_plain_server_html() + { + var identity = NewIdentity(); + Seed(identity); + using var client = Client(); + using var response = await GetPageAsync(client, identity); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Set-Cookie")); + Assert.Contains("no-store", response.Headers.CacheControl!.ToString()); + foreach (var paragraph in TrainingNotice.Text.Split("\n\n")) + { + Assert.Contains(WebUtility.HtmlEncode(paragraph), html); + } + + Assert.Contains("", html); + Assert.Contains("aria-live=\"polite\"", html); + Assert.DoesNotContain(" Assert.NotNull(x.WithdrawnAtUtc)); + } + + [Fact] + public async Task Withdraw_with_no_receipt_changes_nothing() + { + var identity = NewIdentity(); + Seed(identity); + using var client = Client(); + + using var response = await PostAsync(client, "withdraw", identity); + + Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); + Assert.Empty(Receipts(identity)); + } + + [Fact] + public async Task Accept_after_withdraw_makes_a_new_live_receipt_and_the_old_one_stays_withdrawn() + { + var identity = NewIdentity(); + Seed(identity, (TrainingNotice.Version, Agreed, Withdrew)); + using var client = Client(); + + using var response = await PostAsync(client, "accept", identity); + + Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); + var receipts = Receipts(identity); + Assert.Equal(2, receipts.Count); + Assert.Single(receipts, x => x.WithdrawnAtUtc == Withdrew && x.AcceptedAtUtc == Agreed); + Assert.Single(receipts, x => x.WithdrawnAtUtc is null && x.NoticeVersion == TrainingNotice.Version); + } + + [Fact] + public async Task Accept_next_to_an_older_live_receipt_adds_one_for_the_version_in_force() + { + var identity = NewIdentity(); + Seed(identity, (1, Agreed, null)); + using var client = Client(); + + using var response = await PostAsync(client, "accept", identity); + + Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); + Assert.Equal([1, TrainingNotice.Version], Receipts(identity).Select(x => x.NoticeVersion).Order().ToArray()); + } + + [Theory] + [InlineData("1")] + [InlineData("3")] + [InlineData("abc")] + [InlineData("")] + public async Task Accept_with_the_wrong_version_is_refused_and_creates_nothing(string version) + { + var identity = NewIdentity(); + Seed(identity); + using var client = Client(); + + using var response = await PostAsync(client, "accept", identity, new Dictionary { ["version"] = version }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("role=\"alert\"", await response.Content.ReadAsStringAsync()); + Assert.Empty(Receipts(identity)); + } + + [Fact] + public async Task Accept_with_no_version_field_is_refused_and_creates_nothing() + { + var identity = NewIdentity(); + Seed(identity); + using var client = Client(); + + using var response = await PostAsync(client, "accept", identity, new Dictionary { ["other"] = "x" }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Empty(Receipts(identity)); + } + + [Theory] + [InlineData("https://evil.example", null)] + [InlineData(null, "cross-site")] + [InlineData(null, "same-site")] + [InlineData(null, null)] + public async Task Accept_from_another_origin_or_with_no_fetch_metadata_is_forbidden_and_changes_nothing(string? origin, string? fetchSite) + { + var identity = NewIdentity(); + Seed(identity); + using var client = Client(); + + using var response = await PostAsync(client, "accept", identity, origin: origin, fetchSite: fetchSite); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Empty(Receipts(identity)); + } + + [Theory] + [InlineData("https://evil.example", null)] + [InlineData(null, "cross-site")] + [InlineData(null, "same-site")] + [InlineData(null, null)] + public async Task Withdraw_from_another_origin_or_with_no_fetch_metadata_is_forbidden_and_changes_nothing(string? origin, string? fetchSite) + { + var identity = NewIdentity(); + Seed(identity, (TrainingNotice.Version, Agreed, null)); + using var client = Client(); + + using var response = await PostAsync(client, "withdraw", identity, origin: origin, fetchSite: fetchSite); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Null(Assert.Single(Receipts(identity)).WithdrawnAtUtc); + } + + [Fact] + public async Task Same_origin_by_fetch_metadata_alone_is_allowed() + { + var identity = NewIdentity(); + Seed(identity); + using var client = Client(); + + using var response = await PostAsync(client, "accept", identity, origin: null, fetchSite: "same-origin"); + + Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); + Assert.Single(Receipts(identity)); + } + + [Theory] + [InlineData("accept")] + [InlineData("withdraw")] + public async Task Anonymous_posts_are_unauthorized(string action) + { + using var client = Client(); + + using var response = await PostAsync(client, action, null); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task One_account_changing_its_consent_leaves_every_other_account_untouched() + { + var mine = NewIdentity(); + var theirs = NewIdentity(); + Seed(mine, (TrainingNotice.Version, Agreed, null)); + Seed(theirs, (TrainingNotice.Version, Agreed, null)); + using var client = Client(); + + (await PostAsync(client, "withdraw", mine)).Dispose(); + (await PostAsync(client, "accept", mine)).Dispose(); + + Assert.Null(Assert.Single(Receipts(theirs)).WithdrawnAtUtc); + Assert.Equal(2, Receipts(mine).Count); + } + + [Fact] + public async Task A_hostile_identity_is_not_echoed_unescaped_on_the_page() + { + // The page shows no account name at all, so a hostile display name cannot reach it. + var identity = NewIdentity(); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.UserAccounts.Add(new UserAccount { BifrostIdentity = identity, NormalizedBifrostIdentity = identity, DisplayName = "" }); + db.SaveChanges(); + } + + var html = await PageHtmlAsync(identity); + + Assert.DoesNotContain(" x.WithdrawnAtUtc is null); + } +} diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index cd46628..1fc57d2 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -25,6 +25,7 @@ public sealed class MigrationDiscoveryTests "20261109200000_AddSealedBallotMotions", "20261109210000_AddMindKeys", "20261111090000_AddConsentReceipts", + "20261111100000_ConsentReceiptsOneLivePerVersion", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. diff --git a/tests/Bifrost.Web.Tests/RegistrationFormTests.cs b/tests/Bifrost.Web.Tests/RegistrationFormTests.cs index 83e0d13..753df95 100644 --- a/tests/Bifrost.Web.Tests/RegistrationFormTests.cs +++ b/tests/Bifrost.Web.Tests/RegistrationFormTests.cs @@ -149,12 +149,17 @@ public sealed class RegistrationFormTests(RegistrationFormFactory factory) : ICl Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.False(response.Headers.Contains("Set-Cookie")); - Assert.Contains(WebUtility.HtmlEncode(TrainingNotice.Text), html); + foreach (var paragraph in TrainingNotice.Text.Split("\n\n")) + { + Assert.Contains(WebUtility.HtmlEncode(paragraph), html); + } + Assert.Contains($"version {TrainingNotice.Version}", html); Assert.Contains("", html); - Assert.Matches("]*/>", html); + Assert.Matches("]*/>", html); Assert.DoesNotMatch("]*\\bchecked\\b", html); - Assert.Contains("I have read this notice and consent", html); + Assert.Contains("I agree to this notice (optional)", html); + Assert.DoesNotMatch("]*\\brequired\\b", html); foreach (var id in new[] { "identity", "displayName", "consent" }) { Assert.Matches($"]*for=\"{id}\"", html); @@ -169,7 +174,7 @@ public sealed class RegistrationFormTests(RegistrationFormFactory factory) : ICl Assert.Matches("", html); Assert.DoesNotContain("a few seconds", html); Assert.DoesNotContain("nothing is lost", html); - Assert.Contains("tick the box again", html); + Assert.Contains("tick it again if you want to agree", html); Assert.DoesNotContain("().ConsentReceipts.Where(x => x.UserAccount!.NormalizedBifrostIdentity == identity)); + } + + [Fact] + public async Task The_consent_box_with_a_stale_version_returns_the_page_with_an_error_and_nothing_created() { using var client = Client(); var html = await FormKit.PageAsync(client); var challenge = FormKit.Hidden(html, "challenge"); var identity = NewIdentity(); var before = Counts(); + var fields = FormKit.Fields(html, identity, nonce: FormKit.Solved(html)); + fields["acceptedNoticeVersion"] = "1"; - using var response = await FormKit.PostAsync(client, FormKit.Fields(html, identity, consent: false, nonce: FormKit.Solved(html))); + using var response = await FormKit.PostAsync(client, fields); var again = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Matches("role=\"alert\"", again); - Assert.Contains("requires accepting the training notice", again); Assert.Matches("id=\"consent\"[^>]*aria-invalid=\"true\" aria-describedby=\"errors\"", again); Assert.Contains("", again); Assert.DoesNotMatch("]*\\bchecked\\b", again); Assert.Contains($"value=\"{identity}\"", again); Assert.NotEqual(challenge, FormKit.Hidden(again, "challenge")); - Assert.DoesNotContain(challenge, again); Assert.Equal(before, Counts()); } + [Fact] + public async Task The_consent_box_with_an_unreadable_version_is_refused_not_read_as_no_consent() + { + using var client = Client(); + var html = await FormKit.PageAsync(client); + var identity = NewIdentity(); + var fields = FormKit.Fields(html, identity, nonce: FormKit.Solved(html)); + fields["acceptedNoticeVersion"] = "abc"; + + using var response = await FormKit.PostAsync(client, fields); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.False(Exists(identity)); + } + [Fact] public async Task A_wrong_nonce_fails_the_challenge_and_the_error_never_echoes_the_challenge_or_nonce() { diff --git a/tests/Bifrost.Web.Tests/RegistrationGateTests.cs b/tests/Bifrost.Web.Tests/RegistrationGateTests.cs index 53b2b5b..49d2e78 100644 --- a/tests/Bifrost.Web.Tests/RegistrationGateTests.cs +++ b/tests/Bifrost.Web.Tests/RegistrationGateTests.cs @@ -60,22 +60,32 @@ public sealed class RegistrationGateTests(RegistrationGateFactory factory) : ICl Assert.Equal(before, Counts()); } - [Fact] - public async Task No_consent_is_refused_and_creates_nothing() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task No_consent_registers_the_account_with_no_receipt(bool explicitNull) { using var client = Client(); var (challenge, nonce) = await SolvedAsync(client); + var identity = NewIdentity(); + object body = explicitNull + ? new { identity, challenge, nonce, acceptedNoticeVersion = (int?)null } + : new { identity, challenge, nonce }; - await AssertRefusedCreatesNothingAsync( - client, - new { identity = NewIdentity(), challenge, nonce }, - $"current version {TrainingNotice.Version}"); + using var response = await RegistrationKit.PostAsync(client, body, anonymous: true); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity); + Assert.Empty(db.ConsentReceipts.Where(x => x.UserAccountId == account.Id)); } [Theory] [InlineData(0)] [InlineData(-1)] - [InlineData(2)] + [InlineData(1)] + [InlineData(3)] public async Task A_wrong_notice_version_is_refused_and_creates_nothing(int version) { using var client = Client(); @@ -224,84 +234,12 @@ public sealed class RegistrationGateTests(RegistrationGateFactory factory) : ICl var db = scope.ServiceProvider.GetRequiredService(); var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity); var receipt = db.ConsentReceipts.Single(x => x.UserAccountId == account.Id); - Assert.Equal(1, receipt.NoticeVersion); + Assert.Equal(2, receipt.NoticeVersion); + Assert.Equal(TrainingNotice.Version, receipt.NoticeVersion); Assert.Null(receipt.WithdrawnAtUtc); Assert.InRange((factory.Time.GetUtcNow() - receipt.AcceptedAtUtc).Duration(), TimeSpan.Zero, TimeSpan.FromSeconds(5)); } - [Fact] - public async Task Withdraw_stamps_once_and_touches_only_the_caller() - { - using var client = Client(); - var mine = NewIdentity(); - var theirs = NewIdentity(); - (await RegistrationKit.RegisterAsync(client, mine, anonymous: true)).Dispose(); - (await RegistrationKit.RegisterAsync(client, theirs, anonymous: true)).Dispose(); - - async Task WithdrawAsync() - { - using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/withdraw"); - request.Headers.Add("X-Test-Bifrost-Identity", mine); - using var response = await client.SendAsync(request); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - return await response.Content.ReadFromJsonAsync(); - } - - var first = await WithdrawAsync(); - factory.Time.Advance(TimeSpan.FromHours(1)); - var second = await WithdrawAsync(); - - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var myReceipt = db.ConsentReceipts.Single(x => x.UserAccount!.NormalizedBifrostIdentity == mine); - var theirReceipt = db.ConsentReceipts.Single(x => x.UserAccount!.NormalizedBifrostIdentity == theirs); - Assert.NotNull(myReceipt.WithdrawnAtUtc); - Assert.Equal(myReceipt.WithdrawnAtUtc, first.GetProperty("withdrawnAtUtc").GetDateTimeOffset()); - Assert.Equal(first.GetProperty("withdrawnAtUtc").GetDateTimeOffset(), second.GetProperty("withdrawnAtUtc").GetDateTimeOffset()); - Assert.Null(theirReceipt.WithdrawnAtUtc); - } - - [Fact] - public async Task Anonymous_withdraw_is_refused_and_changes_nothing() - { - using var client = Client(); - var identity = NewIdentity(); - (await RegistrationKit.RegisterAsync(client, identity, anonymous: true)).Dispose(); - - using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/withdraw"); - request.Headers.Add("X-Test-Anonymous", "true"); - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - using var scope = factory.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - Assert.DoesNotContain(db.ConsentReceipts, x => x.WithdrawnAtUtc != null && x.UserAccount!.NormalizedBifrostIdentity == identity); - } - - [Fact] - public async Task Withdraw_for_an_account_with_no_receipt_is_404() - { - using var client = Client(); - var identity = NewIdentity(); - using (var scope = factory.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.UserAccounts.Add(new Bifrost.Web.Domain.UserAccount - { - BifrostIdentity = identity, - NormalizedBifrostIdentity = identity, - DisplayName = identity - }); - await db.SaveChangesAsync(); - } - - using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/withdraw"); - request.Headers.Add("X-Test-Bifrost-Identity", identity); - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - [Fact] public async Task The_notice_endpoint_names_the_version_and_the_discord_exclusion() { @@ -309,7 +247,7 @@ public sealed class RegistrationGateTests(RegistrationGateFactory factory) : ICl var notice = await client.GetFromJsonAsync("/auth/bifrost/notice"); - Assert.Equal(1, notice.GetProperty("version").GetInt32()); + Assert.Equal(2, notice.GetProperty("version").GetInt32()); Assert.Contains("Discord", notice.GetProperty("text").GetString()); } -- 2.47.3 From 6a797cb3fa033b9908c353115337125eb2ee6079 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 11:26:10 +0000 Subject: [PATCH 2/4] Consent tests: a live receipt for the version in force outranks an older live one Co-Authored-By: Claude Sonnet 5.5 --- tests/Bifrost.Web.Tests/ConsentTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/Bifrost.Web.Tests/ConsentTests.cs b/tests/Bifrost.Web.Tests/ConsentTests.cs index b17a6e8..e6b55cc 100644 --- a/tests/Bifrost.Web.Tests/ConsentTests.cs +++ b/tests/Bifrost.Web.Tests/ConsentTests.cs @@ -159,6 +159,17 @@ public sealed class ConsentTests(RegistrationGateFactory factory) : IClassFixtur Assert.DoesNotContain("/consent/withdraw", html); } + [Fact] + public async Task A_live_receipt_for_the_version_in_force_outranks_an_older_live_one() + { + var identity = NewIdentity(); + Seed(identity, (1, Agreed, null), (TrainingNotice.Version, Agreed.AddDays(1), null)); + var html = await PageHtmlAsync(identity); + + Assert.Contains($"You agreed to version {TrainingNotice.Version} on 2026-11-02T12:30:05Z", html); + Assert.Contains("action=\"/auth/bifrost/consent/withdraw\"", html); + } + [Fact] public async Task A_withdrawal_shows_its_time_and_offers_agree() { -- 2.47.3 From c9387a255d7654be47cabb1d7fb8fac69e91e9e8 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 11:33:51 +0000 Subject: [PATCH 3/4] Consent page: Withdraw whenever any live receipt exists, Agree whenever none is live for the version in force An account on an older receipt sees both buttons. Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Registration/ConsentPage.cs | 13 +++++++++---- tests/Bifrost.Web.Tests/ConsentTests.cs | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Bifrost.Web/Features/Registration/ConsentPage.cs b/src/Bifrost.Web/Features/Registration/ConsentPage.cs index 306b014..2e7595c 100644 --- a/src/Bifrost.Web/Features/Registration/ConsentPage.cs +++ b/src/Bifrost.Web/Features/Registration/ConsentPage.cs @@ -16,7 +16,7 @@ public abstract record ConsentState } /// -/// The server-rendered consent page: the notice in force, the account's state and the one button that applies. No script, +/// The server-rendered consent page: the notice in force, the account's state and the buttons that apply. No script, /// no external asset, no cookie of its own. /// public static class ConsentPage @@ -46,17 +46,22 @@ public static class ConsentPage { static string E(string? text) => WebUtility.HtmlEncode(text ?? ""); var version = TrainingNotice.Version.ToString(System.Globalization.CultureInfo.InvariantCulture); - var (message, withdraw) = state switch + var (message, anyLive) = state switch { ConsentState.Current current => ($"You agreed to version {version} on {Iso(current.AgreedAtUtc)}. Your messages may be used as the notice says.", true), - ConsentState.Older older => ($"You agreed to version {older.Version} on {Iso(older.AgreedAtUtc)}; version {version} is in force and your messages are not used until you agree to it.", false), + ConsentState.Older older => ($"You agreed to version {older.Version} on {Iso(older.AgreedAtUtc)}; version {version} is in force and your messages are not used until you agree to it.", true), ConsentState.Withdrawn withdrawn => ($"You withdrew on {Iso(withdrawn.WithdrawnAtUtc)}. Your messages are not used.", false), _ => ("You have not agreed. Your messages are not used.", false) }; var summary = error is null ? "" : $""""""; - var button = withdraw + // Withdraw whenever any live receipt exists (an older one included); Agree whenever none is live for the version in force. + var withdrawButton = anyLive ? """
""" + : ""; + var agreeButton = state is ConsentState.Current + ? "" : $"""
"""; + var button = agreeButton + withdrawButton; return $$""" diff --git a/tests/Bifrost.Web.Tests/ConsentTests.cs b/tests/Bifrost.Web.Tests/ConsentTests.cs index e6b55cc..ff3a627 100644 --- a/tests/Bifrost.Web.Tests/ConsentTests.cs +++ b/tests/Bifrost.Web.Tests/ConsentTests.cs @@ -148,7 +148,7 @@ public sealed class ConsentTests(RegistrationGateFactory factory) : IClassFixtur } [Fact] - public async Task Agreement_to_an_older_version_says_it_no_longer_counts_and_offers_agree() + public async Task Agreement_to_an_older_version_says_it_no_longer_counts_and_offers_both_agree_and_withdraw() { var identity = NewIdentity(); Seed(identity, (1, Agreed, null)); @@ -156,7 +156,7 @@ public sealed class ConsentTests(RegistrationGateFactory factory) : IClassFixtur Assert.Contains($"You agreed to version 1 on 2026-11-01T12:30:05Z; version {TrainingNotice.Version} is in force and your messages are not used until you agree to it", html); Assert.Contains("action=\"/auth/bifrost/consent/accept\"", html); - Assert.DoesNotContain("/consent/withdraw", html); + Assert.Contains("action=\"/auth/bifrost/consent/withdraw\"", html); } [Fact] -- 2.47.3 From e4b31b894002978d58c3c2004e0b37b71ad84726 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 11:34:50 +0000 Subject: [PATCH 4/4] Docs: training notice v2 rulings, optional consent, consent page, version-bump behavior Co-Authored-By: Claude Sonnet 5.5 --- docs/registration-gates.md | 37 ++++++---- docs/training-notice.md | 141 ++++++++++++++----------------------- 2 files changed, 76 insertions(+), 102 deletions(-) diff --git a/docs/registration-gates.md b/docs/registration-gates.md index 87f5db0..5560e30 100644 --- a/docs/registration-gates.md +++ b/docs/registration-gates.md @@ -80,21 +80,32 @@ raise the wait or lower the budget; do not add a visual or audio puzzle, which e Lives in Bifrost: the account, the receipt and the registration are one transaction, and chat admission is Bifrost's decision (`admission-is-bifrost-membership`). +- Consent is optional. Registration succeeds with or without it; a receipt exists only if the person agreed. See + `training-notice.md` for why and for what the notice says. - Notice: `TrainingNotice.Version` (positive integer, no leading zeros, per the version rule in Heimdall's - `answer-collector-consent.md`) and `TrainingNotice.Text`. Served at `GET /auth/bifrost/notice`. The text in the repo is - draft wording. Eureka owns the final wording in gamecult-ops; changing the text is a version bump, never an edit in - place. The text states that no Discord material is used. -- Receipt: `ConsentReceipt` (account, notice version, `AcceptedAtUtc`, `WithdrawnAtUtc`), unique per account and version, - written in the same save as the account. Registration without `acceptedNoticeVersion` equal to the current version - creates nothing. A new notice version needs a new acceptance; there is no path yet that asks for one. -- Withdrawal: `POST /auth/bifrost/consent/withdraw`, signed in, sets `WithdrawnAtUtc` once on the account's receipts. - Withdrawal stops future use and cannot recall past use; the notice says so. The row is one-way and never deleted. -- Consumer: a training export must include an account's data only while a receipt for the notice version in force - exists with `WithdrawnAtUtc` null. No export exists in Bifrost; this page is the contract it will be built against. + `answer-collector-consent.md`) and `TrainingNotice.Text`, served at `GET /auth/bifrost/notice`. Version 2 is in force. + Changing the text is a version bump, never an edit in place. The text states that nothing from Discord is used. +- Receipt: `ConsentReceipt` (account, notice version, `AcceptedAtUtc`, `WithdrawnAtUtc`). One live (not withdrawn) + receipt per account and version; withdrawn rows stay as history. At registration an `acceptedNoticeVersion` equal to + the version in force writes the receipt in the same save as the account; a different version is refused (400) and creates + nothing; absent means no receipt. +- Consent page: `GET /auth/bifrost/consent`, for any signed-in account (native, provider, existing). It shows the notice, + the account's state (no agreement, agreed to the current version, agreed to an older one, withdrawn) and the one + applicable button. `POST /auth/bifrost/consent/accept` and `POST /auth/bifrost/consent/withdraw` are form posts refused + with 403 unless they came from the site itself (the strict `RequestOriginGuard`), before anything is read or written. + Agree is idempotent; withdraw stamps `WithdrawnAtUtc` once and deletes nothing; agreeing again after a withdrawal writes + a new live receipt. Withdrawal stops future use and cannot recall past use; the notice says so. +- A version bump: receipts name the version agreed to, so after a bump no receipt matches and everyone is out of the set + until they agree to the new version on the consent page, which tells them their agreement is to an older version. No + one is prompted anywhere else; silence means no. +- Consumer: a training export must include an account's data only while a live receipt for the notice version in force + exists. No export exists in Bifrost; this page is the contract it will be built against, and the notice is not + operative for real people until the export enforces it with a test that a withdrawn or never-agreed account's rows are + absent. -Scope limits: accounts that arrive through Heimdall providers (Discord, Patreon, Twitch, GitHub) are created in -`HeimdallAuthService.UpsertUserAccountAsync` and get no receipt. They reach chat the same way. Closing that needs a -consent step on the completion page and is the next cut. Existing accounts are not re-asked. +Limits: accounts without a receipt (provider-created, existing, registered without agreeing) are excluded, which is the +rule, and they opt in on the consent page. A native account that loses its session cannot reach the page (no credential +until sign-in recovery exists), so it cannot withdraw. ## Email verification diff --git a/docs/training-notice.md b/docs/training-notice.md index f0eadbf..fd98cd0 100644 --- a/docs/training-notice.md +++ b/docs/training-notice.md @@ -1,101 +1,64 @@ -# Training notice: proposed version 2 +# Training notice, version 2 -Proposal from Eureka, for Heimdall to merge or refuse. It changes no code and no served text; merging it records the -proposal, and putting it live is a separate cut that bumps `TrainingNotice.Version` (see "To put it live"). Companion to -`docs/registration-gates.md`, whose "Consent receipt" section is the contract this reads against. Read against main -`33be966`: `TrainingNotice.cs`, the register and withdraw handlers in `Program.cs`, and the doc. Not run, not tested. +Eureka proposed a tighter notice and listed five gaps in the receipt flow (PR #55). The steward (Heimdall) ruled on +2026-11-11. The served text is `TrainingNotice.Text` and nothing else: this page records why it says what it says and +what each gap became, and does not repeat the wording. Contract for the receipt and the export is in +`registration-gates.md`. -## What is wrong with version 1 +## What version 2 changed from version 1 -| Line in version 1 | Problem | +| Version 1 | Version 2 | | --- | --- | -| "Chat messages and activity" | "Activity" names nothing. A reader cannot tell whether it covers display names, reactions, presence, direct messages or the email address Bifrost will soon hold. | -| "train and evaluate AI models" | Whose models, and does the text leave GameCult? Sending chat to a vendor's API to answer a message is different from building a dataset. The notice must say which it is. | -| "No Discord material is used" | True only if nothing bridged from Discord reaches a chat room. The sentence is a promise about the whole room, not about the registrant, and nobody has checked that no bridge exists. | -| "you can withdraw it at any time" | No form and no page exists. A native account has no credential (`registration-gates.md`, Email verification), and withdrawal needs a signed-in session. The sentence promises a door the person cannot yet reach. | -| "Withdrawing stops future use; it cannot recall use that already happened" | Honest, but silent on the question people actually ask: is my data deleted. It is not, and the notice must say so. | -| Registration without acceptance creates nothing | Consent is a condition of having an account. That is not a free choice, and the contract already treats "no receipt" as "no use", so the condition buys nothing it does not already have. | -| No controller, contact or retention | A person cannot tell who holds the receipt, for how long, or whom to ask. | +| "Chat messages and activity" | Names what counts: the text of messages with time and room, tied to the account name. Not the email address, sign-in details or anything not posted. | +| "train and evaluate AI models" | "that GameCult builds and runs". Whether a dataset or model may leave GameCult is Meta's call, still open (below); the notice makes no claim either way beyond what it says. | +| "No Discord material is used" | "Nothing that came from Discord is used, in any form." Kept: `gamecult-chat:ruling:discord-taints-training`. Bifrost has no Discord bridge into a chat room (its Discord posting actuator is cut); whether any other path carries Discord content into a room is for the chat steward to confirm, and any that exists must be excluded from every set for the sentence to stay true. | +| Consent required to register | Optional. See below. | +| "withdraw at any time" with no door | Names the door: the consent page, while signed in. | +| Silent on deletion | Says plainly that withdrawing deletes nothing and what it does and does not undo. | -## Proposed text (version 2) +## Rulings on the five gaps -Square brackets mark words only Meta can fill; they are not in the served text until filled. +1. **A native account with no session cannot withdraw.** True. A native account has no credential, so a visitor who loses + the session cookie has no way back in, to the consent page or anywhere else. Nothing in the consent flow can fix that; + sign-in recovery is the verified address (`registration-gates.md`, Email verification), not built. Until it is, such + a person cannot withdraw and the notice cannot promise otherwise. The consequence is bounded: consent is optional, so + the people affected are those who ticked it and then lost the session. This stays an open limit, listed under + "What remains". +2. **A version bump silently drops everyone.** True, and it stays the behavior because it is the honest one: a receipt + records agreement to specific words, and new words are not agreed to. The export includes an account only while a live + (not withdrawn) receipt for the version in force exists. After a bump, everyone is out until they agree again. The + notice says so ("if the notice changes, its version changes and we ask again; until you agree to the new version, your + messages are not used"), and the consent page shows the person that their agreement is to an older version and + offers the new one. Nobody is emailed or prompted elsewhere; asking is the page's job, and silence means no. +3. **Withdrawal had no CSRF check.** True. The session cookie's SameSite default is Lax, which already keeps a cross-site + POST from carrying it, but nothing in the handler relied on that. Both state changes (agree, withdraw) are now + form POSTs refused with 403 unless they came from the site itself (`RequestOriginGuard`, the strict form), before + anything is read or written. +4. **Provider-created and existing accounts have no receipt.** Correct, and now stated as the rule: no receipt, no use. + They can opt in on the consent page like anyone else, which is also the only way an existing member is asked. +5. **No export exists.** True. Every promise about use is a promise about code not written. The notice must not be + treated as operative for real people until the export enforces the contract with a test that a withdrawn or + never-agreed account's rows are absent (`registration-gates.md`, Consent receipt, Consumer). No deploy has put + version 2 in front of anyone. -> **GameCult chat training notice, version 2** -> -> GameCult may use messages you post in GameCult chat to train and evaluate AI models that GameCult builds and runs. -> [Meta: confirm that this is the whole purpose, and whether any dataset or model leaves GameCult.] -> -> **What counts.** The text of your messages, with their time and room, tied to your account name. Not your email -> address, your sign-in details or anything you did not post. Nothing that came from Discord is used, in any form. -> -> **It is your choice.** [If Meta rules consent optional:] You can use GameCult chat without agreeing. If you do not -> agree, none of your messages are used. -> -> **If you agree,** we record your account, this notice's version number and the time. Nothing else is stored with it. -> -> **If you withdraw,** we leave all your messages out of every training set made after that moment. Withdrawing deletes -> nothing: your messages stay in chat and on our servers, anything already used to train a model stays used, and a -> dataset already built is not recalled. We keep the record that you agreed and when you withdrew. [Meta: deletion of -> messages is a separate request, via ___, or not offered.] -> -> **How to withdraw:** ___. **Questions:** ___. +## Consent is optional -About 170 words. Plain, no consent boilerplate, and every sentence is something the code or an owner can honour. +Registration succeeds with or without agreement. A receipt exists only if the person agreed. The contract already +excluded accounts without a receipt, so optional costs nothing in correctness; it costs training data, and it removes +the objection that agreement obtained as a condition of an account is not a free choice. This is the steward's ruling +on Eureka's recommendation; Meta can overrule it. One consequence: the registration form no longer enforces the notice. -## What each change rests on +## Still open for Meta -- "Tied to your account name" and "not your email address": the export contract in `registration-gates.md` keys on the - account; nothing exports an address because no address is stored today. If email verification keeps the address, this - line becomes a rule the export must enforce, so keep it only if that is ruled. -- "Leave all your messages out of every set made after": the contract says an export includes an account's data only - while a receipt with `WithdrawnAtUtc` null exists, so withdrawal excludes the account's whole history from later - exports, not just later messages. The notice says the stronger thing because the contract delivers it. -- "Withdrawing deletes nothing": the withdraw handler sets a timestamp and nothing else. The room history lives in the - chat server, which Bifrost does not delete from. Whether backups hold it is for the chat steward to confirm. -- "We keep the record": the receipt row is one-way and never deleted, so say it. +- Purpose and scope: whether a dataset or model may leave GameCult, and whether a third-party model API processing + messages to answer them is covered. The notice is silent on both. +- Deletion of messages on request, who does it, and the contact words. The notice offers none. +- Controller and contact, and minimum age. Nothing here addresses either. +- Whether existing members should be asked to opt in beyond the consent page existing. +- Counsel's reading of consent under the law that applies to the people registering. Not researched here. -## Receipt flow: gaps found in reading +## What remains -1. **Withdrawal needs a session a native registrant may not have.** `POST /auth/bifrost/consent/withdraw` returns 401 - without a signed-in actor, and a native account has no credential until email verification gives it one. Until the - sign-in link exists, withdrawal by a native account is only possible through chat sign-in, if that works for them. I did - not trace whether it does. -2. **No re-ask path.** A new notice version leaves every old receipt unequal to the current version. The contract is - fail-closed (no match, no export), which is safe, but it also means a version bump silently removes everyone from the - dataset. The notice should say a new version asks again and silence means no. -3. **Provider-created and existing accounts have no receipt**, so they are excluded from any export. That is correct - behaviour and should be stated as the rule, not left as a "scope limit". -4. **Withdrawal is not CSRF-checked in what I read.** It is a cookie-authenticated POST with no body. I did not test - whether the antiforgery or cookie same-site settings cover it. Heimdall should confirm. -5. **Receipts are not exported anywhere yet.** The consumer (a training export) does not exist, so every promise above is - a promise about code not written. The notice should not go live to real people before the export enforces the - contract with a test that a withdrawn account's rows are absent. - -## Needs a decision - -For Meta (hers, not mine to settle): - -1. **Purpose and scope**: own-model training only, or datasets or models that leave GameCult? Third-party model APIs - processing messages to answer them: covered by this notice or not mentioned? -2. **Optional or required consent.** I recommend optional: registration succeeds and a receipt exists only if ticked. - The contract already excludes anyone without a receipt, so the code change is to make `acceptedNoticeVersion` - nullable. Required consent is also a legal question (consent given as a condition of service is weak in the EU); - I am not counsel and did not verify this against current law. -3. **Deletion**: offered on request, and by whom, or not offered. The notice must match. -4. **Controller and contact** words, and how a person withdraws before the form exists. -5. **Minimum age.** The notice and flow say nothing about children. Not researched. - -For members (the people whose messages these are): whether they want existing members asked at all. Version 1 does not -re-ask existing accounts, so they stay out of any dataset; asking them is the only way they can opt in. - -For Heimdall: items 1, 4 and 5 under "Receipt flow", and whether a Discord bridge into a chat room exists or is planned -(the ruling `gamecult-chat:ruling:discord-taints-training` forbids Discord material in training; the notice repeats it, so -the claim must be true). - -## To put it live - -A code cut, after Meta fills the brackets: replace `TrainingNotice.Text`, set `Version = 2`, update the tests that pin -version 1, and decide the optional-consent change in the same cut because it changes what a receipt means. Version 1 -should not have reached the public (the doc says no form exists; I did not check what is live), so a bump should cost no -re-ask; any real account holding a version 1 receipt falls out of the dataset until asked again. +- A signed-out native account cannot withdraw until sign-in recovery exists. +- No training export, so no operative use. +- Wording changes are a version bump with a new text; never edit version 2's text in place after it has been served. -- 2.47.3