Desk: UnixSocketDeskDoor, the production IDeskDoor over Huginn's desk socket

One connection per call, one request frame, one reply frame, 1 s deadline,
65536-byte bound each way. Request frames reproduce Huginn's golden bytes;
replies are decoded by key and every failure is one DeskDoorException (503).
Adds the MessagePack package for the wire codec.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-11 17:40:47 +00:00
commit 6dca0fa7d4
9 changed files with 1112 additions and 4 deletions

View file

@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack" Version="3.1.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View file

@ -46,6 +46,7 @@ public static class DeskEndpoints
services.TryAddSingleton<IDeskCredential, OidcDeskCredential>();
}
services.TryAddSingleton<IDeskDoor>(_ => new UnixSocketDeskDoor());
services.AddSingleton(provider => new DeskMindRoutes(provider.GetRequiredService<IOptions<DeskOptions>>().Value.Minds));
services.AddCors();
// Exactly the configured origin, no credentials, and only on the routes that require this policy.

View file

@ -2,8 +2,9 @@ namespace Bifrost.Web.Features.Desk;
// The two ports the desk stands on. Bifrost owns the edge's order and judgments; these two own what is not
// Bifrost's yet: validating a bearer token (the OIDC validation scheme) and speaking to a Mind's Huginn on the
// peer-checked door. No production implementation is registered here: with the Desk section configured and a port
// missing, startup fails (DeskEndpoints.MapDesk) instead of serving.
// peer-checked door. The door's production implementation is UnixSocketDeskDoor (registered by AddBifrostDesk unless
// a test or host registered its own); the credential has none registered here, so with the Desk section configured
// and a port missing, startup fails (DeskEndpoints.MapDesk) instead of serving.
// What a validated bearer token claims. The credential port verifies the token; the desk judges audience, scope and
// membership. A null Subject or Audience means the token did not carry one.

View file

@ -0,0 +1,462 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Globalization;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using MessagePack;
namespace Bifrost.Web.Features.Desk;
// The door could not confirm an outcome: no connection, no whole reply in time, or a reply that is not the contract's
// (an error, a failure, a Mind refusal, an unknown variant). The desk edge maps any exception from the door to 503
// `unavailable`; this is the one type the client throws for every one of those, so a failure is never an empty page.
public sealed class DeskDoorException(string message, Exception? inner = null) : Exception(message, inner);
// The production IDeskDoor: a client of Huginn's desk door (Huginn docs/desk-feed-door.md at 9dde93e). One new
// connection per call, one request frame, one reply frame, closed after, all inside one deadline. The client holds no
// connection state, so a dead socket fails only the calls addressed to that Mind.
//
// frame = u32 big-endian length || CultNet message (MessagePack map), at most 65536 bytes each way
// payload = standard base64 of the named MessagePack of Huginn's externally tagged DeskRequest / DeskResponse
//
// The reply is decoded by key, never by position. The card inside an entry is not read: it stays an opaque JsonElement.
public sealed partial class UnixSocketDeskDoor : IDeskDoor
{
public const int MaxFrameBytes = 65536;
public static readonly TimeSpan Deadline = TimeSpan.FromSeconds(1);
private const string ServiceId = "huginn.desk";
private const string RequestSchema = "huginn.desk_request.v1";
private const string ResponseSchema = "huginn.desk_response.v1";
private const string Encoding64 = "messagepack-base64";
private const int MaxMessageIdBytes = 256;
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private readonly Func<string> messageIds;
public UnixSocketDeskDoor()
: this(() => Guid.NewGuid().ToString("N"))
{
}
public UnixSocketDeskDoor(Func<string> messageIds) => this.messageIds = messageIds;
public async Task<DeskAnswerOutcome> AnswerAsync(
string socket, DeskDoorContext context, ReadOnlyMemory<byte> body, CancellationToken cancellationToken)
{
byte[] request;
try
{
// Every key, null and key order of the client's body, as MessagePack.
request = MessagePackSerializer.ConvertFromJson(StrictUtf8.GetString(body.Span));
}
catch (Exception e) when (e is not OperationCanceledException)
{
throw new DeskDoorException("the answer body does not convert to the request map", e);
}
var payload = Payload("Answer", 5, (ref MessagePackWriter w) =>
{
w.Write("mind");
w.Write(context.Mind);
w.Write("subject");
w.Write(context.Subject);
w.Write("authority");
w.Write(context.Authority);
w.Write("deciders");
Strings(ref w, context.Deciders);
w.Write("request");
w.WriteRaw(request);
});
var (variant, value) = await ExchangeAsync(socket, "answer", payload, cancellationToken);
switch (variant)
{
case "Answered":
var answer = Document<DeskAnswerDocument>(value, "answer");
if (answer is null || answer.SchemaVersion != DeskSchemas.DeskAnswer)
{
throw new DeskDoorException("the answered document is not gamecult.desk_answer.v0");
}
// Huginn's reply carries no card: a stale_fork answer gets the current card from the edge's read.
return new DeskAnswerOutcome.Answered(new AnswerResult(
DeskSchemas.AnswerResult, answer, null, OptionalString(value, "counted_answer_id")));
case "NotStored":
var code = RefusalCode(value);
return code is DeskRefusals.ForkTooLarge
? throw new DeskDoorException("fork_too_large is not an answer refusal")
: new DeskAnswerOutcome.NotStored(code);
default:
throw Unexpected(variant);
}
}
public async Task<DeskReadOutcome> ReadAsync(
string socket, DeskDoorContext context, string forkId, CancellationToken cancellationToken)
{
var payload = Payload("Fork", 4, (ref MessagePackWriter w) =>
{
w.Write("mind");
w.Write(context.Mind);
w.Write("subject");
w.Write(context.Subject);
w.Write("deciders");
Strings(ref w, context.Deciders);
w.Write("fork_id");
w.Write(forkId);
});
var (variant, value) = await ExchangeAsync(socket, "fork", payload, cancellationToken);
switch (variant)
{
case "Fork":
var entry = Document<ForkEntry>(value, "entry");
if (entry is null || entry.SchemaVersion != "gamecult.fork.entry.v0" || !IsWholeEntry(entry))
{
throw new DeskDoorException("the entry is not gamecult.fork.entry.v0");
}
return new DeskReadOutcome.Found(entry);
case "NotStored":
var code = RefusalCode(value);
// The route's fork id, never the refusal's informational text.
return code == DeskRefusals.ForkTooLarge
? new DeskReadOutcome.ForkTooLarge(forkId)
: new DeskReadOutcome.NotFound(code);
default:
throw Unexpected(variant);
}
}
public async Task<DeskListOutcome> ListAsync(
string socket, DeskDoorContext context, DeskListRequest request, CancellationToken cancellationToken)
{
var payload = Payload("Forks", 6, (ref MessagePackWriter w) =>
{
w.Write("mind");
w.Write(context.Mind);
w.Write("subject");
w.Write(context.Subject);
w.Write("deciders");
Strings(ref w, context.Deciders);
w.Write("status");
w.Write(request.Status);
w.Write("cursor");
w.Write(request.Cursor);
w.Write("limit");
w.Write(request.Limit);
});
var (variant, value) = await ExchangeAsync(socket, "forks", payload, cancellationToken);
switch (variant)
{
case "Forks":
var list = Document<ForkList>(value, "list");
if (list is null || list.SchemaVersion != "gamecult.fork.list.v0" || list.Mind is null || list.Stamp is null
|| list.Forks is null || !list.Forks.All(IsWholeEntry))
{
throw new DeskDoorException("the list is not gamecult.fork.list.v0");
}
return new DeskListOutcome.Listed(list);
case "NotStored":
var code = RefusalCode(value);
if (code != DeskRefusals.ForkTooLarge)
{
return new DeskListOutcome.NotListed(code);
}
// The fork and its order exist only in the informational text; anything but the exact form is a fault.
var text = RequiredString(value, "error");
var match = ListTooLarge().Match(text);
if (!match.Success || !int.TryParse(match.Groups["order"].ValueSpan, NumberStyles.None, CultureInfo.InvariantCulture, out var order))
{
throw new DeskDoorException("fork_too_large does not name its fork and order in the list form");
}
return new DeskListOutcome.ForkTooLarge(match.Groups["id"].Value, order);
default:
throw Unexpected(variant);
}
}
// fork_too_large: fork <id> at order <N> is past the page budget by itself; ...
[GeneratedRegex(@"\Afork_too_large: fork (?<id>\S+) at order (?<order>0|[1-9][0-9]*) is past the page budget(?:[ ;][^\r\n]*)?\z", RegexOptions.CultureInvariant)]
private static partial Regex ListTooLarge();
private static bool IsWholeEntry(ForkEntry entry) =>
entry.Card.ValueKind == JsonValueKind.Object && entry.Record is { Answers: not null };
private static DeskDoorException Unexpected(string variant) =>
new($"the door answered with {(variant == "Refused" ? "a Mind refusal" : "a variant this call does not take")}");
private static string RefusalCode(JsonElement refusal)
{
if (RequiredString(refusal, "schemaVersion") != DeskSchemas.Refusal)
{
throw new DeskDoorException("the refusal is not gamecult.fork.refusal.v0");
}
var code = RequiredString(refusal, "code");
_ = RequiredString(refusal, "error");
return code is DeskRefusals.ForkUnknown or DeskRefusals.BodyInvalid or DeskRefusals.RequestIdConflict or DeskRefusals.ForkTooLarge
? code
: throw new DeskDoorException("the refusal code is not one the contract names");
}
// ---- request ----
private delegate void FieldWriter(ref MessagePackWriter writer);
private static byte[] Payload(string variant, int fields, FieldWriter write)
{
var buffer = new ArrayBufferWriter<byte>();
var w = new MessagePackWriter(buffer);
w.WriteMapHeader(1);
w.Write(variant);
w.WriteMapHeader(fields);
write(ref w);
w.Flush();
return buffer.WrittenSpan.ToArray();
}
private static void Strings(ref MessagePackWriter w, IReadOnlyList<string> values)
{
w.WriteArrayHeader(values.Count);
foreach (var value in values)
{
w.Write(value);
}
}
// Operation messages write their keys sorted, absent optionals omitted.
private static byte[] RequestMessage(string messageId, string operation, byte[] payload)
{
var buffer = new ArrayBufferWriter<byte>();
var w = new MessagePackWriter(buffer);
w.WriteMapHeader(7);
w.Write("messageId");
w.Write(messageId);
w.Write("operation");
w.Write(operation);
w.Write("payload");
w.Write(Convert.ToBase64String(payload));
w.Write("payloadEncoding");
w.Write(Encoding64);
w.Write("payloadSchema");
w.Write(RequestSchema);
w.Write("schemaVersion");
w.Write("cultnet.operation_request.v0");
w.Write("serviceId");
w.Write(ServiceId);
w.Flush();
return buffer.WrittenSpan.ToArray();
}
// ---- exchange ----
private async Task<(string Variant, JsonElement Value)> ExchangeAsync(
string socketPath, string operation, byte[] payload, CancellationToken cancellationToken)
{
var messageId = messageIds();
if (messageId.Length == 0 || StrictUtf8.GetByteCount(messageId) > MaxMessageIdBytes)
{
throw new DeskDoorException("the message id is empty or over 256 bytes");
}
var message = RequestMessage(messageId, operation, payload);
if (message.Length > MaxFrameBytes)
{
throw new DeskDoorException("the request is past the frame bound");
}
var frame = new byte[4 + message.Length];
BinaryPrimitives.WriteUInt32BigEndian(frame, (uint)message.Length);
message.CopyTo(frame, 4);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(Deadline);
byte[] reply;
try
{
using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), deadline.Token);
for (var sent = 0; sent < frame.Length;)
{
sent += await socket.SendAsync(frame.AsMemory(sent), SocketFlags.None, deadline.Token);
}
var header = new byte[4];
await ReadWholeAsync(socket, header, deadline.Token);
var length = BinaryPrimitives.ReadUInt32BigEndian(header);
if (length == 0 || length > MaxFrameBytes)
{
// Refused on the prefix alone: the body is never read.
throw new DeskDoorException("the reply's length prefix is outside the frame bound");
}
reply = new byte[length];
await ReadWholeAsync(socket, reply, deadline.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new DeskDoorException("no whole reply within the deadline");
}
catch (Exception e) when (e is SocketException or IOException or ObjectDisposedException or ArgumentException)
{
throw new DeskDoorException("the desk socket failed", e);
}
return Decode(reply, messageId, operation);
}
// Loops until the buffer is whole: a stream socket may hand it over in pieces. A peer closing early is a failure.
private static async Task ReadWholeAsync(Socket socket, byte[] buffer, CancellationToken cancellationToken)
{
for (var read = 0; read < buffer.Length;)
{
var count = await socket.ReceiveAsync(buffer.AsMemory(read), SocketFlags.None, cancellationToken);
if (count == 0)
{
throw new DeskDoorException("the peer closed before a whole reply frame");
}
read += count;
}
}
// ---- reply ----
// The envelope judged by key; the payload's one variant returned with its value. Everything else throws.
private static (string Variant, JsonElement Value) Decode(byte[] frame, string messageId, string operation)
{
try
{
using var envelope = ToJson(frame);
var root = envelope.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
throw new DeskDoorException("the reply is not a map");
}
// A CultNet Error message is not an operation reply (and its keys are not in operation order).
if (RequiredString(root, "schemaVersion") != "cultnet.operation_response.v0")
{
throw new DeskDoorException("the reply is not an operation response");
}
if (RequiredString(root, "serviceId") != ServiceId
|| RequiredString(root, "operation") != operation
|| RequiredString(root, "messageId") != messageId)
{
throw new DeskDoorException("the reply is not for this request");
}
var status = RequiredString(root, "status");
if (RequiredString(root, "payloadSchema") != ResponseSchema || RequiredString(root, "payloadEncoding") != Encoding64)
{
// Including gamecult.cultnet.operation_failure.v1: the envelope or payload did not decode.
throw new DeskDoorException("the reply carries no desk response");
}
var payload = new byte[(RequiredString(root, "payload").Length * 3 / 4) + 3];
if (!Convert.TryFromBase64String(RequiredString(root, "payload"), payload, out var written))
{
throw new DeskDoorException("the payload is not base64");
}
using var body = ToJson(payload.AsMemory(0, written));
if (body.RootElement.ValueKind != JsonValueKind.Object)
{
throw new DeskDoorException("the payload is not a tagged variant");
}
var variants = body.RootElement.EnumerateObject().ToList();
if (variants.Count != 1)
{
throw new DeskDoorException("the payload is not a one-key variant");
}
var (variant, expectedStatus) = (variants[0].Name, variants[0].Name switch
{
"Answered" or "Forks" or "Fork" => "accepted",
"NotStored" or "Refused" => "rejected",
_ => null,
});
var takesVariant = operation switch
{
"answer" => variant == "Answered",
"forks" => variant == "Forks",
"fork" => variant == "Fork",
_ => false,
} || variant is "NotStored" or "Refused";
if (expectedStatus is null || !takesVariant || status != expectedStatus)
{
throw new DeskDoorException("the reply's variant or status does not fit the operation");
}
return (variant, variants[0].Value.Clone());
}
catch (Exception e) when (e is not (DeskDoorException or OperationCanceledException))
{
throw new DeskDoorException("the reply does not decode", e);
}
}
private static JsonDocument ToJson(ReadOnlyMemory<byte> messagePack)
{
var reader = new MessagePackReader(messagePack);
var json = new StringWriter(CultureInfo.InvariantCulture);
MessagePackSerializer.ConvertToJson(ref reader, json);
if (!reader.End)
{
throw new DeskDoorException("bytes follow the message");
}
return JsonDocument.Parse(json.ToString(), new JsonDocumentOptions { MaxDepth = 64 });
}
private static T? Document<T>(JsonElement map, string name)
{
try
{
return (Property(map, name) ?? throw new DeskDoorException($"{name} is missing")).Deserialize<T>(DeskJson.Options);
}
catch (JsonException e)
{
throw new DeskDoorException($"{name} does not decode", e);
}
}
// A key's value by name; a key written twice is a fault, not a choice.
private static JsonElement? Property(JsonElement map, string name)
{
JsonElement? found = null;
foreach (var property in map.EnumerateObject())
{
if (property.Name == name)
{
found = found is null ? property.Value : throw new DeskDoorException($"{name} is written twice");
}
}
return found;
}
private static string RequiredString(JsonElement map, string name) =>
Property(map, name) is { ValueKind: JsonValueKind.String } value
? value.GetString()!
: throw new DeskDoorException($"{name} is missing or not text");
private static string? OptionalString(JsonElement map, string name) =>
Property(map, name) switch
{
{ ValueKind: JsonValueKind.String } value => value.GetString(),
{ ValueKind: JsonValueKind.Null } => null,
_ => throw new DeskDoorException($"{name} is missing or not text"),
};
}

View file

@ -8,6 +8,17 @@
"resolved": "2.4.0",
"contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ=="
},
"MessagePack": {
"type": "Direct",
"requested": "[3.1.12, )",
"resolved": "3.1.12",
"contentHash": "m1QEsd59gUj82ji5tEQbZiyjV6y0F7cYfGVhil6pAlNZpyzR7ze9TucMu4lPpi8cZAV8aNl3D9/T9J1HbFz15Q==",
"dependencies": {
"MessagePack.Annotations": "3.1.12",
"MessagePackAnalyzer": "3.1.12",
"Microsoft.NET.StringTools": "17.11.4"
}
},
"Microsoft.EntityFrameworkCore.Design": {
"type": "Direct",
"requested": "[8.0.0, )",
@ -105,6 +116,16 @@
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"MessagePack.Annotations": {
"type": "Transitive",
"resolved": "3.1.12",
"contentHash": "LdI67sY7F3KK+JsrG02+DP1XFkowaZSoyfL2M88hjm1MOOsjf8amBLAJolV+zxoPUZf3uGbKzq1cMQD8PlpSpA=="
},
"MessagePackAnalyzer": {
"type": "Transitive",
"resolved": "3.1.12",
"contentHash": "iNSNatPh6bafRUbkQKlClExjpPpd8hiZgfZsodGf7bheIlkizGbMzsJ2mPui8jOHRe4Ky9eg5ou/fFzI7CT3fg=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "6.0.0",
@ -306,6 +327,11 @@
"Microsoft.IdentityModel.Logging": "8.19.2"
}
},
"Microsoft.NET.StringTools": {
"type": "Transitive",
"resolved": "17.11.4",
"contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA=="
},
"Mono.TextTemplating": {
"type": "Transitive",
"resolved": "2.2.1",

View file

@ -29,6 +29,7 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory
private readonly Dictionary<string, string?>? configuration;
private readonly bool registerPorts;
private readonly bool realCredential;
private readonly bool fakeDoor;
// A configured desk needs Bifrost:Oidc (its credential validates against the local issuer): generated keys per factory.
private readonly OidcTestKit oidcKit = new();
@ -36,9 +37,12 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory
// The chat client's secret, for a test that holds the chat client's own token.
public string OidcSecret => oidcKit.ClientSecret;
public DeskWebApplicationFactory(Dictionary<string, string?>? configuration = null, bool configured = true, bool registerPorts = true, bool realCredential = false)
// fakeDoor false leaves the door to the app's own wiring (UnixSocketDeskDoor), for tests that stand a socket up.
public DeskWebApplicationFactory(
Dictionary<string, string?>? configuration = null, bool configured = true, bool registerPorts = true, bool realCredential = false, bool fakeDoor = true)
{
this.realCredential = realCredential;
this.fakeDoor = fakeDoor;
this.configuration = configured ? configuration ?? DefaultConfiguration() : null;
this.registerPorts = registerPorts;
}
@ -141,7 +145,10 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory
builder.ConfigureServices(services =>
{
services.AddSingleton<IDeskCredential>(Credential);
services.AddSingleton<IDeskDoor>(Door);
if (fakeDoor)
{
services.AddSingleton<IDeskDoor>(Door);
}
});
}
}

View file

@ -0,0 +1,183 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Net.Sockets;
using System.Text.Json;
using MessagePack;
namespace Bifrost.Web.Tests.Support;
// Huginn's desk door as a test double on a real unix socket: it accepts connections, reads each request frame,
// records it, and lets the test decide what happens next (reply, dribble, stall, close).
public sealed class FakeHuginnSocket : IDisposable
{
private readonly Socket listener = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
private readonly CancellationTokenSource stop = new();
private readonly string directory = Directory.CreateTempSubdirectory("hd").FullName;
private readonly Func<Socket, byte[], CancellationToken, Task> handler;
public FakeHuginnSocket(Func<Socket, byte[], CancellationToken, Task> handler)
{
this.handler = handler;
Path = System.IO.Path.Combine(directory, "d.sock");
listener.Bind(new UnixDomainSocketEndPoint(Path));
listener.Listen(16);
_ = Task.Run(AcceptLoopAsync);
}
public string Path { get; }
// The whole request frame of every connection, header included.
public List<byte[]> Requests { get; } = [];
// A path no one listens on.
public static string Absent() => System.IO.Path.Combine(Directory.CreateTempSubdirectory("hd").FullName, "absent.sock");
public static byte[] Fixture(string name) =>
File.ReadAllBytes(System.IO.Path.Combine(DeskFeedFixturePinTests.Directory, name + ".frame.bin"));
// Writes the bytes and closes.
public static Func<Socket, byte[], CancellationToken, Task> Replay(byte[] frame) =>
async (socket, _, token) => await socket.SendAsync(frame, SocketFlags.None, token);
// Answers each request by its own message id and operation: the reply is built from the request.
public static Func<Socket, byte[], CancellationToken, Task> Answer(Func<string, JsonDocument, string> variantJson, string status = "accepted") =>
async (socket, request, token) =>
{
using var envelope = DeskWire.Envelope(request);
var root = envelope.RootElement;
var operation = root.GetProperty("operation").GetString()!;
var reply = DeskWire.ResponseFrame(root.GetProperty("messageId").GetString()!, operation, status, variantJson(operation, envelope));
await socket.SendAsync(reply, SocketFlags.None, token);
};
private async Task AcceptLoopAsync()
{
while (!stop.IsCancellationRequested)
{
Socket client;
try
{
client = await listener.AcceptAsync(stop.Token);
}
catch (Exception)
{
return;
}
_ = Task.Run(async () =>
{
using (client)
{
try
{
var header = new byte[4];
await client.ReceiveAllAsync(header, stop.Token);
var body = new byte[BinaryPrimitives.ReadUInt32BigEndian(header)];
await client.ReceiveAllAsync(body, stop.Token);
var request = header.Concat(body).ToArray();
lock (Requests)
{
Requests.Add(request);
}
await handler(client, request, stop.Token);
}
catch (Exception)
{
// The client went away, or the test ended.
}
}
});
}
}
public void Dispose()
{
stop.Cancel();
listener.Dispose();
try
{
Directory.Delete(directory, true);
}
catch (IOException)
{
}
}
}
internal static class SocketReceive
{
public static async Task ReceiveAllAsync(this Socket socket, byte[] buffer, CancellationToken token)
{
for (var read = 0; read < buffer.Length;)
{
var count = await socket.ReceiveAsync(buffer.AsMemory(read), SocketFlags.None, token);
if (count == 0)
{
throw new EndOfStreamException();
}
read += count;
}
}
}
// Frames and envelopes as the golden fixtures spell them, for building replies and reading requests.
public static class DeskWire
{
public static JsonDocument Envelope(byte[] frame)
{
var reader = new MessagePackReader(frame.AsMemory(4));
var json = new StringWriter();
MessagePackSerializer.ConvertToJson(ref reader, json);
return JsonDocument.Parse(json.ToString());
}
// The base64 payload of an envelope, decoded to JSON.
public static JsonDocument Payload(JsonDocument envelope)
{
var bytes = Convert.FromBase64String(envelope.RootElement.GetProperty("payload").GetString()!);
var reader = new MessagePackReader(bytes);
var json = new StringWriter();
MessagePackSerializer.ConvertToJson(ref reader, json);
return JsonDocument.Parse(json.ToString());
}
// A reply frame in operation-message key order. `mutate` edits the text fields before they are written.
public static byte[] ResponseFrame(
string messageId, string operation, string status, string variantJson, Action<SortedDictionary<string, string>>? mutate = null)
{
var fields = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["messageId"] = messageId,
["operation"] = operation,
["payload"] = Convert.ToBase64String(MessagePackSerializer.ConvertFromJson(variantJson)),
["payloadEncoding"] = "messagepack-base64",
["payloadSchema"] = "huginn.desk_response.v1",
["schemaVersion"] = "cultnet.operation_response.v0",
["serviceId"] = "huginn.desk",
["sourceRuntimeId"] = "huginn-test",
["status"] = status,
};
mutate?.Invoke(fields);
var buffer = new ArrayBufferWriter<byte>();
var w = new MessagePackWriter(buffer);
w.WriteMapHeader(fields.Count);
foreach (var (key, value) in fields)
{
w.Write(key);
w.Write(value);
}
w.Flush();
return Frame(buffer.WrittenSpan);
}
public static byte[] Frame(ReadOnlySpan<byte> message)
{
var frame = new byte[4 + message.Length];
BinaryPrimitives.WriteUInt32BigEndian(frame, (uint)message.Length);
message.CopyTo(frame.AsSpan(4));
return frame;
}
}

View file

@ -0,0 +1,401 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
// The production door against a real unix socket and Huginn's golden frames (fixtures at Huginn 9dde93e).
public sealed class UnixSocketDeskDoorTests
{
private const string Fork = "eureka-substrate:question:desk-listing-authority";
private static readonly DeskDoorContext Context = new("eureka", "Member", "7", ["7", "12"]);
private static UnixSocketDeskDoor DoorWith(string messageId) => new(() => messageId);
private static Task<DeskListOutcome> List(UnixSocketDeskDoor door, string socket, string? status = "open", string? cursor = null, int limit = 50) =>
door.ListAsync(socket, Context, new DeskListRequest("eureka", status, cursor, limit), CancellationToken.None);
private static Task<DeskReadOutcome> Read(UnixSocketDeskDoor door, string socket, string forkId = Fork) =>
door.ReadAsync(socket, Context, forkId, CancellationToken.None);
private const string UnknownFork =
"""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_unknown","error":"fork_unknown: no such fork"}}""";
// ---- (a) the request frames, byte for byte ----
[Theory]
[InlineData("list-open.request", "m-1")]
[InlineData("list-open-cursor.request", "m-2")]
[InlineData("list-counts.request", "m-3")]
[InlineData("read.request", "m-4")]
public async Task Request_frames_are_the_golden_bytes(string fixture, string messageId)
{
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => UnknownFork, "rejected"));
var door = DoorWith(messageId);
_ = fixture switch
{
"list-open.request" => await List(door, huginn.Path),
"list-open-cursor.request" => await List(door, huginn.Path, cursor: "c42"),
"list-counts.request" => await List(door, huginn.Path, limit: 0),
_ => (object)await Read(door, huginn.Path),
};
Assert.Equal(FakeHuginnSocket.Fixture(fixture), Assert.Single(huginn.Requests));
}
[Fact]
public async Task An_answer_request_is_the_Answer_variant_with_the_clients_body_as_a_map_in_its_own_order()
{
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => UnknownFork, "rejected"));
var body = DeskReplay.AnswerBody(DeskReplay.ChatFork);
var context = new DeskDoorContext("gamecult-chat", "Steward", "9f8e", ["9f8e", "aa"]);
var outcome = await new UnixSocketDeskDoor().AnswerAsync(huginn.Path, context, body, CancellationToken.None);
Assert.Equal(new DeskAnswerOutcome.NotStored("fork_unknown"), outcome);
using var envelope = DeskWire.Envelope(Assert.Single(huginn.Requests));
Assert.Equal("answer", envelope.RootElement.GetProperty("operation").GetString());
using var payload = DeskWire.Payload(envelope);
var answer = payload.RootElement.GetProperty("Answer");
Assert.Equal(["mind", "subject", "authority", "deciders", "request"], answer.EnumerateObject().Select(p => p.Name));
Assert.Equal("Steward", answer.GetProperty("authority").GetString());
// Every key, the nulls too, in the order the client wrote them.
using var sent = JsonDocument.Parse(body);
Assert.Equal(sent.RootElement.EnumerateObject().Select(p => p.Name), answer.GetProperty("request").EnumerateObject().Select(p => p.Name));
foreach (var property in sent.RootElement.EnumerateObject())
{
var back = answer.GetProperty("request").GetProperty(property.Name);
Assert.True(
System.Text.Json.Nodes.JsonNode.DeepEquals(
System.Text.Json.Nodes.JsonNode.Parse(property.Value.GetRawText()),
System.Text.Json.Nodes.JsonNode.Parse(back.GetRawText())),
property.Name);
}
}
// ---- (b) each golden reply ----
private static (string Kind, string MessageId) Shape(string fixture)
{
using var envelope = DeskWire.Envelope(FakeHuginnSocket.Fixture(fixture));
var root = envelope.RootElement;
return root.TryGetProperty("operation", out var operation)
? (operation.GetString() == "fork" ? "read" : "list", root.GetProperty("messageId").GetString()!)
: ("list", "m-1"); // a CultNet Error message has neither
}
private static async Task<object> Replayed(string fixture, string? readForkId = null)
{
var (kind, messageId) = Shape(fixture);
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Replay(FakeHuginnSocket.Fixture(fixture)));
var door = DoorWith(messageId);
return kind == "read"
? await Read(door, huginn.Path, readForkId ?? Fork)
: await List(door, huginn.Path);
}
[Fact]
public async Task An_empty_list_is_an_empty_list()
{
var listed = Assert.IsType<DeskListOutcome.Listed>(await Replayed("list-empty.response"));
Assert.Equal((0, 0, 0, "s10190", "eureka"), (listed.List.OpenCount, listed.List.RecordedCount, listed.List.Forks.Length, listed.List.Stamp, listed.List.Mind));
Assert.Null(listed.List.NextCursor);
}
[Fact]
public async Task The_counts_reply_carries_the_counts()
{
var listed = Assert.IsType<DeskListOutcome.Listed>(await Replayed("list-counts.response"));
Assert.Equal((3, 5, 0), (listed.List.OpenCount, listed.List.RecordedCount, listed.List.Forks.Length));
}
[Fact]
public async Task An_unknown_fork_is_not_found()
{
Assert.Equal(new DeskReadOutcome.NotFound("fork_unknown"), await Replayed("read-unknown.response"));
}
[Fact]
public async Task A_list_too_large_names_its_fork_and_order_from_the_refusal_text()
{
Assert.Equal(new DeskListOutcome.ForkTooLarge(Fork, 41), await Replayed("list-too-large.response"));
}
[Fact]
public async Task A_read_too_large_names_the_routes_fork_not_the_refusal_text()
{
// The refusal text names the fixture's fork; the route asked for another, and the route's id is what returns.
Assert.Equal(
new DeskReadOutcome.ForkTooLarge("route:question:asked"),
await Replayed("read-too-large.response", "route:question:asked"));
}
[Theory]
[InlineData("list-response-too-large.response")]
[InlineData("refused-unit.response")]
[InlineData("unavailable.response")]
[InlineData("failure.response")]
[InlineData("error.response")]
public async Task A_refusal_a_failure_or_an_error_is_a_door_failure(string fixture)
{
var failure = await Assert.ThrowsAsync<DeskDoorException>(() => Replayed(fixture));
Assert.DoesNotContain("deadline", failure.Message);
}
[Fact]
public async Task A_list_refusal_text_that_does_not_parse_exactly_is_a_failure_never_a_guess()
{
foreach (var text in new[]
{
"fork_too_large: fork a:question:b is past the page budget by itself",
"fork_too_large: fork a:question:b at order 4x is past the page budget by itself",
"fork_too_large: fork at order 4 is past the page budget by itself",
"fork_too_large: fork a:question:b at order 04 is past the page budget by itself",
"oops fork_too_large: fork a:question:b at order 4 is past the page budget by itself",
})
{
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) =>
$$$"""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"fork_too_large","error":"{{{text}}}"}}""", "rejected"));
await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), huginn.Path));
}
}
[Fact]
public async Task A_one_key_refusal_an_unknown_variant_and_an_unknown_code_are_failures()
{
foreach (var (variant, status) in new[]
{
("""{"Refused":{"Unavailable":{"detail":"x"}}}""", "rejected"),
("""{"Refused":"MissingIdentity"}""", "rejected"),
("""{"Mystery":{"x":1}}""", "accepted"),
("""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v0","code":"nope","error":"x"}}""", "rejected"),
("""{"NotStored":{"schemaVersion":"gamecult.fork.refusal.v9","code":"fork_unknown","error":"x"}}""", "rejected"),
("""{"Fork":{"entry":{},"as_of":1}}""", "accepted"),
("""{"Forks":{"list":{"schemaVersion":"gamecult.fork.list.v0","mind":"eureka","stamp":"s1","open_count":0,"recorded_count":0,"forks":[],"next_cursor":null},"as_of":1}}""", "rejected"),
})
{
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => variant, status));
await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), huginn.Path));
}
}
[Fact]
public async Task A_reply_for_another_message_or_operation_is_a_failure()
{
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Replay(FakeHuginnSocket.Fixture("list-empty.response")));
await Assert.ThrowsAsync<DeskDoorException>(() => List(DoorWith("m-9"), huginn.Path));
using var other = new FakeHuginnSocket(FakeHuginnSocket.Replay(FakeHuginnSocket.Fixture("list-empty.response")));
await Assert.ThrowsAsync<DeskDoorException>(() => Read(DoorWith("m-1"), other.Path));
}
// ---- (c) entries pass through, the card untouched ----
private const string CardWithRaisedAt =
"""{"schemaVersion":"gamecult.fork.card.v0","fork_id":"x:question:a","title":"It's é","raised_at":"2026-11-11T09:00:00Z","options":[{"id":"a","text":"A"}],"order":7,"options_hash":"ab"}""";
// Reordered, no raised_at, a key no contract names yet.
private const string CardReordered =
"""{"order":8,"future_key":{"z":[3,null,100],"a":true},"options_hash":"cd","schemaVersion":"gamecult.fork.card.v0","fork_id":"x:question:b","title":"B","leans":null}""";
private static string EntryJson(string card, string forkId) =>
$$$"""{"schemaVersion":"gamecult.fork.entry.v0","card":{{{card}}},"record":{"schemaVersion":"gamecult.fork.record.v0","fork_id":"{{{forkId}}}","options_hash":"ab","counted_answer_id":null,"answers":[]}}""";
[Fact]
public async Task Cards_in_a_list_and_a_read_come_through_byte_faithful()
{
var list = $$$"""{"Forks":{"list":{"schemaVersion":"gamecult.fork.list.v0","mind":"eureka","stamp":"s7","open_count":2,"recorded_count":0,"forks":[{{{EntryJson(CardWithRaisedAt, "x:question:a")}}},{{{EntryJson(CardReordered, "x:question:b")}}}],"next_cursor":"c8"},"as_of":7}}""";
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((operation, _) =>
operation == "forks" ? list : $$$"""{"Fork":{"entry":{{{EntryJson(CardReordered, "x:question:b")}}},"as_of":7}}"""));
var door = new UnixSocketDeskDoor();
var listed = Assert.IsType<DeskListOutcome.Listed>(await List(door, huginn.Path));
Assert.Equal(["x:question:a", "x:question:b"], listed.List.Forks.Select(f => f.Record.ForkId));
Assert.Equal(CardWithRaisedAt, JsonSerializer.Serialize(listed.List.Forks[0].Card, DeskJson.Options));
Assert.Equal(CardReordered, JsonSerializer.Serialize(listed.List.Forks[1].Card, DeskJson.Options));
Assert.Equal("c8", listed.List.NextCursor);
var found = Assert.IsType<DeskReadOutcome.Found>(await Read(door, huginn.Path, "x:question:b"));
Assert.Equal(CardReordered, JsonSerializer.Serialize(found.Entry.Card, DeskJson.Options));
}
[Fact]
public async Task An_answered_reply_becomes_the_answer_result_without_a_card()
{
var answered = """{"Answered":{"answer":{"schemaVersion":"gamecult.desk_answer.v0","answer_id":"n1","fork_id":"x:question:a","outcome":"Counted","of":null,"retracts":null,"code":null,"options_hash":"ab","choice":"a","note":null,"answered_at":"2026-11-11T09:00:00Z","received_at":"2026-11-11T09:00:01Z","claim_in_future":false,"answerer":"7","authority":"Member","request_id":"r"},"counted_answer_id":"n1","as_of":9}}""";
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => answered));
var outcome = await new UnixSocketDeskDoor().AnswerAsync(huginn.Path, Context, DeskReplay.AnswerBody("x:question:a"), CancellationToken.None);
var result = Assert.IsType<DeskAnswerOutcome.Answered>(outcome).Result;
Assert.Equal((DeskSchemas.AnswerResult, "n1", "Counted", "n1", null), (result.SchemaVersion, result.Answer.AnswerId, result.Answer.Outcome, result.CountedAnswerId, result.Card));
}
// ---- (d) one dead Mind fails only itself, through the HTTP edge ----
private static Dictionary<string, string?> ConfigurationWith(string chatSocket, string nibuSocket)
{
var configuration = DeskWebApplicationFactory.DefaultConfiguration();
foreach (var key in configuration.Keys.Where(k => k.EndsWith(":Id", StringComparison.Ordinal)).ToList())
{
var prefix = key[..^2];
configuration[prefix + "Socket"] = configuration[key] == "nibu" ? nibuSocket : chatSocket;
}
return configuration;
}
[Fact]
public async Task A_dead_Minds_socket_is_503_for_that_Mind_only_and_the_live_one_answers()
{
var list = $$$"""{"Forks":{"list":{"schemaVersion":"gamecult.fork.list.v0","mind":"gamecult-chat","stamp":"s7","open_count":1,"recorded_count":0,"forks":[{{{EntryJson(CardWithRaisedAt, "x:question:a")}}}],"next_cursor":null},"as_of":7}}""";
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => list));
using var factory = new DeskWebApplicationFactory(ConfigurationWith(huginn.Path, FakeHuginnSocket.Absent()), fakeDoor: false);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read");
var live = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: token);
var dead = await factory.SendAsync("GET", "/api/desk/nibu/forks", token: token);
var liveAgain = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", token: token);
Assert.Equal(200, live.Status);
Assert.Contains("\"card\":" + CardWithRaisedAt + ",", live.Text);
Assert.Equal(503, dead.Status);
Assert.Equal("30", dead.Headers.RetryAfter.ToString());
Assert.Contains("\"code\":\"unavailable\"", dead.Text);
Assert.Equal(200, liveAgain.Status);
Assert.Equal(2, huginn.Requests.Count);
}
[Fact]
public async Task A_silent_door_is_503_with_retry_after_not_a_hang()
{
using var huginn = new FakeHuginnSocket(async (_, _, token) => await Task.Delay(Timeout.Infinite, token));
using var factory = new DeskWebApplicationFactory(ConfigurationWith(FakeHuginnSocket.Absent(), huginn.Path), fakeDoor: false);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var clock = Stopwatch.StartNew();
var reply = await factory.SendAsync("GET", "/api/desk/nibu/forks", token: factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read"));
Assert.Equal(503, reply.Status);
Assert.True(reply.Headers.ContainsKey("Retry-After"));
Assert.True(clock.Elapsed < TimeSpan.FromSeconds(3), $"took {clock.Elapsed}");
}
// ---- (e) deadline, bounds, partial frames ----
[Fact]
public async Task A_server_that_accepts_and_never_replies_fails_at_the_deadline()
{
using var huginn = new FakeHuginnSocket(async (_, _, token) => await Task.Delay(Timeout.Infinite, token));
var clock = Stopwatch.StartNew();
var failure = await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), huginn.Path));
Assert.Contains("deadline", failure.Message);
Assert.InRange(clock.Elapsed, TimeSpan.FromMilliseconds(900), TimeSpan.FromSeconds(2.5));
}
[Fact]
public async Task A_caller_that_gives_up_is_cancelled_not_a_door_failure()
{
using var huginn = new FakeHuginnSocket(async (_, _, token) => await Task.Delay(Timeout.Infinite, token));
using var cancel = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
new UnixSocketDeskDoor().ListAsync(huginn.Path, Context, new DeskListRequest("eureka", null, null, 50), cancel.Token));
}
[Fact]
public async Task A_length_prefix_over_the_bound_is_refused_without_reading_the_body()
{
// The server sends only the prefix, then stalls: a client that waited for the body would run out the deadline.
using var huginn = new FakeHuginnSocket(async (socket, _, token) =>
{
await socket.SendAsync(new byte[] { 0, 1, 0, 1 }, SocketFlags.None, token); // 65537
await Task.Delay(Timeout.Infinite, token);
});
var clock = Stopwatch.StartNew();
var failure = await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), huginn.Path));
Assert.Contains("frame bound", failure.Message);
Assert.True(clock.Elapsed < TimeSpan.FromMilliseconds(700), $"took {clock.Elapsed}");
}
[Fact]
public async Task A_frame_exactly_at_the_bound_is_read_whole()
{
// A prefix of 65536 is within the bound; the body that follows is not a message, so it fails on content, not size.
using var huginn = new FakeHuginnSocket(async (socket, _, token) =>
{
var frame = new byte[4 + 65536];
frame[1] = 1;
await socket.SendAsync(frame, SocketFlags.None, token);
});
var failure = await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), huginn.Path));
Assert.DoesNotContain("frame bound", failure.Message);
}
[Fact]
public async Task A_request_over_the_bound_is_never_sent()
{
var deciders = Enumerable.Range(0, 64).Select(i => i.ToString("D4") + new string('d', 1100)).ToArray();
var context = new DeskDoorContext("eureka", "Member", "7", deciders);
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Answer((_, _) => UnknownFork, "rejected"));
var failure = await Assert.ThrowsAsync<DeskDoorException>(() =>
new UnixSocketDeskDoor().ListAsync(huginn.Path, context, new DeskListRequest("eureka", null, null, 50), CancellationToken.None));
Assert.Contains("frame bound", failure.Message);
Assert.Empty(huginn.Requests);
}
[Fact]
public async Task A_truncated_frame_or_an_early_close_is_a_failure()
{
var golden = FakeHuginnSocket.Fixture("list-empty.response");
foreach (var cut in new[] { 0, 2, 4, 10, golden.Length - 1 })
{
using var huginn = new FakeHuginnSocket(FakeHuginnSocket.Replay(golden[..cut]));
var failure = await Assert.ThrowsAsync<DeskDoorException>(() => List(DoorWith("m-1"), huginn.Path));
Assert.Contains("closed", failure.Message);
}
}
[Fact]
public async Task A_frame_that_arrives_in_pieces_is_read_whole()
{
var golden = FakeHuginnSocket.Fixture("list-empty.response");
using var huginn = new FakeHuginnSocket(async (socket, _, token) =>
{
for (var at = 0; at < golden.Length; at += 7)
{
await socket.SendAsync(golden.AsMemory(at, Math.Min(7, golden.Length - at)), SocketFlags.None, token);
await Task.Delay(2, token);
}
});
Assert.IsType<DeskListOutcome.Listed>(await List(DoorWith("m-1"), huginn.Path));
}
[Fact]
public async Task Absent_and_refusing_sockets_are_failures()
{
await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), FakeHuginnSocket.Absent()));
// A socket file with nobody listening: ECONNREFUSED.
var stale = FakeHuginnSocket.Absent();
using (var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified))
{
socket.Bind(new UnixDomainSocketEndPoint(stale));
}
await Assert.ThrowsAsync<DeskDoorException>(() => List(new UnixSocketDeskDoor(), stale));
}
}

View file

@ -60,6 +60,26 @@
"resolved": "2.4.0",
"contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ=="
},
"MessagePack": {
"type": "Transitive",
"resolved": "3.1.12",
"contentHash": "m1QEsd59gUj82ji5tEQbZiyjV6y0F7cYfGVhil6pAlNZpyzR7ze9TucMu4lPpi8cZAV8aNl3D9/T9J1HbFz15Q==",
"dependencies": {
"MessagePack.Annotations": "3.1.12",
"MessagePackAnalyzer": "3.1.12",
"Microsoft.NET.StringTools": "17.11.4"
}
},
"MessagePack.Annotations": {
"type": "Transitive",
"resolved": "3.1.12",
"contentHash": "LdI67sY7F3KK+JsrG02+DP1XFkowaZSoyfL2M88hjm1MOOsjf8amBLAJolV+zxoPUZf3uGbKzq1cMQD8PlpSpA=="
},
"MessagePackAnalyzer": {
"type": "Transitive",
"resolved": "3.1.12",
"contentHash": "iNSNatPh6bafRUbkQKlClExjpPpd8hiZgfZsodGf7bheIlkizGbMzsJ2mPui8jOHRe4Ky9eg5ou/fFzI7CT3fg=="
},
"Microsoft.AspNetCore.TestHost": {
"type": "Transitive",
"resolved": "8.0.0",
@ -461,6 +481,11 @@
"Microsoft.IdentityModel.Logging": "8.19.2"
}
},
"Microsoft.NET.StringTools": {
"type": "Transitive",
"resolved": "17.11.4",
"contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "17.14.1",
@ -671,6 +696,7 @@
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.4.0, )",
"MessagePack": "[3.1.12, )",
"Microsoft.Extensions.Caching.Memory": "[8.0.1, )",
"Npgsql": "[8.0.3, )",
"Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.0, )",