diff --git a/src/Bifrost.Web/Features/Desk/DeskRedaction.cs b/src/Bifrost.Web/Features/Desk/DeskRedaction.cs
index 1a41f71..434ea2a 100644
--- a/src/Bifrost.Web/Features/Desk/DeskRedaction.cs
+++ b/src/Bifrost.Web/Features/Desk/DeskRedaction.cs
@@ -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)] };
}
diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs
index ba67d8c..782f5f2 100644
--- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs
+++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs
@@ -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);
}
+ /// A token whose scopes include a desk scope is a desk token. Only the chat token requires the training-notice receipt.
+ private static bool IsDeskToken(ClaimsPrincipal? principal) => principal is not null && principal.GetScopes().Any(DeskEndpoints.IsDeskScope);
+
private static async Task 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
///
/// 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.
///
- internal static async Task ChatRefusalAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
+ internal static async Task 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;
}
/// 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.
diff --git a/tests/Bifrost.Web.Tests/DeskCredentialTests.cs b/tests/Bifrost.Web.Tests/DeskCredentialTests.cs
index 3807278..ea45409 100644
--- a/tests/Bifrost.Web.Tests/DeskCredentialTests.cs
+++ b/tests/Bifrost.Web.Tests/DeskCredentialTests.cs
@@ -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();
+ 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();
diff --git a/tests/Bifrost.Web.Tests/DeskReadTests.cs b/tests/Bifrost.Web.Tests/DeskReadTests.cs
index 9688051..e32410d 100644
--- a/tests/Bifrost.Web.Tests/DeskReadTests.cs
+++ b/tests/Bifrost.Web.Tests/DeskReadTests.cs
@@ -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();