desk: envelope names, decider bounds, a failing port is unavailable, hardening tests

The door context carries mind, authority, subject and deciders (fork-card.md M5).
Deciders are 1 to 64 unique ids per Mind, refused at startup and again before any
door call. A throwing credential port or account lookup answers 503 unavailable.
Startup refuses two Minds sharing one socket. Behavioural tests for the per-Mind
list, the body cap to the byte, Retry-After, audience case, no-store, cancellation
and the stale-fork card read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-10 20:15:05 +00:00
commit f03027d929
12 changed files with 449 additions and 18 deletions

View file

@ -30,8 +30,15 @@ public sealed class DeskMindOptions
// The full path of this Mind's socket on the peer-checked door. Bifrost only connects to it.
public string Socket { get; init; } = string.Empty;
// Accounts that may answer this Mind's forks. Sent to Huginn on every call; no request sets or extends it.
// Accounts that may answer this Mind's forks. Sent to Huginn with every call; no request sets or extends it.
public List<string> Deciders { get; init; } = [];
// Huginn's bound on the list a call carries (fork-card.md: one to 64 ids).
public const int MaxDeciders = 64;
// One to 64 ids, none repeated. The startup validator and the request path both ask this one question.
public static bool DecidersWithinBounds(IReadOnlyCollection<string> deciders) =>
deciders.Count is >= 1 and <= MaxDeciders && deciders.Distinct(StringComparer.Ordinal).Count() == deciders.Count;
}
// The authority a stamped answer carries (fork-card.md: Operator, Steward or Member), from configuration because

View file

@ -73,7 +73,18 @@ public static class DeskEndpoints
}
// 2-4. Bearer, audience, scope.
var token = await credential.AuthenticateAsync(http, cancellationToken);
DeskToken? token;
try
{
token = await credential.AuthenticateAsync(http, cancellationToken);
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
// The bearer could not be judged: neither a pass nor a refusal of the caller.
await WriteUnavailableAsync(http, desk);
return;
}
if (token is null || !string.Equals(token.Audience, Audience, StringComparison.Ordinal))
{
await WriteRefusalAsync(http, DeskRefusals.Unauthenticated);
@ -88,7 +99,17 @@ public static class DeskEndpoints
}
// 5. Active membership, re-read now; the body has not been touched.
var accountId = await OidcEndpoints.ActiveMemberIdAsync(db, token.Subject, cancellationToken);
Guid? accountId;
try
{
accountId = await OidcEndpoints.ActiveMemberIdAsync(db, token.Subject, cancellationToken);
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
await WriteUnavailableAsync(http, desk);
return;
}
if (accountId is null)
{
await WriteRefusalAsync(http, DeskRefusals.Forbidden);
@ -129,9 +150,15 @@ public static class DeskEndpoints
return;
}
// 8-10. The door.
// 8-10. The door. A decider list outside 1..64 unique ids is a configuration fault: nothing is sent.
if (!DeskMindOptions.DecidersWithinBounds(mind.Deciders))
{
await WriteUnavailableAsync(http, desk);
return;
}
var subject = accountId.Value.ToString("D");
var context = new DeskDoorContext(subject, desk.Attribution.AuthorityOf(subject), mind.Deciders);
var context = new DeskDoorContext(mind.Id, desk.Attribution.AuthorityOf(subject), subject, mind.Deciders.ToArray());
try
{
switch (route.Kind)

View file

@ -15,9 +15,11 @@ public interface IDeskCredential
ValueTask<DeskToken?> AuthenticateAsync(HttpContext context, CancellationToken cancellationToken);
}
// Sent with every door call, built by Bifrost alone: the verified account, its attribution, and the decider list
// configured for the route's Mind. Nothing in the request sets any of it.
public sealed record DeskDoorContext(string Subject, string Attribution, IReadOnlyList<string> Deciders);
// The envelope sent with every answer, read and list call (fork-card.md M5), under the contract's names, built by
// Bifrost alone: the route's configured Mind id, the verified account's authority (Operator, Steward or Member),
// the account id, and the admin-set decider list of that Mind (1 to 64 unique ids). Nothing in the request sets any
// of it, and Huginn keeps no copy of the list.
public sealed record DeskDoorContext(string Mind, string Authority, string Subject, IReadOnlyList<string> Deciders);
public abstract record DeskAnswerOutcome
{

View file

@ -151,6 +151,7 @@ public sealed class StartupConfigurationValidator(
}
var ids = new HashSet<string>(StringComparer.Ordinal);
var sockets = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < desk.Minds.Count; i++)
{
var mind = desk.Minds[i];
@ -167,6 +168,15 @@ public sealed class StartupConfigurationValidator(
{
failures.Add($"Desk:Minds:{i}:Socket must be an absolute path.");
}
else if (!sockets.Add(mind.Socket))
{
failures.Add($"Desk:Minds:{i}:Socket repeats an earlier Mind's socket.");
}
if (mind.Deciders.Count is < 1 or > DeskMindOptions.MaxDeciders)
{
failures.Add($"Desk:Minds:{i}:Deciders must hold 1 to {DeskMindOptions.MaxDeciders} account ids.");
}
var deciders = new HashSet<string>(StringComparer.Ordinal);
foreach (var decider in mind.Deciders)

View file

@ -124,8 +124,9 @@ public sealed class DeskAnswerTests
Assert.Equal(200, reply.Status);
var call = Assert.Single(factory.Door.Calls);
Assert.Equal(DeskWebApplicationFactory.Steward, call.Context.Subject);
Assert.Equal("Steward", call.Context.Attribution);
Assert.Equal([DeskWebApplicationFactory.Alice, DeskWebApplicationFactory.Steward], call.Context.Deciders);
Assert.Equal("Steward", call.Context.Authority);
Assert.Equal("nibu", call.Context.Mind);
Assert.Equal(DeskWebApplicationFactory.DecidersOf("nibu"), call.Context.Deciders);
}
}

View file

@ -0,0 +1,310 @@
using System.Text;
using System.Text.Json.Nodes;
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
// The rules the desk's edge keeps that a happy path never shows: whose decider list rides each call, the body cap
// to the byte, what the Retry-After comes from, the audience's case, no-store, and what a failing port becomes.
public sealed class DeskHardeningTests
{
private const string Nibu = "nibu";
private const string Chat = "gamecult-chat";
private const string NibuFork = "nibu:question:glow-order";
private static async Task<DeskWebApplicationFactory> ArrangeAsync(Dictionary<string, string?>? configuration = null)
{
var factory = new DeskWebApplicationFactory(configuration);
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
return factory;
}
private static string Token(DeskWebApplicationFactory factory, string audience = "bifrost-desk") =>
factory.Token(DeskWebApplicationFactory.Alice, audience, "desk.read", "desk.answer");
private static Task<DeskReply> SendRouteAsync(DeskWebApplicationFactory factory, string route, string mind, string token)
{
var fork = mind == Nibu ? NibuFork : DeskReplay.ChatFork;
return route switch
{
"answer" => factory.SendAsync("POST", DeskReplay.Target("answer", mind, null), DeskReplay.AnswerBody(fork), token),
"read" => factory.SendAsync("GET", DeskReplay.Target("read", mind, fork), null, token),
_ => factory.SendAsync("GET", DeskReplay.Target("list", mind, null), null, token),
};
}
private static readonly string[] Routes = ["answer", "read", "list"];
private static string Code(DeskReply reply) => (string)JsonNode.Parse(reply.Body)!["code"]!;
// (a) Each Mind's list is its own: a context built from another Mind's list changes the call.
[Fact]
public async Task Each_call_carries_the_route_minds_own_decider_list_and_envelope()
{
using var factory = await ArrangeAsync();
Assert.NotEqual(DeskWebApplicationFactory.DecidersOf(Chat), DeskWebApplicationFactory.DecidersOf(Nibu));
var token = Token(factory);
foreach (var mind in new[] { Chat, Nibu })
{
foreach (var route in Routes)
{
factory.Door.Calls.Clear();
var reply = await SendRouteAsync(factory, route, mind, token);
Assert.True(reply.Status == 200, $"{mind} {route}: {reply.Status}");
var call = Assert.Single(factory.Door.Calls);
Assert.True(DeskWebApplicationFactory.DecidersOf(mind).SequenceEqual(call.Context.Deciders), $"{mind} {route}: deciders");
Assert.Equal(mind, call.Context.Mind);
Assert.Equal(DeskWebApplicationFactory.SocketOf(mind), call.Socket);
Assert.Equal(DeskWebApplicationFactory.Alice, call.Context.Subject);
Assert.Equal("Operator", call.Context.Authority);
}
}
}
// (b) The cap to the byte, at the HTTP edge, however the length is announced.
[Theory]
[InlineData(0)]
[InlineData(4096)]
[InlineData(4097)]
[InlineData(8192)]
public async Task A_body_of_exactly_the_cap_reaches_the_shape_judge_and_one_more_byte_does_not(int cap)
{
var bare = DeskReplay.AnswerBody(DeskReplay.ChatFork);
cap = Math.Max(cap, bare.Length);
var config = DeskWebApplicationFactory.DefaultConfiguration();
config["Desk:MaxBodyBytes"] = cap.ToString(System.Globalization.CultureInfo.InvariantCulture);
using var factory = await ArrangeAsync(config);
var token = Token(factory);
var target = DeskReplay.Target("answer", Chat, null);
byte[] Padded(int length) => [.. bare, .. Enumerable.Repeat((byte)' ', length - bare.Length)];
var modes = new (string Name, Action<HttpContext> Configure)[]
{
("content-length", _ => { }),
("no content-length", http => http.Request.ContentLength = null),
("chunked", http =>
{
http.Request.ContentLength = null;
http.Request.Headers.TransferEncoding = "chunked";
}),
};
foreach (var (name, configure) in modes)
{
factory.Door.Calls.Clear();
var exact = await factory.SendAsync("POST", target, Padded(cap), token, configure: configure);
Assert.True(exact.Status == 200, $"{name} exact: {exact.Status} {exact.Text}");
Assert.Equal(cap, Assert.Single(factory.Door.Calls).Body!.Length);
factory.Door.Calls.Clear();
var over = await factory.SendAsync("POST", target, Padded(cap + 1), token, configure: configure);
Assert.True(over.Status == 400 && Code(over) == "body_invalid", $"{name} over: {over.Status} {over.Text}");
Assert.Empty(factory.Door.Calls);
}
}
// (c) The wait an `unavailable` names is the configured one, in the document and in the header.
[Fact]
public async Task Retry_after_comes_from_configuration_in_the_document_and_the_header()
{
var config = DeskWebApplicationFactory.DefaultConfiguration();
config["Desk:RetryAfterSeconds"] = "7";
using var factory = await ArrangeAsync(config);
factory.Door.Down = true;
var token = Token(factory);
foreach (var route in Routes)
{
var reply = await SendRouteAsync(factory, route, Chat, token);
Assert.True(reply.Status == 503, route);
Assert.Equal(7, (int)JsonNode.Parse(reply.Body)!["retryAfterSeconds"]!);
Assert.Equal("7", reply.Headers.RetryAfter.ToString());
}
}
// (d) The audience is compared ordinally.
[Theory]
[InlineData("BIFROST-DESK")]
[InlineData("Bifrost-Desk")]
[InlineData("bifrost-desk ")]
[InlineData("bifrost-votes")]
public async Task The_audience_is_case_sensitive(string audience)
{
using var factory = await ArrangeAsync();
foreach (var route in Routes)
{
var reply = await SendRouteAsync(factory, route, Chat, Token(factory, audience));
Assert.True(reply.Status == 401 && Code(reply) == "unauthenticated", $"{route}: {reply.Status}");
}
Assert.Empty(factory.Door.Calls);
}
// (e) No desk response is cacheable: answers, refusals, unavailable and an unrecognised target alike.
[Fact]
public async Task Every_desk_response_is_no_store()
{
using var factory = await ArrangeAsync();
var token = Token(factory);
var replies = new List<(string, DeskReply)>();
foreach (var route in Routes)
{
replies.Add((route + " 200", await SendRouteAsync(factory, route, Chat, token)));
replies.Add((route + " 401", await SendRouteAsync(factory, route, Chat, "")));
replies.Add((route + " 404", await SendRouteAsync(factory, route, "ghost", token)));
}
factory.Door.Down = true;
replies.Add(("503", await SendRouteAsync(factory, "list", Chat, token)));
replies.Add(("unrecognised", await factory.SendAsync("GET", "/api/desk/x", null, token)));
foreach (var (label, reply) in replies)
{
Assert.True(reply.Headers.CacheControl.ToString() == "no-store", $"{label}: '{reply.Headers.CacheControl}'");
}
Assert.Equal(200, replies[0].Item2.Status);
Assert.Equal(503, replies[^2].Item2.Status);
}
// (f) A cancelled request is not `unavailable`: the exception goes on to the host.
[Fact]
public async Task A_cancelled_request_is_not_called_unavailable()
{
using var factory = await ArrangeAsync();
using var cts = new CancellationTokenSource();
factory.Door.AnswerScript = (_, _, _) =>
{
cts.Cancel();
throw new IOException("the client went away");
};
var reply = await factory.SendAsync(
"POST", DeskReplay.Target("answer", Chat, null), DeskReplay.AnswerBody(DeskReplay.ChatFork), Token(factory),
configure: http => http.RequestAborted = cts.Token);
// The host's exception handling answers; the desk does not claim the door was merely unavailable.
Assert.NotEqual(503, reply.Status);
Assert.DoesNotContain("unavailable", reply.Text);
}
// (f) A stale_fork needs the current card; a card read that finds nothing, for any code, is `unavailable`.
[Theory]
[InlineData("fork_unknown")]
[InlineData("request_invalid")]
[InlineData("made_up")]
public async Task A_stale_fork_whose_card_read_finds_nothing_is_unavailable_never_a_stale_fork_without_a_card(string code)
{
using var factory = await ArrangeAsync();
var stale = new AnswerResult(
DeskSchemas.AnswerResult,
new DeskAnswerDocument(
DeskSchemas.DeskAnswer, "n1", DeskReplay.ChatFork, "Refused", null, null, "stale_fork", "oldhash", "no", null,
"2026-11-12T19:02:00Z", "2026-11-12T19:02:05Z", false, DeskWebApplicationFactory.Alice, "Operator",
"00000001-0000-4000-8000-000000000001"),
null,
null);
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(stale);
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(code);
var reply = await SendRouteAsync(factory, "answer", Chat, Token(factory));
Assert.Equal(503, reply.Status);
Assert.Equal("unavailable", Code(reply));
Assert.Null(JsonNode.Parse(reply.Body)!["card"]);
Assert.Contains(factory.Door.Calls, c => c.Kind == "read");
}
// (h) A port that fails is `unavailable`, never a 500 and never a pass.
[Fact]
public async Task A_throwing_credential_port_is_unavailable_and_opens_nothing()
{
using var factory = await ArrangeAsync();
factory.Credential.Throws = true;
foreach (var route in Routes)
{
var reply = await SendRouteAsync(factory, route, Chat, Token(factory));
Assert.True(reply.Status == 503 && Code(reply) == "unavailable", $"{route}: {reply.Status}");
}
Assert.Empty(factory.Door.Calls);
}
[Fact]
public async Task A_throwing_account_lookup_is_unavailable_and_opens_nothing()
{
using var factory = await ArrangeAsync();
// The request's database context is gone by the time the desk looks the account up (the database failed).
factory.Credential.OnAuthenticate = http => http.RequestServices.GetRequiredService<Bifrost.Web.Data.BifrostDbContext>().Dispose();
foreach (var route in Routes)
{
var reply = await SendRouteAsync(factory, route, Chat, Token(factory));
Assert.True(reply.Status == 503 && Code(reply) == "unavailable", $"{route}: {reply.Status}");
}
Assert.Empty(factory.Door.Calls);
}
// The runtime guard on the decider list (the startup validator is the first layer; this is the second).
private static string Id(int n) => new Guid(n, 0, 0, new byte[8]).ToString("D");
private static void SetDeciders(DeskWebApplicationFactory factory, string mind, IEnumerable<string> ids)
{
var list = factory.Services.GetRequiredService<IOptions<DeskOptions>>().Value.Minds.Single(m => m.Id == mind).Deciders;
list.Clear();
list.AddRange(ids);
}
[Fact]
public async Task The_door_is_called_with_64_deciders()
{
using var factory = await ArrangeAsync();
var ids = Enumerable.Range(1, 64).Select(Id).ToList();
SetDeciders(factory, Chat, ids);
foreach (var route in Routes)
{
factory.Door.Calls.Clear();
var reply = await SendRouteAsync(factory, route, Chat, Token(factory));
Assert.True(reply.Status == 200, route);
Assert.Equal(ids, Assert.Single(factory.Door.Calls).Context.Deciders);
}
}
[Theory]
[InlineData("65")]
[InlineData("0")]
[InlineData("duplicate")]
public async Task A_decider_list_out_of_bounds_is_unavailable_without_a_door_call(string fault)
{
using var factory = await ArrangeAsync();
var ids = fault switch
{
"65" => Enumerable.Range(1, 65).Select(Id).ToList(),
"0" => [],
_ => [Id(1), Id(2), Id(1)],
};
SetDeciders(factory, Chat, ids);
foreach (var route in Routes)
{
var reply = await SendRouteAsync(factory, route, Chat, Token(factory));
Assert.True(reply.Status == 503 && Code(reply) == "unavailable", $"{route}: {reply.Status}");
}
Assert.Empty(factory.Door.Calls);
// Only that Mind's list is at fault: the other Mind still answers.
var other = await SendRouteAsync(factory, "list", Nibu, Token(factory));
Assert.Equal(200, other.Status);
}
}

View file

@ -20,6 +20,7 @@ public sealed class DeskOptionsTests
["Desk:Minds:0:Deciders:0"] = Alice,
["Desk:Minds:1:Id"] = "nibu",
["Desk:Minds:1:Socket"] = "/run/huginn/huginn-nibu.sock",
["Desk:Minds:1:Deciders:0"] = Bob,
["Desk:Attribution:Operator"] = Alice,
["Desk:Attribution:Stewards:0"] = Bob,
};
@ -59,7 +60,7 @@ public sealed class DeskOptionsTests
Assert.Equal(["gamecult-chat", "nibu"], options.Minds.Select(m => m.Id));
Assert.Equal([Alice], options.Minds[0].Deciders);
Assert.Empty(options.Minds[1].Deciders);
Assert.Equal([Bob], options.Minds[1].Deciders);
Validate(Good());
}
@ -158,6 +159,47 @@ public sealed class DeskOptionsTests
Refused(repeated, "Desk:Attribution:Stewards repeats");
}
private static string Id(int n) => new Guid(n, 0, 0, new byte[8]).ToString("D");
private static Dictionary<string, string?> WithDeciders(int count)
{
var values = Good();
for (var i = 0; i < count; i++)
{
values[$"Desk:Minds:0:Deciders:{i}"] = Id(i + 1);
}
if (count == 0)
{
values.Remove("Desk:Minds:0:Deciders:0");
}
return values;
}
[Fact]
public void Startup_takes_one_to_64_deciders_per_mind()
{
Validate(WithDeciders(1));
Validate(WithDeciders(64));
Refused(WithDeciders(65), "Desk:Minds:0:Deciders must hold 1 to 64");
Refused(WithDeciders(0), "Desk:Minds:0:Deciders must hold 1 to 64");
// The bound is per Mind: the second Mind is judged on its own list.
var second = Good();
second.Remove("Desk:Minds:1:Deciders:0");
Refused(second, "Desk:Minds:1:Deciders must hold 1 to 64");
}
[Fact]
public void Startup_refuses_two_minds_sharing_one_socket()
{
var values = Good();
values["Desk:Minds:1:Socket"] = values["Desk:Minds:0:Socket"];
Refused(values, "Desk:Minds:1:Socket repeats an earlier Mind's socket");
}
[Fact]
public void Desk_failures_are_reported_in_production_too_and_alongside_each_other()
{

View file

@ -164,6 +164,6 @@ public sealed class DeskReadTests
Assert.Equal("body_unknown_field", (string?)JsonNode.Parse(named.Body)!["code"]);
Assert.Equal(["list", "read", "answer"], factory.Door.Calls.Select(c => c.Kind).ToArray());
Assert.All(factory.Door.Calls, c => Assert.Equal([DeskWebApplicationFactory.Alice, DeskWebApplicationFactory.Steward], c.Context.Deciders));
Assert.All(factory.Door.Calls, c => Assert.Equal(DeskWebApplicationFactory.DecidersOf("nibu"), c.Context.Deciders));
}
}

View file

@ -9,7 +9,7 @@ public sealed class DeskScenarioTests
{
private static readonly string Socket = DeskWebApplicationFactory.SocketOf("gamecult-chat");
private static readonly string[] ConfiguredDeciders = [DeskWebApplicationFactory.Alice, DeskWebApplicationFactory.Steward];
private static readonly string[] ConfiguredDeciders = DeskWebApplicationFactory.DecidersOf("gamecult-chat");
// Replays every answer step of every scenario. The door is scripted from the vector's response; what Bifrost
// must produce by itself (edge refusals, unavailable, the attached card) is compared with the vector.
@ -101,7 +101,8 @@ public sealed class DeskScenarioTests
var call = Assert.Single(door.Calls.Where(c => c.Kind == "answer"));
Assert.Equal(body, call.Body);
Assert.Equal(answerer, call.Context.Subject);
Assert.Equal((string)server["authority"]!, call.Context.Attribution);
Assert.Equal((string)server["authority"]!, call.Context.Authority);
Assert.Equal("gamecult-chat", call.Context.Mind);
Assert.Equal(ConfiguredDeciders, call.Context.Deciders);
Assert.Equal(Socket, call.Socket);
}

View file

@ -35,6 +35,9 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory
public FakeDeskDoor Door { get; } = new();
public FakeDeskCredential Credential { get; } = new();
public static Dictionary<string, string?> DefaultConfiguration()
{
var values = new Dictionary<string, string?>
@ -49,14 +52,23 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory
{
values[$"Desk:Minds:{i}:Id"] = mind.Key;
values[$"Desk:Minds:{i}:Socket"] = SocketOf(mind.Key);
values[$"Desk:Minds:{i}:Deciders:0"] = Alice;
values[$"Desk:Minds:{i}:Deciders:1"] = Steward;
var deciders = DecidersOf(mind.Key);
for (var d = 0; d < deciders.Length; d++)
{
values[$"Desk:Minds:{i}:Deciders:{d}"] = deciders[d];
}
i++;
}
return values;
}
// A distinct list per Mind (different members, order and length), so a door context that carries another
// Mind's list cannot pass for the route Mind's.
public static string[] DecidersOf(string mindId) =>
mindId == "nibu" ? [Steward, Alice, Member] : [Alice, Steward];
public static string SocketOf(string mindId) =>
"/run/huginn/" + (string)DeskVectors.Root["transport"]!["mindRoutes"]!["minds"]![mindId]!;
@ -75,7 +87,7 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory
{
builder.ConfigureServices(services =>
{
services.AddSingleton<IDeskCredential, FakeDeskCredential>();
services.AddSingleton<IDeskCredential>(Credential);
services.AddSingleton<IDeskDoor>(Door);
});
}

View file

@ -13,8 +13,20 @@ public sealed class FakeDeskCredential : IDeskCredential
public static string Encode(string? subject, string? audience, IEnumerable<string> scopes) =>
JsonSerializer.Serialize(new { sub = subject, aud = audience, scopes });
// The port fails (its key source is down), instead of answering.
public bool Throws { get; set; }
// Runs when a request reaches the port, after the host's own middleware: a test breaks what comes next.
public Action<HttpContext>? OnAuthenticate { get; set; }
public ValueTask<DeskToken?> AuthenticateAsync(HttpContext context, CancellationToken cancellationToken)
{
if (Throws)
{
throw new InvalidOperationException("the credential port is down");
}
OnAuthenticate?.Invoke(context);
if (!context.Request.Headers.TryGetValue(Header, out var raw))
{
return ValueTask.FromResult<DeskToken?>(null);

View file

@ -27,6 +27,8 @@ public sealed class FakeDeskDoor : IDeskDoor
public bool ThrowOnRead { get; set; }
public Func<string, string, DeskReadOutcome>? ReadScript { get; set; }
public Func<string, DeskListRequest, DeskListOutcome>? ListScript { get; set; }
public Task<DeskListOutcome> ListAsync(
@ -90,6 +92,11 @@ public sealed class FakeDeskDoor : IDeskDoor
throw new IOException("huginn is down");
}
if (ReadScript is not null)
{
return Task.FromResult(ReadScript(socket, forkId));
}
return Task.FromResult<DeskReadOutcome>(Forks.TryGetValue((socket, forkId), out var entry)
? new DeskReadOutcome.Found(entry)
: new DeskReadOutcome.NotFound(DeskRefusals.ForkUnknown));
@ -111,7 +118,7 @@ public sealed class FakeDeskDoor : IDeskDoor
DeskSchemas.DeskAnswer, "n1", forkId, "Counted", null, null, null,
(string)request["options_hash"]!, (string?)answer?["choice"], (string?)answer?["note"],
(string)request["answered_at"]!, "2026-11-12T19:02:05Z", false,
context.Subject, context.Attribution, (string)request["request_id"]!),
context.Subject, context.Authority, (string)request["request_id"]!),
null,
"n1"));
}