Desk: the training-notice receipt gates the chat token only; Bifrost no longer masks answerer ids (Huginn's door withholds them), notes stay stripped

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-11 17:53:33 +00:00
commit 17e46df690
4 changed files with 68 additions and 33 deletions

View file

@ -1,25 +1,14 @@
namespace Bifrost.Web.Features.Desk;
// What a caller who is not a decider of the Mind does not read, taken out at the door and nowhere else. For them
// every note is nil and every answerer is the sentinel; every key stays written (nil where absent, a string where a
// string is required), so the redacted body is still the contract's shape. Cards, outcomes, codes, counts and the
// number of answers are untouched; Huginn returns the full record and Bifrost drops and adds nothing.
//
// The sentinel is one fixed string for every answer: nothing derived from an account id (a hash or a per-reader
// alias would still let a reader link two answers to one person, or confirm a guess), and it is not a D-format
// UUID, so it cannot be mistaken for an account. The contract's `answerer` is a non-empty string and nothing more.
// What a caller who is not a decider of the Mind does not read, taken out at the door and nowhere else: every note is
// nil, with every key still written, so the body is still the contract's shape. Account ids are not Bifrost's to hide:
// Huginn's door withholds the decider ids and answerer ids from a non-decider and returns counts
// (eureka-substrate:ruling:desk-read-visibility-withheld-ids), and Bifrost cannot mask what it cannot read (the card is
// opaque to it). Cards, outcomes, codes, counts and the number of answers are untouched.
public static class DeskRedaction
{
public const string MaskedAnswerer = "masked";
public static ForkEntry ForNonDecider(ForkEntry entry) =>
entry with
{
Record = entry.Record with
{
Answers = [.. entry.Record.Answers.Select(a => a with { Note = null, Answerer = MaskedAnswerer })],
},
};
entry with { Record = entry.Record with { Answers = [.. entry.Record.Answers.Select(a => a with { Note = null })] } };
public static ForkList ForNonDecider(ForkList list) => list with { Forks = [.. list.Forks.Select(ForNonDecider)] };
}

View file

@ -101,10 +101,11 @@ public static class OidcEndpoints
_ => [Destinations.AccessToken]
});
// The training notice is the chat's: a desk token is not a chat token and needs no receipt (Meta's ruling, 2026-11-11).
// Last, so a refusal that no agreement can cure (suspended, no identity, reserved) is never answered with an invitation to agree.
// Not an OIDC error: the member is sent to the consent page with this request's resume path, and the parked request
// (single use, ten minutes) is still there when they come back, because only an OpenIddict response spends it.
if (!await ConsentEndpoints.HasLiveReceiptAsync(db, account.Id, http.RequestAborted))
if (!IsDeskToken(principal) && !await ConsentEndpoints.HasLiveReceiptAsync(db, account.Id, http.RequestAborted))
{
return Results.Redirect(ConsentEndpoints.PathFor(http.Request.PathBase + http.Request.Path + http.Request.QueryString));
}
@ -112,6 +113,9 @@ public static class OidcEndpoints
return Results.SignIn(principal, properties: null, OpenIddictScheme);
}
/// <summary>A token whose scopes include a desk scope is a desk token. Only the chat token requires the training-notice receipt.</summary>
private static bool IsDeskToken(ClaimsPrincipal? principal) => principal is not null && principal.GetScopes().Any(DeskEndpoints.IsDeskScope);
private static async Task<IResult> ExchangeAsync(HttpContext http, BifrostDbContext db)
{
// Only the authorization code grant is enabled, so OpenIddict has refused every other grant already.
@ -119,7 +123,7 @@ public static class OidcEndpoints
// because the code outlives the authorize decision (an account suspended or withdrawn since gets no tokens).
var result = await http.AuthenticateAsync(OpenIddictScheme);
var subject = result.Principal?.GetClaim(Claims.Subject);
if (await ChatRefusalAsync(db, subject, http.RequestAborted) is { } refusal)
if (await ChatRefusalAsync(db, subject, http.RequestAborted, IsDeskToken(result.Principal)) is { } refusal)
{
return Deny(Errors.InvalidGrant, refusal);
}
@ -136,7 +140,7 @@ public static class OidcEndpoints
}
// An access token outlives the authorize decision too: an account suspended since, or one whose agreement has gone, is no longer answered.
if (await ChatRefusalAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted) is { } refusal)
if (await ChatRefusalAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted, IsDeskToken(result.Principal)) is { } refusal)
{
return Deny(Errors.InsufficientAccess, refusal);
}
@ -154,9 +158,9 @@ public static class OidcEndpoints
/// <summary>
/// The one reading of "this subject may still chat", shared by the token and userinfo endpoints and the logout reconciler:
/// null when they may, else the description of why not. The account exists, its membership is not Suspended (absent, pending
/// or Active all chat), and it holds a live receipt for the notice version in force. Re-read on every call.
/// or Active all chat), and, for a chat token (not a desk token), it holds a live receipt for the notice version in force. Re-read on every call.
/// </summary>
internal static async Task<string?> ChatRefusalAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
internal static async Task<string?> ChatRefusalAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken, bool deskToken = false)
{
if (!Guid.TryParse(subject, out var accountId))
{
@ -171,7 +175,7 @@ public static class OidcEndpoints
return SuspendedDescription;
}
return await ConsentEndpoints.HasLiveReceiptAsync(db, accountId, cancellationToken) ? null : ConsentRequiredDescription;
return deskToken || await ConsentEndpoints.HasLiveReceiptAsync(db, accountId, cancellationToken) ? null : ConsentRequiredDescription;
}
/// <summary>The account id of the subject when it is an Active member now, else null. The door to the fork desk (cookie surfaces check IsActiveMember); a chat-tier token does not pass it. Re-read on every call.</summary>

View file

@ -1,3 +1,4 @@
using Bifrost.Web.Data;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
@ -118,6 +119,32 @@ public sealed class DeskCredentialTests : IDisposable
Assert.Equal("answer", Assert.Single(factory.Door.Calls).Kind);
}
[Fact]
public async Task A_desk_token_issues_for_a_member_with_no_training_notice_receipt_and_a_chat_token_does_not()
{
var (client, identity) = await SignedInAsync();
await RemoveReceiptsAsync(identity, withdrawOnly: false);
// Chat: sent to the consent page, no code.
var chat = NewRequest(OidcTestKit.ClientId, OidcTestKit.RedirectUri, "openid profile");
var chatFinal = await DriveAsync(client, chat);
Assert.Equal(HttpStatusCode.Redirect, chatFinal.StatusCode);
Assert.DoesNotContain("code=", chatFinal.Headers.Location!.OriginalString);
// Desk: a code, a token, and the desk answers it.
var request = NewRequest(DeskClient, DeskRedirect, "desk.read");
var code = await CodeAsync(client, request);
var token = await ExchangeAsync(client, request, code, DeskClient, DeskSecret);
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 0, 0, [], null));
Assert.Equal(200, (await GetAsync(token)).Status);
// A desk code survives the receipt going between authorize and the exchange; a suspended account still does not.
var later = NewRequest(DeskClient, DeskRedirect, "desk.read");
var laterCode = await CodeAsync(client, later);
await RemoveReceiptsAsync(identity, withdrawOnly: true);
Assert.False(string.IsNullOrEmpty(await ExchangeAsync(client, later, laterCode, DeskClient, DeskSecret)));
}
[Fact]
public async Task The_chat_client_can_neither_ask_for_a_desk_scope_nor_use_its_own_token_on_the_desk()
{
@ -199,7 +226,7 @@ public sealed class DeskCredentialTests : IDisposable
}
[Fact]
public async Task A_member_who_is_not_a_decider_reads_masked_answerers_through_a_real_token()
public async Task A_member_who_is_not_a_decider_reads_no_notes_through_a_real_token_and_ids_pass_as_the_door_sent_them()
{
var (_, token) = await MemberWithTokenAsync("desk.read");
const string secretAnswerer = "0badc0de-0000-4000-8000-00000000000d"; // not on any decider list, so no card carries it
@ -213,7 +240,7 @@ public sealed class DeskCredentialTests : IDisposable
var reply = await GetAsync(token);
Assert.Equal(200, reply.Status);
Assert.DoesNotContain(secretAnswerer, reply.Text);
Assert.Contains(secretAnswerer, reply.Text); // Bifrost masks no id; Huginn's door withholds them from non-deciders
Assert.DoesNotContain("private words", reply.Text);
}
@ -246,6 +273,25 @@ public sealed class DeskCredentialTests : IDisposable
return (client, identity);
}
// Leaves the account with no live training-notice receipt: none at all, or each one withdrawn.
private async Task RemoveReceiptsAsync(string identity, bool withdrawOnly)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var id = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).Id;
var receipts = db.ConsentReceipts.Where(x => x.UserAccountId == id).ToList();
if (withdrawOnly)
{
receipts.ForEach(r => r.WithdrawnAtUtc = DateTimeOffset.UtcNow);
}
else
{
db.ConsentReceipts.RemoveRange(receipts);
}
await db.SaveChangesAsync();
}
private async Task<(string Identity, string Token)> MemberWithTokenAsync(string scope)
{
var (client, identity) = await SignedInAsync();

View file

@ -430,17 +430,13 @@ public sealed class DeskReadTests
Assert.DoesNotContain("decider's own words", list);
Assert.False(AnyNote(JsonNode.Parse(read)));
Assert.False(AnyNote(JsonNode.Parse(list)));
// No answerer id of any answer reaches a non-decider, and every answer says the same thing instead.
var readMasked = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray().Select(a => (string?)a!["answerer"]);
Assert.Equal(4, readMasked.Count());
Assert.All(readMasked, answerer => Assert.Equal("masked", answerer));
var masked = JsonNode.Parse(list)!["forks"]!.AsArray().SelectMany(f => f!["record"]!["answers"]!.AsArray()).Select(a => (string?)a!["answerer"]);
Assert.Equal(8, masked.Count());
Assert.All(masked, answerer => Assert.Equal("masked", answerer));
// Bifrost masks no id: what the door sent for the answerers is what the reader gets (the door withholds ids from non-deciders).
var readAnswerers = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray().Select(a => (string?)a!["answerer"]);
Assert.Equal(NotedEntry().Record.Answers.Select(x => x.Answerer), readAnswerers);
// Outcomes and cards stay; the key stays written as nil, so the body is still the contract's shape.
var expected = JsonSerializer.SerializeToNode(NotedEntry() with
{
Record = NotedEntry().Record with { Answers = [.. NotedEntry().Record.Answers.Select(a => a with { Note = null, Answerer = DeskRedaction.MaskedAnswerer })] },
Record = NotedEntry().Record with { Answers = [.. NotedEntry().Record.Answers.Select(a => a with { Note = null })] },
}, DeskJson.Options);
Assert.True(DeskVectors.SameShape(JsonNode.Parse(read), expected));
var answers = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray();