desk: Bifrost half of the Fork Desk answer binding (ports, routes, per-call deciders) #40

Merged
heimdall merged 13 commits from heimdall/desk-bifrost-half into main 2026-11-11 01:02:37 +00:00
33 changed files with 21562 additions and 6 deletions

View file

@ -85,6 +85,7 @@ Derived from them, not asked separately:
- **Network prefix.** Owner: one function, `NetworkPrefix`, mapping a client address to its configured IPv4/IPv6 prefix (IPv4-mapped IPv6 folded to IPv4). The audit `NetworkTag` (HMAC-SHA256 of the prefix under a configured secret) and the rate-limit partition both read it; no second prefix computation exists.
- **Tallies.** Derived only: per live subject, per stance, all and members, computed from rows and never stored as counters.
- **Public tally export.** The JSON xenos boundary: anonymous `GET /public/concepts/{collection}/tallies` returning `{ key: { up, down, members: { up, down } } }` for every live subject; no voter keys, account ids or tags; CORS for the configured origins only (`https://aetheria.gamecult.org`), `GET`, no credentials; `Cache-Control: public, max-age=60`. It owns nothing.
- **Ranked read.** Beside the map, anonymous `GET /public/concepts/{collection}/ranked?sort=net|total|newest&limit=1..100&cursor=&unvoted=network` returns `{ items: [{ key, title, up, down, members }], next_cursor }`, largest first, ties by key. The cursor is opaque, keyset and bound to its sort and filter; any other query shape or cursor is a 400. `unvoted=network` omits the concepts the caller's own network tag has voted on (tag derived from the connection, never accepted or returned) and is never cached. Same CORS and Vary policy as the map; the map is unchanged.
- **The Mill page.** Read only. Its existing vote slot (fact 28) fills from the export and links to `https://bifrost.gamecult.org/mill/aetheria-concept-mill/<ship id>`. Voting happens on Bifrost's own origin, which keeps the device cookie first-party.
- **Client address.** `UseForwardedHeaders` trusting loopback only makes `RemoteIpAddress` the real client. It is a rate-limit bucket and an audit tag, never a vote key.
- **Key ring.** Data Protection keys live at a configured path with a fixed application name, so tokens survive restarts and deploys; production refuses to start without the path.

View file

@ -0,0 +1,68 @@
namespace Bifrost.Web.Configuration;
// The fork desk's configuration (CultLib fork-card.md, Transport; M1 and M5). Minds is an ARRAY, never a map keyed
// by Mind id: configuration keys are case-insensitive, so a keyed section would let GAMECULT-CHAT reach
// gamecult-chat. Account ids are lowercase D-format Guids. An empty Minds list means the desk is not configured.
public sealed class DeskOptions
{
public const string SectionName = "Desk";
public List<DeskMindOptions> Minds { get; init; } = [];
public DeskAttributionOptions Attribution { get; init; } = new();
// The one origin allowed to call /api/desk from a browser (exactly this, no credentials).
public string AllowedOrigin { get; init; } = string.Empty;
// The cap on an answer body, in bytes, judged before it is parsed.
public int MaxBodyBytes { get; init; } = 16 * 1024;
// What an `unavailable` document tells the client to wait.
public int RetryAfterSeconds { get; init; } = 30;
public bool IsConfigured => Minds.Count > 0;
}
public sealed class DeskMindOptions
{
public string Id { get; init; } = string.Empty;
// 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 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
// Bifrost has no such roles: Operator is one account, Stewards a list, everyone else a Member.
public sealed class DeskAttributionOptions
{
public string? Operator { get; init; }
public List<string> Stewards { get; init; } = [];
public string AuthorityOf(string accountId)
{
if (!string.IsNullOrEmpty(Operator) && string.Equals(accountId, Operator, StringComparison.Ordinal))
{
return DeskAuthority.Operator;
}
return Stewards.Contains(accountId, StringComparer.Ordinal) ? DeskAuthority.Steward : DeskAuthority.Member;
}
}
public static class DeskAuthority
{
public const string Operator = "Operator";
public const string Steward = "Steward";
public const string Member = "Member";
}

View file

@ -23,6 +23,18 @@ public sealed record StanceCounts(int Up, int Down);
public sealed record ConceptTally(int Up, int Down, StanceCounts Members);
public enum RankOrder
{
Net,
Total,
Newest
}
// One row of the ranked read: counts and the subject's title only, never a voter key, account id or network tag.
public sealed record RankedSubject(string Key, string Title, ConceptTally Tally);
public sealed record RankedPage(IReadOnlyList<RankedSubject> Items, string? NextCursor);
public sealed record ConceptSubjectView(
string Collection,
string Key,
@ -189,6 +201,74 @@ public sealed class ConceptAppraisalService(
StringComparer.Ordinal);
}
// The ranked read: live subjects of one collection ordered by net (up minus down), total (up plus down) or
// registration time, largest first, ties by key ascending, so the order is total and a cursor names a position.
// The cursor is keyset: the next page is whatever sorts after the last row's (value, key), so a vote landing
// between pages can move rows but never makes a page repeat the cursor's own row. With unvoted and a client
// address, subjects whose stored tag equals the client's are left out (no address filters nothing) ("not yet voted by this network"); the tag is only compared
// inside the query and is never returned. Null when the collection has no live subject.
public async Task<RankedPage?> GetRankedAsync(
string collection,
RankOrder order,
int limit,
RankCursor? after,
bool unvoted,
IPAddress? client,
CancellationToken cancellationToken)
{
var live = await dbContext.ConceptSubjects.AsNoTracking()
.Where(x => x.Collection == collection && x.RetiredAtUtc == null)
.Select(x => new { x.Id, x.Key, x.Title, x.RegisteredAtUtc })
.ToListAsync(cancellationToken);
if (live.Count == 0)
{
return null;
}
var ids = live.Select(x => x.Id).ToList();
var groups = await dbContext.ConceptAppraisals.AsNoTracking()
.Where(x => ids.Contains(x.SubjectId))
.GroupBy(x => new { x.SubjectId, x.Stance, IsMember = x.VoterClass == VoterClass.Member })
.Select(g => new { g.Key.SubjectId, g.Key.Stance, g.Key.IsMember, Count = g.Count() })
.ToListAsync(cancellationToken);
var voted = new HashSet<Guid>();
if (unvoted && client is not null)
{
var opts = options.Value;
var tag = NetworkPrefix.Tag(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength, opts.NetworkTagSecret);
voted = (await dbContext.ConceptAppraisals.AsNoTracking()
.Where(x => ids.Contains(x.SubjectId) && x.NetworkTag == tag)
.Select(x => x.SubjectId)
.ToListAsync(cancellationToken)).ToHashSet();
}
var rows = live
.Where(s => !voted.Contains(s.Id))
.Select(s =>
{
var tally = Tally(groups.Where(g => g.SubjectId == s.Id).Select(g => (g.Stance, g.IsMember, g.Count)));
var value = order switch
{
RankOrder.Net => (long)tally.Up - tally.Down,
RankOrder.Total => (long)tally.Up + tally.Down,
_ => s.RegisteredAtUtc.UtcTicks
};
return (Value: value, s.Key, s.Title, Tally: tally);
})
.Where(r => after is null || r.Value < after.Value || (r.Value == after.Value && string.CompareOrdinal(r.Key, after.Key) > 0))
.OrderByDescending(r => r.Value)
.ThenBy(r => r.Key, StringComparer.Ordinal)
.Take(limit + 1)
.ToList();
var page = rows.Take(limit).ToList();
var next = rows.Count > limit
? new RankCursor(order, unvoted, page[^1].Value, page[^1].Key).Encode()
: null;
return new RankedPage(page.Select(r => new RankedSubject(r.Key, r.Title, r.Tally)).ToList(), next);
}
public async Task<ConceptSubjectView?> GetSubjectAsync(
string collection,
string key,

View file

@ -65,4 +65,89 @@ public static class ConceptTallyExport
response.Headers.CacheControl = "public, max-age=60";
return Results.Json(tallies);
}).AllowAnonymous().RequireCors(CorsPolicy);
public const int DefaultPageSize = 50;
public const int MaxPageSize = 100;
// The ranked, paged read beside the map: GET /public/concepts/{collection}/ranked
// ?sort=net|total|newest (default net) &limit=1..100 (default 50) &cursor=<opaque> &unvoted=network
// returns { items: [{ key, title, up, down, members: { up, down } }], next_cursor }. The query is a closed
// shape: an unknown or repeated key, an out-of-range value or a cursor that is not exactly one this route issued
// for the same sort and filter is a 400. unvoted=network leaves out the concepts the caller's own network tag
// has already voted on; the tag is derived from the connection and compared inside the query, never accepted
// from the request and never returned. That answer depends on the caller, so it is never cached.
public static IEndpointConventionBuilder MapConceptRankedExport(this IEndpointRouteBuilder app) =>
app.MapGet(RoutePrefix + "/{collection}/ranked", async (
string collection,
HttpContext http,
ConceptAppraisalService appraisals,
CancellationToken cancellationToken) =>
{
if (!ConceptKey.IsValid(collection))
{
return Results.NotFound();
}
if (!TryReadRankedQuery(http.Request.Query, out var order, out var limit, out var cursor, out var unvoted))
{
return JsonText(new { error = "request_invalid" }, StatusCodes.Status400BadRequest);
}
var page = await appraisals.GetRankedAsync(
collection, order, limit, cursor, unvoted, http.Connection.RemoteIpAddress, cancellationToken);
if (page is null)
{
return Results.NotFound();
}
http.Response.Headers.CacheControl = unvoted ? "private, no-store" : "public, max-age=60";
return JsonText(new
{
items = page.Items.Select(i => new
{
key = i.Key,
title = i.Title,
up = i.Tally.Up,
down = i.Tally.Down,
members = new { up = i.Tally.Members.Up, down = i.Tally.Members.Down }
}),
next_cursor = page.NextCursor
}, StatusCodes.Status200OK);
}).AllowAnonymous().RequireCors(CorsPolicy);
private static IResult JsonText(object body, int status) =>
Results.Text(System.Text.Json.JsonSerializer.Serialize(body), "application/json", statusCode: status);
internal static bool TryReadRankedQuery(
IQueryCollection query, out RankOrder order, out int limit, out RankCursor? cursor, out bool unvoted)
{
order = RankOrder.Net;
limit = DefaultPageSize;
cursor = null;
unvoted = false;
foreach (var (key, values) in query)
{
if (values.Count != 1 || values[0] is not { } value)
{
return false;
}
switch (key)
{
case "sort" when value == "net": order = RankOrder.Net; break;
case "sort" when value == "total": order = RankOrder.Total; break;
case "sort" when value == "newest": order = RankOrder.Newest; break;
case "limit" when int.TryParse(value, System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture, out var n) &&
n is >= 1 and <= MaxPageSize && value == n.ToString(System.Globalization.CultureInfo.InvariantCulture):
limit = n;
break;
case "cursor" when RankCursor.TryDecode(value, out var decoded): cursor = decoded; break;
case "unvoted" when value == "network": unvoted = true; break;
default: return false;
}
}
return cursor is null || (cursor.Order == order && cursor.Unvoted == unvoted);
}
}

View file

@ -0,0 +1,59 @@
using System.Globalization;
using System.Text;
using Microsoft.AspNetCore.WebUtilities;
namespace Bifrost.Web.Features.ConceptVotes;
// The opaque cursor of the ranked read: the last row's sort value and key, plus the order and filter it was issued
// for, so a cursor cannot be replayed under another order or filter. Text "1|order|u|value|key", base64url without
// padding; the length cap only bounds decoding work. Not signed: it names a position in public data and grants nothing. Decoding is strict and canonical (a
// cursor that does not re-encode to the same text is refused), and every field is re-validated, so no cursor text
// reaches a query unparsed.
public sealed record RankCursor(RankOrder Order, bool Unvoted, long Value, string Key)
{
public const int MaxLength = 200;
public string Encode() =>
WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(
string.Create(CultureInfo.InvariantCulture, $"1|{(int)Order}|{(Unvoted ? 1 : 0)}|{Value}|{Key}")));
public static bool TryDecode(string? text, out RankCursor cursor)
{
cursor = null!;
if (string.IsNullOrEmpty(text) || text.Length > MaxLength)
{
return false;
}
string plain;
try
{
var bytes = WebEncoders.Base64UrlDecode(text);
plain = new UTF8Encoding(false, true).GetString(bytes);
}
catch (Exception ex) when (ex is FormatException or ArgumentException)
{
return false;
}
var parts = plain.Split('|');
if (parts.Length != 5 || parts[0] != "1" ||
!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var order) ||
!Enum.IsDefined((RankOrder)order) ||
parts[2] is not ("0" or "1") ||
!long.TryParse(parts[3], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var value) ||
!ConceptKey.IsValid(parts[4]))
{
return false;
}
var candidate = new RankCursor((RankOrder)order, parts[2] == "1", value, parts[4]);
if (candidate.Encode() != text)
{
return false;
}
cursor = candidate;
return true;
}
}

View file

@ -0,0 +1,270 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Bifrost.Web.Features.Desk;
// The edge's judgment of an answer body (fork-card.md, answer_request, steps 1 and 2). It checks the shape only:
// whether the answer is legal (a retraction's target, the choice, the fork) is Huginn's. A body that passes is
// forwarded as the bytes received; the parsed request is returned for the door's context and for tests.
public sealed record DeskBodyVerdict(AnswerRequest? Request, string? RefusalCode)
{
public static DeskBodyVerdict Pass(AnswerRequest request) => new(request, null);
public static DeskBodyVerdict Refuse(string code) => new(null, code);
}
public static partial class DeskBodyShape
{
public const int MaxNoteBytes = 4000;
// Two keys the receiver alone stamps. Refused whole at any depth, never stripped.
private static readonly string[] ForbiddenKeys = ["answerer", "authority"];
private static readonly string[] TopKeys =
["schemaVersion", "fork_id", "options_hash", "answer", "retracted", "retracts", "answered_at", "request_id"];
private static readonly string[] AnswerKeys = ["choice", "note"];
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private static readonly JsonDocumentOptions ParseOptions = new() { MaxDepth = 32 };
public static DeskBodyVerdict Judge(ReadOnlySpan<byte> body, int maxBodyBytes)
{
if (body.Length > maxBodyBytes)
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
}
JsonDocument document;
try
{
document = JsonDocument.Parse(body.ToArray(), ParseOptions);
}
catch (JsonException)
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
}
using (document)
{
try
{
return JudgeParsed(document.RootElement);
}
catch (InvalidOperationException)
{
// A string the parser accepted but cannot decode: invalid UTF-8 or a lone surrogate.
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
}
}
}
private static DeskBodyVerdict JudgeParsed(JsonElement root)
{
if (HasForbiddenKey(root))
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyForbiddenField);
}
if (root.ValueKind != JsonValueKind.Object)
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
}
var top = Members(root);
if (top is null)
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
}
// Unknown keys, including inside the answer object, before any value is judged.
if (top.Keys.Any(key => !TopKeys.Contains(key, StringComparer.Ordinal)))
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyUnknownField);
}
Dictionary<string, JsonElement>? answerMembers = null;
if (top.TryGetValue("answer", out var answerElement) && answerElement.ValueKind == JsonValueKind.Object)
{
answerMembers = Members(answerElement);
if (answerMembers is not null &&
answerMembers.Keys.Any(key => !AnswerKeys.Contains(key, StringComparer.Ordinal)))
{
return DeskBodyVerdict.Refuse(DeskRefusals.BodyUnknownField);
}
}
return Shape(top, answerMembers);
}
private static DeskBodyVerdict Shape(Dictionary<string, JsonElement> top, Dictionary<string, JsonElement>? answerMembers)
{
var invalid = DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
// Map-mode: every key is written.
if (TopKeys.Any(key => !top.ContainsKey(key)))
{
return invalid;
}
if (!IsString(top["schemaVersion"], out var schemaVersion) || schemaVersion != DeskSchemas.AnswerRequest ||
!IsString(top["fork_id"], out var forkId) || forkId.Length == 0 ||
!IsString(top["options_hash"], out var optionsHash) || !Hex64().IsMatch(optionsHash) ||
!IsString(top["answered_at"], out var answeredAt) || !Instant().IsMatch(answeredAt) ||
!IsString(top["request_id"], out var requestId) || !RequestId().IsMatch(requestId))
{
return invalid;
}
var retractedElement = top["retracted"];
if (retractedElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
return invalid;
}
var retracted = retractedElement.ValueKind == JsonValueKind.True;
string? retracts = null;
if (top["retracts"].ValueKind != JsonValueKind.Null)
{
if (!IsString(top["retracts"], out var target) || !AnswerId().IsMatch(target))
{
return invalid;
}
retracts = target;
}
AnswerObject? answer = null;
if (top["answer"].ValueKind != JsonValueKind.Null)
{
if (answerMembers is null || AnswerKeys.Any(key => !answerMembers.ContainsKey(key)))
{
return invalid;
}
string? choice = null;
if (answerMembers["choice"].ValueKind != JsonValueKind.Null)
{
if (!IsString(answerMembers["choice"], out var picked) || picked.Length == 0)
{
return invalid;
}
choice = picked;
}
string? note = null;
if (answerMembers["note"].ValueKind != JsonValueKind.Null)
{
if (!IsString(answerMembers["note"], out var words) || !IsWords(words))
{
return invalid;
}
note = words;
}
if (choice is null && note is null)
{
return invalid;
}
answer = new AnswerObject(choice, note);
}
// No answer and no retraction is no request. The illegal retraction forms (a target-less retraction, a
// retraction carrying an answer, a target on a plain answer) pass here: Huginn stores them as bad_retraction.
if (answer is null && !retracted)
{
return invalid;
}
return DeskBodyVerdict.Pass(new AnswerRequest(
schemaVersion, forkId, optionsHash, answer, retracted, retracts, answeredAt, requestId));
}
// A note is 1 to 4000 UTF-8 bytes and not whitespace only. Bytes, not characters or UTF-16 units.
private static bool IsWords(string value)
{
int bytes;
try
{
bytes = StrictUtf8.GetByteCount(value);
}
catch (EncoderFallbackException)
{
return false;
}
return bytes is >= 1 and <= MaxNoteBytes && !value.All(IsSchemaSpace);
}
// The whitespace set of the schema's `words` pattern.
private static bool IsSchemaSpace(char c) =>
c is (>= (char)0x09 and <= (char)0x0d) or (char)0x20 or (char)0x85 or (char)0xa0 or (char)0x1680
or (>= (char)0x2000 and <= (char)0x200a) or (char)0x2028 or (char)0x2029 or (char)0x202f or (char)0x205f or (char)0x3000;
private static bool IsString(JsonElement element, out string value)
{
value = string.Empty;
if (element.ValueKind != JsonValueKind.String)
{
return false;
}
value = element.GetString() ?? string.Empty;
return true;
}
// Object members by unescaped name; null when a name repeats, because the forwarded bytes would then mean
// different things to different parsers.
private static Dictionary<string, JsonElement>? Members(JsonElement element)
{
var members = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
foreach (var property in element.EnumerateObject())
{
if (!members.TryAdd(property.Name, property.Value))
{
return null;
}
}
return members;
}
private static bool HasForbiddenKey(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
foreach (var property in element.EnumerateObject())
{
if (ForbiddenKeys.Contains(property.Name, StringComparer.Ordinal) || HasForbiddenKey(property.Value))
{
return true;
}
}
return false;
case JsonValueKind.Array:
return element.EnumerateArray().Any(HasForbiddenKey);
default:
return false;
}
}
[GeneratedRegex(@"^[0-9a-f]{64}\z")]
private static partial Regex Hex64();
[GeneratedRegex(@"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,7})?Z\z")]
private static partial Regex Instant();
[GeneratedRegex(@"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z")]
private static partial Regex RequestId();
[GeneratedRegex(@"^n[1-9][0-9]*\z")]
private static partial Regex AnswerId();
}

View file

@ -0,0 +1,194 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Bifrost.Web.Features.Desk;
// The fork-desk documents Bifrost reads or writes, spelled as CultLib's contracts/cultnet/governance/fork-card.md
// (commit 89755b08) spells them: snake_case keys, every key written (nil where absent), declared in the
// contract's key order. Bifrost authors only DeskRefusal and DeskUnavailable. AnswerRequest is the client's body;
// ForkCard, DeskAnswerDocument and AnswerResult are Huginn's documents, typed here so they pass through unchanged.
public static class DeskSchemas
{
public const string AnswerRequest = "gamecult.fork.answer_request.v0";
public const string AnswerResult = "gamecult.fork.answer_result.v0";
public const string Card = "gamecult.fork.card.v0";
public const string DeskAnswer = "gamecult.desk_answer.v0";
public const string Refusal = "gamecult.fork.refusal.v0";
public const string Unavailable = "gamecult.fork.unavailable.v0";
}
public static class DeskJson
{
// Defaults write nulls, which map-mode needs ("every key written").
public static readonly JsonSerializerOptions Options = new()
{
// The documents are not HTML: keep apostrophes and non-ASCII text as written.
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
}
public sealed record AnswerObject(
[property: JsonPropertyName("choice")] string? Choice,
[property: JsonPropertyName("note")] string? Note);
public sealed record AnswerRequest(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("options_hash")] string OptionsHash,
[property: JsonPropertyName("answer")] AnswerObject? Answer,
[property: JsonPropertyName("retracted")] bool Retracted,
[property: JsonPropertyName("retracts")] string? Retracts,
[property: JsonPropertyName("answered_at")] string AnsweredAt,
[property: JsonPropertyName("request_id")] string RequestId);
public sealed record DeskRefusal(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("error")] string Error);
public sealed record DeskUnavailable(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("error")] string Error,
[property: JsonPropertyName("retryAfterSeconds")] int? RetryAfterSeconds);
public sealed record ForkOption(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("text")] string Text);
public sealed record ForkRef(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("id")] string Id);
public sealed record ForkCard(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("context")] string Context,
[property: JsonPropertyName("options")] ForkOption[] Options,
[property: JsonPropertyName("recommendation")] string Recommendation,
[property: JsonPropertyName("depends")] string[] Depends,
[property: JsonPropertyName("leans")] string? Leans,
[property: JsonPropertyName("flag")] string? Flag,
[property: JsonPropertyName("raised_in")] ForkRef? RaisedIn,
[property: JsonPropertyName("deciders")] string[] Deciders,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("ruling")] string? Ruling,
[property: JsonPropertyName("ruled_from")] string[] RuledFrom,
[property: JsonPropertyName("order")] long Order,
[property: JsonPropertyName("options_hash")] string OptionsHash);
// One stored answer: Counted, Revision, Retraction or Refused, told apart by Outcome and Code.
public sealed record DeskAnswerDocument(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("answer_id")] string AnswerId,
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("outcome")] string Outcome,
[property: JsonPropertyName("of")] string? Of,
[property: JsonPropertyName("retracts")] string? Retracts,
[property: JsonPropertyName("code")] string? Code,
[property: JsonPropertyName("options_hash")] string OptionsHash,
[property: JsonPropertyName("choice")] string? Choice,
[property: JsonPropertyName("note")] string? Note,
[property: JsonPropertyName("answered_at")] string AnsweredAt,
[property: JsonPropertyName("received_at")] string ReceivedAt,
[property: JsonPropertyName("claim_in_future")] bool ClaimInFuture,
[property: JsonPropertyName("answerer")] string Answerer,
[property: JsonPropertyName("authority")] string Authority,
[property: JsonPropertyName("request_id")] string RequestId);
public sealed record ForkRecord(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("fork_id")] string ForkId,
[property: JsonPropertyName("options_hash")] string OptionsHash,
[property: JsonPropertyName("counted_answer_id")] string? CountedAnswerId,
[property: JsonPropertyName("answers")] DeskAnswerDocument[] Answers);
public sealed record ForkEntry(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("card")] ForkCard Card,
[property: JsonPropertyName("record")] ForkRecord Record);
public sealed record ForkList(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("mind")] string Mind,
[property: JsonPropertyName("stamp")] string Stamp,
[property: JsonPropertyName("open_count")] int OpenCount,
[property: JsonPropertyName("recorded_count")] int RecordedCount,
[property: JsonPropertyName("forks")] ForkEntry[] Forks,
[property: JsonPropertyName("next_cursor")] string? NextCursor);
public sealed record AnswerResult(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("answer")] DeskAnswerDocument Answer,
[property: JsonPropertyName("card")] ForkCard? Card,
[property: JsonPropertyName("counted_answer_id")] string? CountedAnswerId);
// The refusal codes Bifrost itself writes, each with its one status and message (vectors doorRefusals).
public static class DeskRefusals
{
public const string Unauthenticated = "unauthenticated";
public const string BodyForbiddenField = "body_forbidden_field";
public const string BodyUnknownField = "body_unknown_field";
public const string BodyInvalid = "body_invalid";
public const string ForkUnknown = "fork_unknown";
public const string InsufficientScope = "insufficient_scope";
public const string Forbidden = "forbidden";
public const string RequestInvalid = "request_invalid";
public const string RequestIdConflict = "request_id_conflict";
private static readonly Dictionary<string, (int Status, string Error)> Table = new(StringComparer.Ordinal)
{
[Unauthenticated] = (401, "unauthenticated: a verified session is required"),
[BodyForbiddenField] = (400, "body_forbidden_field: answerer and authority are set by the receiver"),
[BodyUnknownField] = (400, "body_unknown_field: the body is a closed shape"),
[BodyInvalid] = (400, "body_invalid: the body does not satisfy gamecult.fork.answer_request.v0"),
[ForkUnknown] = (404, "fork_unknown: no such fork"),
[InsufficientScope] = (403, "insufficient_scope: the token lacks the scope this route needs"),
[Forbidden] = (403, "forbidden: only Active members may use the desk"),
[RequestInvalid] = (400, "request_invalid: the request does not satisfy its document"),
[RequestIdConflict] = (409, "request_id_conflict: this request_id was used for a different body"),
};
// fork_too_large (Libby's ruling) is list-only and names its fork, so it is built here, not read from the table.
// Not a literal const: the pinned vectors' doorRefusals do not name it yet, and the schema test pins literals to them.
public static string ForkTooLarge => "fork_too_large";
public static int ForkTooLargeStatus => 422;
public static DeskRefusal ForkTooLargeDocument(string forkId, int order) => new(
DeskSchemas.Refusal,
ForkTooLarge,
order > 0
? $"fork_too_large: fork {forkId} (order {order}) is too large for a page; continue with cursor c{order}"
: $"fork_too_large: fork {forkId} (order {order}) is too large for a page; the list has reached its end");
public static bool IsKnown(string code) => Table.ContainsKey(code);
public static int StatusOf(string code) => Table[code].Status;
public static DeskRefusal Document(string code) => new(DeskSchemas.Refusal, code, Table[code].Error);
}
public static class DeskUnavailables
{
public const int Status = 503;
public static DeskUnavailable Document(int? retryAfterSeconds) => new(
DeskSchemas.Unavailable,
"unavailable",
"unavailable: the outcome is not confirmed, try again with the same request_id",
retryAfterSeconds);
}
// Notes are returned only to deciders. For everyone else Bifrost nils every note at the door: the key stays written
// (every key written, nil where absent), so the stripped body is still the contract's shape. Cards, outcomes, codes
// and counts are untouched; Huginn returns the full record.
public static class DeskNotes
{
public static ForkEntry Strip(ForkEntry entry) =>
entry with { Record = entry.Record with { Answers = [.. entry.Record.Answers.Select(a => a with { Note = null })] } };
public static ForkList Strip(ForkList list) => list with { Forks = [.. list.Forks.Select(Strip)] };
}

View file

@ -0,0 +1,445 @@
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Features.Oidc;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
namespace Bifrost.Web.Features.Desk;
// The fork desk's HTTP edge (fork-card.md, Transport). One catch-all under /api/desk judges, in this order and no
// other: the raw target, the bearer, its audience, its scope, Active membership (the body is unread), the body's
// shape, the {mind} lookup, the door. Every refusal is a contract document. Bifrost holds no fork state: it passes
// Huginn's outcome through, attaches the current card to a stale_fork answer, and says `unavailable` when the door
// cannot confirm an outcome.
public static class DeskEndpoints
{
public const string RoutePrefix = "/api/desk";
public const string Audience = "bifrost-desk";
public const string ReadScope = "desk.read";
public const string AnswerScope = "desk.answer";
public const string CorsPolicy = "DeskCors";
public static IServiceCollection AddBifrostDesk(this IServiceCollection services)
{
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.
services
.AddOptions<CorsOptions>()
.Configure<IOptions<DeskOptions>>((cors, desk) =>
cors.AddPolicy(
CorsPolicy,
policy => policy
.WithOrigins(desk.Value.AllowedOrigin)
.WithMethods("GET", "POST")
.WithHeaders(HeaderNames.Authorization, HeaderNames.ContentType)));
return services;
}
public static IEndpointConventionBuilder MapDesk(this IEndpointRouteBuilder app)
{
// A configured desk with no credential or door port would serve nothing true: stop startup instead.
app.ServiceProvider.GetRequiredService<IDeskCredential>();
app.ServiceProvider.GetRequiredService<IDeskDoor>();
return app.MapMethods(RoutePrefix + "/{**rest}", ["GET", "POST"], HandleAsync)
.RequireCors(CorsPolicy)
.AllowAnonymous();
}
private static async Task HandleAsync(
HttpContext http,
IDeskCredential credential,
IDeskDoor door,
DeskMindRoutes minds,
IOptions<DeskOptions> options,
BifrostDbContext db)
{
var cancellationToken = http.RequestAborted;
var desk = options.Value;
http.Response.Headers.Append(HeaderNames.Vary, "Origin");
http.Response.Headers.CacheControl = "no-store";
// 1. The raw target, never the decoded Request.Path.
var rawTarget = http.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpRequestFeature>()?.RawTarget ?? string.Empty;
var route = DeskRoute.Parse(http.Request.Method, rawTarget);
if (route is null)
{
await WriteRefusalAsync(http, DeskRefusals.ForkUnknown);
return;
}
// 2-4. Bearer, audience, scope.
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);
return;
}
var scope = route.Kind == DeskRouteKind.Answer ? AnswerScope : ReadScope;
if (!token.Scopes.Contains(scope, StringComparer.Ordinal))
{
await WriteRefusalAsync(http, DeskRefusals.InsufficientScope);
return;
}
// 5. Active membership, re-read now; the body has not been touched.
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);
return;
}
// 6. The request's shape. An answer's bytes are the bytes forwarded; list and read judge their query.
byte[] body = [];
DeskListRequest? listRequest = null;
var forkId = string.Empty;
if (route.Kind == DeskRouteKind.Answer)
{
var read = await ReadBodyAsync(http.Request, desk.MaxBodyBytes, cancellationToken);
var verdict = read is null
? DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid)
: DeskBodyShape.Judge(read, desk.MaxBodyBytes);
if (verdict.RefusalCode is not null)
{
await WriteRefusalAsync(http, verdict.RefusalCode);
return;
}
body = read!;
}
else if (route.Kind == DeskRouteKind.Read
? !DeskQuery.TryRead(route.RawQuery, route.RawFork!, out forkId)
: !DeskQuery.TryList(route.RawQuery, string.Empty, out listRequest))
{
await WriteRefusalAsync(http, DeskRefusals.RequestInvalid);
return;
}
// 7. The {mind} lookup: a miss is the same document as an unknown fork.
var mind = minds.Resolve(route.RawMind);
if (mind is null)
{
await WriteRefusalAsync(http, DeskRefusals.ForkUnknown);
return;
}
// 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(mind.Id, desk.Attribution.AuthorityOf(subject), subject, mind.Deciders.ToArray());
try
{
switch (route.Kind)
{
case DeskRouteKind.List:
var listed = await door.ListAsync(mind.Socket, context, listRequest! with { Mind = mind.Id }, cancellationToken);
if (listed is DeskListOutcome.Listed(var list))
{
await WriteJsonAsync(http, 200, IsDecider(context) ? list : DeskNotes.Strip(list));
}
else if (listed is DeskListOutcome.ForkTooLarge(var largeId, var largeOrder))
{
await WriteJsonAsync(http, DeskRefusals.ForkTooLargeStatus, DeskRefusals.ForkTooLargeDocument(largeId, largeOrder));
}
else if (listed is DeskListOutcome.NotListed(var listCode) && DoorMayRefuse(listCode) && listCode != DeskRefusals.RequestIdConflict)
{
await WriteRefusalAsync(http, ReadListCode(listCode));
}
else
{
await WriteUnavailableAsync(http, desk);
}
return;
case DeskRouteKind.Read:
var found = await door.ReadAsync(mind.Socket, context, forkId, cancellationToken);
if (found is DeskReadOutcome.Found(var entry))
{
await WriteJsonAsync(http, 200, IsDecider(context) ? entry : DeskNotes.Strip(entry));
}
else if (found is DeskReadOutcome.NotFound(var readCode) && DoorMayRefuse(readCode) && readCode != DeskRefusals.RequestIdConflict)
{
await WriteRefusalAsync(http, ReadListCode(readCode));
}
else
{
await WriteUnavailableAsync(http, desk);
}
return;
default:
await AnswerAsync(http, door, mind, context, body, desk);
return;
}
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
await WriteUnavailableAsync(http, desk);
}
}
private static async Task AnswerAsync(
HttpContext http, IDeskDoor door, DeskMindOptions mind, DeskDoorContext context, byte[] body, DeskOptions desk)
{
var cancellationToken = http.RequestAborted;
var outcome = await door.AnswerAsync(mind.Socket, context, body, cancellationToken);
switch (outcome)
{
case DeskAnswerOutcome.NotStored(var code) when DoorMayRefuse(code):
await WriteRefusalAsync(http, code);
return;
case DeskAnswerOutcome.Answered(var result) when DeskStatus.Of(result) is { } status:
// A stale_fork answer, fresh or replayed, carries the CURRENT card: nothing is stored for it.
if (string.Equals(result.Answer.Code, DeskStatus.StaleFork, StringComparison.Ordinal))
{
var current = await door.ReadAsync(mind.Socket, context, result.Answer.ForkId, cancellationToken);
if (current is not DeskReadOutcome.Found(var entry))
{
await WriteUnavailableAsync(http, desk);
return;
}
result = result with { Card = entry.Card };
}
await WriteJsonAsync(http, status, result);
return;
default:
await WriteUnavailableAsync(http, desk);
return;
}
}
// A note is a decider's own words: only a caller on the admin-set list reads them (visibility ruling c).
private static bool IsDecider(DeskDoorContext context) =>
context.Deciders.Contains(context.Subject, StringComparer.Ordinal);
// Codes the door may hand back for nothing stored. The three the edge alone judges are never the door's.
private static bool DoorMayRefuse(string code) =>
code is not (DeskRefusals.Unauthenticated or DeskRefusals.InsufficientScope or DeskRefusals.Forbidden) &&
DeskRefusals.IsKnown(code);
// request_id_conflict is the answer's alone (Libby): a read or list carries no request_id, so the door saying it
// there is a door fault and the edge says `unavailable`.
// A read or list sends no body. A door that refuses one for its body (an `authority` field among them) is
// refusing the request, which is request_invalid; it is not an outage.
private static string ReadListCode(string code) =>
code is DeskRefusals.BodyForbiddenField or DeskRefusals.BodyUnknownField or DeskRefusals.BodyInvalid
? DeskRefusals.RequestInvalid
: code;
// At most `cap` bytes. Null when the body is longer, judged by Content-Length first and then while reading.
private static async Task<byte[]?> ReadBodyAsync(HttpRequest request, int cap, CancellationToken cancellationToken)
{
if (request.ContentLength > cap)
{
return null;
}
using var buffer = new MemoryStream();
var chunk = new byte[4096];
int count;
while ((count = await request.Body.ReadAsync(chunk, cancellationToken)) > 0)
{
if (buffer.Length + count > cap)
{
return null;
}
buffer.Write(chunk, 0, count);
}
return buffer.ToArray();
}
private static Task WriteRefusalAsync(HttpContext http, string code) =>
WriteJsonAsync(http, DeskRefusals.StatusOf(code), DeskRefusals.Document(code));
private static async Task WriteUnavailableAsync(HttpContext http, DeskOptions desk)
{
http.Response.Headers.RetryAfter = desk.RetryAfterSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
await WriteJsonAsync(http, DeskUnavailables.Status, DeskUnavailables.Document(desk.RetryAfterSeconds));
}
private static async Task WriteJsonAsync<T>(HttpContext http, int status, T document)
{
http.Response.StatusCode = status;
http.Response.ContentType = "application/json; charset=utf-8";
await JsonSerializer.SerializeAsync(http.Response.Body, document, DeskJson.Options, http.RequestAborted);
}
}
// The one place a stored outcome becomes an HTTP status. fork-card.md prose says 200 for any stored outcome; the
// vectors (results[].httpStatus) say a Refused answer carries its refusal's status, and the vectors are the check.
public static class DeskStatus
{
public const string StaleFork = "stale_fork";
private static readonly Dictionary<string, int> RefusedStatus = new(StringComparer.Ordinal)
{
[StaleFork] = 409,
["not_a_decider"] = 403,
["fork_closed"] = 409,
["choice_invalid"] = 400,
["bad_retraction"] = 400,
["already_retracted"] = 409,
};
// Null when the door sent an outcome or a refusal code this contract does not name: Bifrost then cannot vouch
// for it and says `unavailable`.
public static int? Of(AnswerResult result) => result.Answer.Outcome switch
{
"Counted" or "Revision" or "Retraction" => 200,
"Refused" when result.Answer.Code is { } code && RefusedStatus.TryGetValue(code, out var status) => status,
_ => null,
};
}
public enum DeskRouteKind
{
List,
Read,
Answer,
}
public sealed record DeskRoute(DeskRouteKind Kind, string RawMind, string? RawFork, string RawQuery)
{
// The raw request target against the three routes. Segments stay percent-encoded: {mind} is decoded once, by
// the resolver, and nothing here normalises a dot segment. Any other shape is not a desk route.
public static DeskRoute? Parse(string method, string rawTarget)
{
var path = rawTarget;
var query = string.Empty;
var split = path.IndexOf('?');
if (split >= 0)
{
query = path[(split + 1)..];
path = path[..split];
}
if (!path.StartsWith(DeskEndpoints.RoutePrefix + "/", StringComparison.Ordinal))
{
return null;
}
var segments = path[(DeskEndpoints.RoutePrefix.Length + 1)..].Split('/');
return (method, segments) switch
{
("GET", [var mind, "forks"]) => new DeskRoute(DeskRouteKind.List, mind, null, query),
("GET", [var mind, "forks", var fork]) => new DeskRoute(DeskRouteKind.Read, mind, fork, query),
("POST", [var mind, "answers"]) => new DeskRoute(DeskRouteKind.Answer, mind, null, query),
_ => null,
};
}
}
// The list and read requests the query string and path stand for (fork-card.md, List and Read). Nothing in a
// query sets a decider, an answerer or a Mind; an unknown key, a repeated key or a bad value is request_invalid.
public static class DeskQuery
{
public const int MaxLimit = 50;
public static bool TryList(string rawQuery, string mind, out DeskListRequest request)
{
request = new DeskListRequest(mind, null, null, MaxLimit);
if (!TryPairs(rawQuery, out var pairs))
{
return false;
}
string? status = null, cursor = null;
var limit = MaxLimit;
foreach (var (key, value) in pairs)
{
switch (key)
{
case "status" when value is "open" or "recorded":
status = value;
break;
case "status" when value.Length == 0:
break;
case "cursor" when IsCursor(value):
cursor = value;
break;
case "cursor" when value.Length == 0:
break;
case "limit" when int.TryParse(value, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var n) && n <= MaxLimit:
limit = n;
break;
default:
return false;
}
}
request = new DeskListRequest(mind, status, cursor, limit);
return true;
}
// The read request has no query at all.
public static bool TryRead(string rawQuery, string rawFork, out string forkId)
{
forkId = string.Empty;
return rawQuery.Length == 0 && DeskMindRoutes.TryDecode(rawFork, out forkId) && forkId.Length > 0;
}
// c<N>, N a positive integer without a leading zero.
private static bool IsCursor(string value) =>
value.Length > 1 && value[0] == 'c' && value[1] is >= '1' and <= '9' && value.Skip(2).All(char.IsAsciiDigit);
private static bool TryPairs(string rawQuery, out List<(string Key, string Value)> pairs)
{
pairs = [];
var seen = new HashSet<string>(StringComparer.Ordinal);
if (rawQuery.Length == 0)
{
return true;
}
foreach (var part in rawQuery.Split('&'))
{
var eq = part.IndexOf('=');
var rawKey = eq < 0 ? part : part[..eq];
var rawValue = eq < 0 ? string.Empty : part[(eq + 1)..];
if (!DeskMindRoutes.TryDecode(rawKey, out var key) || !DeskMindRoutes.TryDecode(rawValue, out var value) || !seen.Add(key))
{
return false;
}
pairs.Add((key, value));
}
return true;
}
}

View file

@ -0,0 +1,84 @@
using System.Text;
using Bifrost.Web.Configuration;
namespace Bifrost.Web.Features.Desk;
// The {mind} segment of a desk route (fork-card.md M1). The raw segment is percent-decoded once and compared,
// ordinally, with the configured ids: no case folding, trimming or normalisation, and a decoding failure is a miss.
// The segment is never joined into a path, a socket name or a query, so a value off the list opens nothing.
public sealed class DeskMindRoutes
{
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private readonly Dictionary<string, DeskMindOptions> byId;
public DeskMindRoutes(IEnumerable<DeskMindOptions> minds)
{
byId = new Dictionary<string, DeskMindOptions>(StringComparer.Ordinal);
foreach (var mind in minds)
{
byId.Add(mind.Id, mind);
}
}
public DeskMindOptions? Resolve(string rawSegment) =>
TryDecode(rawSegment, out var id) && byId.TryGetValue(id, out var mind) ? mind : null;
// One pass of percent-decoding. Uri.UnescapeDataString is not used: it leaves a bad escape in place and turns
// bytes that are not UTF-8 into U+FFFD instead of failing.
internal static bool TryDecode(string raw, out string decoded)
{
decoded = string.Empty;
var bytes = new List<byte>(raw.Length);
for (var i = 0; i < raw.Length; i++)
{
var c = raw[i];
if (c == '%')
{
if (i + 2 >= raw.Length)
{
return false;
}
if (!TryHex(raw[i + 1], out var high) || !TryHex(raw[i + 2], out var low))
{
return false;
}
bytes.Add((byte)((high << 4) | low));
i += 2;
}
else if (c > 0x7f)
{
// A raw request target is ASCII; anything else is not a segment this desk issues.
return false;
}
else
{
bytes.Add((byte)c);
}
}
try
{
decoded = StrictUtf8.GetString(bytes.ToArray());
return true;
}
catch (DecoderFallbackException)
{
return false;
}
}
private static bool TryHex(char c, out int value)
{
value = c switch
{
>= '0' and <= '9' => c - '0',
>= 'a' and <= 'f' => c - 'a' + 10,
>= 'A' and <= 'F' => c - 'A' + 10,
_ => -1,
};
return value >= 0;
}
}

View file

@ -0,0 +1,67 @@
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.
// 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.
public sealed record DeskToken(string? Subject, string? Audience, IReadOnlyCollection<string> Scopes);
public interface IDeskCredential
{
// Null when the request carries no valid bearer token.
ValueTask<DeskToken?> AuthenticateAsync(HttpContext context, CancellationToken cancellationToken);
}
// 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
{
// Huginn stored an outcome (Counted, Revision, Retraction or Refused) and returned its document.
public sealed record Answered(AnswerResult Result) : DeskAnswerOutcome;
// Nothing stored: Huginn refused with one of the contract's refusal codes.
public sealed record NotStored(string Code) : DeskAnswerOutcome;
}
public abstract record DeskReadOutcome
{
public sealed record Found(ForkEntry Entry) : DeskReadOutcome;
public sealed record NotFound(string Code) : DeskReadOutcome;
}
// The list request the desk's query string stands for. Mind is the route's, resolved against the configured list.
public sealed record DeskListRequest(string Mind, string? Status, string? Cursor, int Limit);
public abstract record DeskListOutcome
{
public sealed record Listed(ForkList List) : DeskListOutcome;
public sealed record NotListed(string Code) : DeskListOutcome;
// The next fork is too large for any page: the page holds nothing past the cursor. The client continues with
// cursor c<Order>; at Order 0 it has reached the end. List only: no other call can be refused this way.
public sealed record ForkTooLarge(string ForkId, int Order) : DeskListOutcome;
}
public interface IDeskDoor
{
// The body is exactly the bytes the client sent. Any exception means the outcome is not confirmed.
Task<DeskAnswerOutcome> AnswerAsync(
string socket, DeskDoorContext context, ReadOnlyMemory<byte> body, CancellationToken cancellationToken);
// The entry for one fork, its card built and hashed by Huginn.
Task<DeskReadOutcome> ReadAsync(
string socket, DeskDoorContext context, string forkId, CancellationToken cancellationToken);
// A page of entries, the counts and the stamp, as Huginn projects them for this viewer.
Task<DeskListOutcome> ListAsync(
string socket, DeskDoorContext context, DeskListRequest request, CancellationToken cancellationToken);
}

View file

@ -117,18 +117,22 @@ public static class OidcEndpoints
return Results.Json(claims);
}
/// <summary>The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints and the logout reconciler.</summary>
internal static async Task<bool> IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
/// <summary>The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints, the logout reconciler and the fork desk.</summary>
internal static async Task<bool> IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) =>
await ActiveMemberIdAsync(db, subject, cancellationToken) is not null;
/// <summary>The account id of the subject when it is an Active member now, else null. Re-read on every call.</summary>
internal static async Task<Guid?> ActiveMemberIdAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
{
if (!Guid.TryParse(subject, out var accountId))
{
return false;
return null;
}
var account = await db.UserAccounts.AsNoTracking()
.Include(x => x.Membership)
.SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken);
return new CurrentBifrostActor(true, account).IsActiveMember;
return new CurrentBifrostActor(true, account).IsActiveMember ? accountId : null;
}
private static IResult Deny(string error, string description) =>

View file

@ -12,7 +12,8 @@ public sealed class StartupConfigurationValidator(
IOptions<HeimdallOptions> heimdallOptions,
IOptions<StripeOptions> stripeOptions,
IOptions<BifrostHostOptions> hostOptions,
IOptions<ConceptVoteOptions> conceptVoteOptions)
IOptions<ConceptVoteOptions> conceptVoteOptions,
IOptions<DeskOptions> deskOptions)
{
public void Validate()
{
@ -45,6 +46,8 @@ public sealed class StartupConfigurationValidator(
failures.Add("ConceptVotes:MaxTrackedKeys must be at least 1.");
}
ValidateDesk(deskOptions.Value, failures);
if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing"))
{
Refuse(failures);
@ -116,6 +119,106 @@ public sealed class StartupConfigurationValidator(
Refuse(failures);
}
// Checked in every environment: a bad Mind list or decider id would otherwise pass startup and misroute or
// mis-attribute answers. Account existence is a request-time question, not a startup one.
private void ValidateDesk(DeskOptions desk, List<string> failures)
{
if (!desk.IsConfigured)
{
if (configuration.GetSection(DeskOptions.SectionName).Exists())
{
failures.Add("Desk:Minds must list at least one Mind when the Desk section exists.");
}
return;
}
if (!Uri.TryCreate(desk.AllowedOrigin, UriKind.Absolute, out var origin) ||
origin.Scheme is not ("http" or "https") ||
!string.Equals(desk.AllowedOrigin, origin.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
{
failures.Add("Desk:AllowedOrigin must be one http(s) origin: scheme, host and optional port, no path.");
}
if (desk.MaxBodyBytes < 1)
{
failures.Add("Desk:MaxBodyBytes must be at least 1.");
}
if (desk.RetryAfterSeconds < 1)
{
failures.Add("Desk:RetryAfterSeconds must be at least 1.");
}
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];
if (string.IsNullOrWhiteSpace(mind.Id))
{
failures.Add($"Desk:Minds:{i}:Id must not be blank.");
}
else if (!ids.Add(mind.Id))
{
failures.Add($"Desk:Minds:{i}:Id repeats an earlier Mind id.");
}
if (!Path.IsPathFullyQualified(mind.Socket))
{
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)
{
if (!IsLowercaseGuid(decider))
{
failures.Add($"Desk:Minds:{i}:Deciders entries must be lowercase D-format account ids.");
}
else if (!deciders.Add(decider))
{
failures.Add($"Desk:Minds:{i}:Deciders repeats an account id.");
}
}
}
var attribution = desk.Attribution;
if (!string.IsNullOrEmpty(attribution.Operator) && !IsLowercaseGuid(attribution.Operator))
{
failures.Add("Desk:Attribution:Operator must be a lowercase D-format account id.");
}
var stewards = new HashSet<string>(StringComparer.Ordinal);
foreach (var steward in attribution.Stewards)
{
if (!IsLowercaseGuid(steward))
{
failures.Add("Desk:Attribution:Stewards entries must be lowercase D-format account ids.");
}
else if (!stewards.Add(steward))
{
failures.Add("Desk:Attribution:Stewards repeats an account id.");
}
else if (string.Equals(steward, attribution.Operator, StringComparison.Ordinal))
{
failures.Add("Desk:Attribution:Operator must not also be a steward.");
}
}
}
private static bool IsLowercaseGuid(string? value) =>
Guid.TryParseExact(value, "D", out var guid) && string.Equals(value, guid.ToString("D"), StringComparison.Ordinal);
private static void Refuse(List<string> failures)
{
if (failures.Count > 0)

View file

@ -7,6 +7,7 @@ using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Bridge;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Features.GitHub;
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
@ -76,6 +77,10 @@ builder.Services
.AddOptions<ConceptVoteOptions>()
.BindConfiguration(ConceptVoteOptions.SectionName);
builder.Services
.AddOptions<DeskOptions>()
.BindConfiguration(DeskOptions.SectionName);
builder.Services.AddAntiforgery(options =>
{
options.Cookie.Name = BifrostCookies.Antiforgery;
@ -252,6 +257,13 @@ if (oidcOptions is not null)
builder.Services.AddBifrostOidc(oidcOptions);
}
// The desk is mapped only when the Desk section exists, and a configured desk needs its credential and door ports.
var deskConfigured = builder.Configuration.GetSection(DeskOptions.SectionName).Exists();
if (deskConfigured)
{
builder.Services.AddBifrostDesk();
}
var app = builder.Build();
var appJsonSerializerOptions = CreateAppJsonSerializerOptions();
@ -293,12 +305,18 @@ app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToke
}).AllowAnonymous();
app.MapConceptTallyExport();
app.MapConceptRankedExport();
if (oidcOptions is not null)
{
app.MapBifrostOidc();
}
if (deskConfigured)
{
app.MapDesk();
}
app.MapPost("/github/webhooks", async (
HttpRequest request,
GitHubWebhookService gitHubWebhookService,

View file

@ -0,0 +1,561 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Tests;
public sealed class ConceptRankedExportTests : IClassFixture<ConceptRankedExportTests.RankedFactory>
{
private const string AllowedOrigin = "https://aetheria.gamecult.org";
private static readonly DateTimeOffset Epoch = new(2026, 11, 1, 0, 0, 0, TimeSpan.Zero);
private readonly RankedFactory _factory;
public ConceptRankedExportTests(RankedFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Net_ranks_up_minus_down_and_ties_break_by_key()
{
var c = await Seed();
var keys = Keys(await GetOk(c, "?sort=net"));
Assert.Equal(["bravo", "alpha", "charlie", "delta", "echo"], keys);
}
[Fact]
public async Task Default_order_is_net_and_the_default_page_is_everything_under_the_cap()
{
var c = await Seed();
var body = await GetOk(c, "");
Assert.Equal(Keys(await GetOk(c, "?sort=net")), Keys(body));
Assert.Equal(JsonValueKind.Null, body.GetProperty("next_cursor").ValueKind);
}
[Fact]
public async Task Total_ranks_up_plus_down()
{
var c = await Seed();
Assert.Equal(["alpha", "bravo", "charlie", "delta", "echo"], Keys(await GetOk(c, "?sort=total")));
}
[Fact]
public async Task Newest_ranks_by_registration_time()
{
var c = await Seed();
Assert.Equal(["echo", "delta", "charlie", "bravo", "alpha"], Keys(await GetOk(c, "?sort=newest")));
}
[Fact]
public async Task Items_carry_counts_and_title_only()
{
var c = await Seed();
var first = (await GetOk(c, "?limit=1")).GetProperty("items")[0];
Assert.Equal(["down", "key", "members", "title", "up"], first.EnumerateObject().Select(p => p.Name).Order().ToArray());
Assert.Equal(["down", "up"], first.GetProperty("members").EnumerateObject().Select(p => p.Name).Order().ToArray());
}
[Theory]
[InlineData("net")]
[InlineData("total")]
[InlineData("newest")]
public async Task Paging_walks_every_subject_once_in_the_same_order_as_one_big_page(string sort)
{
var c = await Seed();
var whole = Keys(await GetOk(c, $"?sort={sort}"));
var walked = new List<string>();
string? cursor = null;
var pages = 0;
do
{
var body = await GetOk(c, $"?sort={sort}&limit=2" + (cursor is null ? "" : "&cursor=" + cursor));
walked.AddRange(Keys(body));
cursor = body.GetProperty("next_cursor").ValueKind == JsonValueKind.Null ? null : body.GetProperty("next_cursor").GetString();
pages++;
}
while (cursor is not null && pages < 10);
Assert.Equal(whole, walked);
Assert.Equal(3, pages);
}
[Fact]
public async Task Ties_break_by_ordinal_key_even_when_keys_hold_hyphens_and_were_registered_out_of_order()
{
var c = "c" + Guid.NewGuid().ToString("N");
string[] ordinal = ["a-b", "a-c", "ab", "b", "b-a"];
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
foreach (var key in ordinal.Reverse())
{
db.ConceptSubjects.Add(new ConceptSubject { Collection = c, Key = key, Title = key, RegisteredAtUtc = Epoch });
}
await db.SaveChangesAsync();
}
var walked = new List<string>();
string? cursor = null;
do
{
var body = await GetOk(c, "?limit=1" + (cursor is null ? "" : "&cursor=" + cursor));
walked.AddRange(Keys(body));
cursor = body.GetProperty("next_cursor").ValueKind == JsonValueKind.Null ? null : body.GetProperty("next_cursor").GetString();
}
while (cursor is not null && walked.Count < 10);
Assert.Equal(ordinal, walked);
Assert.Equal(ordinal, Keys(await GetOk(c, "?sort=newest")));
}
[Fact]
public async Task Unvoted_without_a_client_address_still_pages()
{
var c = await Seed();
var first = await GetOk(c, "?unvoted=network&limit=2");
var second = await GetOk(c, "?unvoted=network&limit=2&cursor=" + first.GetProperty("next_cursor").GetString());
Assert.Equal(["bravo", "alpha"], Keys(first));
Assert.Equal(["charlie", "delta"], Keys(second));
}
[Fact]
public async Task A_vote_between_pages_never_repeats_the_cursors_own_row()
{
var c = await Seed();
var first = await GetOk(c, "?limit=2");
var seen = Keys(first);
await Cast(c, "echo", "mover-1", ConceptStance.Up);
await Cast(c, "echo", "mover-2", ConceptStance.Up);
await Cast(c, "echo", "mover-3", ConceptStance.Up);
await Cast(c, "echo", "mover-4", ConceptStance.Up);
var second = Keys(await GetOk(c, "?limit=5&cursor=" + first.GetProperty("next_cursor").GetString()));
Assert.Empty(seen.Intersect(second));
}
[Theory]
[InlineData("?limit=0")]
[InlineData("?limit=101")]
[InlineData("?limit=-1")]
[InlineData("?limit=+5")]
[InlineData("?limit=05")]
[InlineData("?limit=5.0")]
[InlineData("?limit=%205")]
[InlineData("?limit=")]
[InlineData("?limit=abc")]
[InlineData("?limit=99999999999999999999")]
[InlineData("?sort=")]
[InlineData("?sort=oldest")]
[InlineData("?sort=Net")]
[InlineData("?sort=net&sort=total")]
[InlineData("?limit=1&limit=2")]
[InlineData("?unvoted=")]
[InlineData("?unvoted=all")]
[InlineData("?unvoted=network&unvoted=network")]
[InlineData("?Sort=net")]
[InlineData("?tag=abc")]
[InlineData("?networkTag=abc")]
[InlineData("?page=2")]
public async Task A_query_outside_the_closed_shape_is_a_400(string query)
{
var c = await Seed();
using var response = await Get(c, query);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("request_invalid", JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.GetProperty("error").GetString());
}
[Fact]
public async Task The_page_cap_is_one_hundred_and_one_hundred_is_allowed()
{
var c = await Seed();
using var ok = await Get(c, "?limit=100");
using var over = await Get(c, "?limit=101");
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, over.StatusCode);
}
public static IEnumerable<object[]> HostileCursors()
{
static string B64(string text) => Convert.ToBase64String(Encoding.UTF8.GetBytes(text)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
yield return ["x"];
yield return ["%00"];
yield return ["not base64!"];
yield return ["AAAA"];
yield return [new string('A', 201)];
yield return [new string('A', 100_000)];
yield return [B64("")];
yield return [B64("1|0|0|5")];
yield return [B64("1|0|0|5|alpha|extra")];
yield return [B64("2|0|0|5|alpha")];
yield return [B64("1|3|0|5|alpha")];
yield return [B64("1|-1|0|5|alpha")];
yield return [B64("1|0|2|5|alpha")];
yield return [B64("1|0|0|abc|alpha")];
yield return [B64("1|0|0|+5|alpha")];
yield return [B64("1|0|0|-0|alpha")];
yield return [B64("1|0|0|05|alpha")];
yield return [B64("1|0|0|99999999999999999999|alpha")];
yield return [B64("1|0|0|5|Alpha")];
yield return [B64("1|0|0|5|al pha")];
yield return [B64("1|0|0|5|alpha\n")];
yield return [B64("1|0|0|5|'; drop table")];
yield return [B64("1|0|0|5|../x")];
yield return [B64("1|0|0|5|" + new string('a', 81))];
yield return [B64("1|0|0|5|")];
yield return [Convert.ToBase64String(Encoding.UTF8.GetBytes("1|0|0|5|alpha"))];
yield return [B64("1|0|0|5|alpha") + "="];
yield return [Convert.ToBase64String([0xFF, 0xFE, 0xFD, 0xFC]).Replace('+', '-').Replace('/', '_').TrimEnd('=')];
}
[Theory]
[MemberData(nameof(HostileCursors))]
public async Task A_hostile_cursor_is_a_400_and_never_a_query(string cursor)
{
var c = await Seed();
using var response = await Get(c, "?cursor=" + Uri.EscapeDataString(cursor));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task A_cursor_is_bound_to_the_sort_and_the_filter_it_was_issued_for()
{
var c = await Seed();
var net = (await GetOk(c, "?limit=1")).GetProperty("next_cursor").GetString()!;
var unvoted = (await GetOk(c, "?limit=1&unvoted=network", client: "198.51.100.9")).GetProperty("next_cursor").GetString()!;
using var otherSort = await Get(c, "?sort=total&cursor=" + net);
using var dropsFilter = await Get(c, "?cursor=" + unvoted);
using var addsFilter = await Get(c, "?unvoted=network&cursor=" + net, client: "198.51.100.9");
using var same = await Get(c, "?limit=1&cursor=" + net);
Assert.Equal(HttpStatusCode.BadRequest, otherSort.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, dropsFilter.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, addsFilter.StatusCode);
Assert.Equal(HttpStatusCode.OK, same.StatusCode);
}
[Fact]
public async Task A_well_formed_cursor_past_every_row_is_an_empty_last_page()
{
var c = await Seed();
var past = new RankCursor(RankOrder.Net, false, long.MinValue, "a").Encode();
var body = await GetOk(c, "?cursor=" + past);
Assert.Empty(Keys(body));
Assert.Equal(JsonValueKind.Null, body.GetProperty("next_cursor").ValueKind);
}
[Theory]
[InlineData("Alpha")]
[InlineData("a_b")]
[InlineData("..")]
[InlineData("a%2Fb")]
[InlineData("a%0A")]
[InlineData("%27%3B--")]
[InlineData("nope")]
public async Task A_hostile_or_unknown_collection_is_a_404(string collection)
{
using var response = await Get(collection, "");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task A_collection_over_the_key_grammar_is_a_404()
{
using var response = await Get(new string('a', 81), "");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Retired_subjects_are_left_out()
{
var c = await Seed();
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
(await db.ConceptSubjects.SingleAsync(x => x.Collection == c && x.Key == "bravo")).RetiredAtUtc = Epoch;
await db.SaveChangesAsync();
}
Assert.DoesNotContain("bravo", Keys(await GetOk(c, "")));
}
[Fact]
public async Task Unvoted_leaves_out_what_the_callers_network_voted_and_not_what_others_did()
{
var c = await Seed();
var fromVoter = Keys(await GetOk(c, "?unvoted=network", client: Voter));
var fromStranger = Keys(await GetOk(c, "?unvoted=network", client: "198.51.100.77"));
Assert.Equal(["charlie", "delta", "echo"], fromVoter.Order().ToArray());
Assert.Equal(["alpha", "bravo", "charlie", "delta", "echo"], fromStranger.Order().ToArray());
}
[Fact]
public async Task Unvoted_pages_and_ranks_the_same_way()
{
var c = await Seed();
var walked = new List<string>();
string? cursor = null;
do
{
var body = await GetOk(c, "?unvoted=network&sort=newest&limit=1" + (cursor is null ? "" : "&cursor=" + cursor), client: "198.51.100.77");
walked.AddRange(Keys(body));
cursor = body.GetProperty("next_cursor").ValueKind == JsonValueKind.Null ? null : body.GetProperty("next_cursor").GetString();
}
while (cursor is not null && walked.Count < 10);
Assert.Equal(["echo", "delta", "charlie", "bravo", "alpha"], walked);
}
[Fact]
public async Task Unvoted_without_a_client_address_filters_nothing_and_matches_no_untagged_row()
{
var c = await Seed();
await CastUntagged(c, "echo", "ghost-device");
var keys = Keys(await GetOk(c, "?unvoted=network", client: null));
Assert.Equal(5, keys.Length);
}
[Fact]
public async Task Unvoted_and_plain_responses_expose_no_tag_or_voter_identity()
{
var c = await Seed();
string[] tags;
using (var scope = _factory.Services.CreateScope())
{
tags = await scope.ServiceProvider.GetRequiredService<BifrostDbContext>()
.ConceptAppraisals.Where(x => x.NetworkTag != "").Select(x => x.NetworkTag).Distinct().ToArrayAsync();
}
Assert.NotEmpty(tags);
foreach (var query in new[] { "", "?unvoted=network", "?limit=2&unvoted=network" })
{
using var response = await Get(c, query, client: Voter);
var text = await response.Content.ReadAsStringAsync();
foreach (var forbidden in tags.Concat(["voter-", "device:", "account:", "networktag", "voterkey", Voter]))
{
Assert.DoesNotContain(forbidden, text, StringComparison.OrdinalIgnoreCase);
}
}
// The cursor is position only: it carries no tag either.
var cursor = (await GetOk(c, "?limit=1&unvoted=network", client: Voter)).GetProperty("next_cursor").GetString()!;
var decoded = Encoding.UTF8.GetString(Microsoft.AspNetCore.WebUtilities.WebEncoders.Base64UrlDecode(cursor));
foreach (var tag in tags)
{
Assert.DoesNotContain(tag, decoded);
}
}
[Fact]
public async Task Nothing_in_the_request_can_name_a_tag_or_a_client()
{
var c = await Seed();
using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{c}/ranked?unvoted=network");
request.Headers.Add("X-Test-Anonymous", "true");
request.Headers.Add("X-Test-Client", "198.51.100.77");
request.Headers.Add("X-Network-Tag", "anything");
request.Headers.Add("X-Forwarded-For", Voter);
using var response = await client.SendAsync(request);
// The test connection address wins; the forwarded header is the proxy's to set, not the request's.
Assert.Equal(5, Keys(JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement).Length);
}
[Fact]
public async Task Cache_control_is_public_for_the_plain_read_and_never_cached_for_the_unvoted_one()
{
var c = await Seed();
using var plain = await Get(c, "");
using var unvoted = await Get(c, "?unvoted=network", client: Voter);
Assert.True(plain.Headers.CacheControl!.Public);
Assert.Equal(TimeSpan.FromSeconds(60), plain.Headers.CacheControl.MaxAge);
Assert.True(unvoted.Headers.CacheControl!.Private);
Assert.True(unvoted.Headers.CacheControl.NoStore);
Assert.False(unvoted.Headers.CacheControl.Public);
}
[Fact]
public async Task Cors_and_vary_follow_the_tally_export_policy_and_the_read_sets_no_cookie_and_writes_nothing()
{
var c = await Seed();
int rows, audits;
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
rows = db.ConceptAppraisals.Count();
audits = db.AuditEvents.Count();
}
using var allowed = await Get(c, "", origin: AllowedOrigin);
using var other = await Get(c, "", origin: "https://evil.example");
using var bad = await Get(c, "?limit=0", origin: AllowedOrigin);
Assert.Equal([AllowedOrigin], allowed.Headers.GetValues("Access-Control-Allow-Origin").ToArray());
Assert.False(allowed.Headers.Contains("Access-Control-Allow-Credentials"));
Assert.False(other.Headers.Contains("Access-Control-Allow-Origin"));
foreach (var response in new[] { allowed, other, bad })
{
Assert.Contains("Origin", response.Headers.Vary.ToArray());
Assert.False(response.Headers.Contains("Set-Cookie"));
}
using var after = _factory.Services.CreateScope();
var dbAfter = after.ServiceProvider.GetRequiredService<BifrostDbContext>();
Assert.Equal(rows, dbAfter.ConceptAppraisals.Count());
Assert.Equal(audits, dbAfter.AuditEvents.Count());
}
// The network that cast the votes in Seed.
private const string Voter = "203.0.113.7";
// alpha 3 up 2 down (net 1, total 5), bravo 2 up (net 2, total 2), charlie 1 up 1 down (net 0, total 2),
// delta and echo unvoted (net 0, total 0). Registered alpha first, echo last. Voter's network cast alpha and
// bravo; charlie's votes came from a second network.
private async Task<string> Seed()
{
var c = "c" + Guid.NewGuid().ToString("N");
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var i = 0;
foreach (var key in new[] { "alpha", "bravo", "charlie", "delta", "echo" })
{
db.ConceptSubjects.Add(new ConceptSubject { Collection = c, Key = key, Title = key.ToUpperInvariant(), RegisteredAtUtc = Epoch.AddDays(i++) });
}
await db.SaveChangesAsync();
}
await Cast(c, "alpha", "voter-1", ConceptStance.Up);
await Cast(c, "alpha", "voter-2", ConceptStance.Up);
await Cast(c, "alpha", "voter-3", ConceptStance.Up);
await Cast(c, "alpha", "voter-4", ConceptStance.Down);
await Cast(c, "alpha", "voter-5", ConceptStance.Down);
await Cast(c, "bravo", "voter-1", ConceptStance.Up);
await Cast(c, "bravo", "voter-2", ConceptStance.Up);
await Cast(c, "charlie", "other-1", ConceptStance.Up, "192.0.2.50");
await Cast(c, "charlie", "other-2", ConceptStance.Down, "192.0.2.50");
return c;
}
private async Task Cast(string collection, string key, string device, ConceptStance stance, string client = Voter)
{
using var scope = _factory.Services.CreateScope();
var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, device)!;
var result = await scope.ServiceProvider.GetRequiredService<ConceptAppraisalService>()
.CastAsync(collection, key, voter, stance, IPAddress.Parse(client), default);
Assert.Equal(CastResult.Recorded, result);
}
private async Task CastUntagged(string collection, string key, string device)
{
using var scope = _factory.Services.CreateScope();
var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, device)!;
await scope.ServiceProvider.GetRequiredService<ConceptAppraisalService>()
.CastAsync(collection, key, voter, ConceptStance.Up, null, default);
}
private async Task<HttpResponseMessage> Get(string collection, string query, string? client = null, string? origin = null)
{
using var http = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{collection}/ranked{query}");
request.Headers.Add("X-Test-Anonymous", "true");
if (client is not null)
{
request.Headers.Add("X-Test-Client", client);
}
if (origin is not null)
{
request.Headers.TryAddWithoutValidation("Origin", origin);
}
return await http.SendAsync(request);
}
private async Task<JsonElement> GetOk(string collection, string query, string? client = null)
{
using var response = await Get(collection, query, client);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.Clone();
}
private static string[] Keys(JsonElement body) =>
body.GetProperty("items").EnumerateArray().Select(i => i.GetProperty("key").GetString()!).ToArray();
// The test host has no connection address; a header stands in for the one the proxy path would have resolved.
private sealed class ClientAddressFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
{
app.Use((context, nextMiddleware) =>
{
if (context.Request.Headers.TryGetValue("X-Test-Client", out var address))
{
context.Connection.RemoteIpAddress = IPAddress.Parse(address.ToString());
}
return nextMiddleware(context);
});
next(app);
};
}
public sealed class RankedFactory : TestWebApplicationFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureServices(services => services.AddTransient<IStartupFilter, ClientAddressFilter>());
builder.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConceptVotes:AllowedOrigins:0"] = AllowedOrigin,
["ConceptVotes:NetworkTagSecret"] = "ranked-test-tag-secret-0123456789-abcdef"
}));
}
}
}

View file

@ -0,0 +1,203 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskAnswerTests
{
private static readonly string Socket = DeskWebApplicationFactory.SocketOf("gamecult-chat");
private static async Task<(DeskWebApplicationFactory Factory, string Token)> ArrangeAsync()
{
var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
return (factory, factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"));
}
private static AnswerResult Stale(string forkId) => new(
DeskSchemas.AnswerResult,
new DeskAnswerDocument(
DeskSchemas.DeskAnswer, "n1", forkId, "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);
[Fact]
public async Task Stale_fork_attaches_current_card_fresh_and_replayed()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
var target = DeskReplay.Target("answer", "gamecult-chat", null);
factory.Door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork, "first card"));
var fresh = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
// The same stored answer is replayed after the card changed: nothing is stored for the card, so the reply carries the new one.
factory.Door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork, "second card"));
var replayed = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.Equal(409, fresh.Status);
Assert.Equal("first card", (string?)JsonNode.Parse(fresh.Body)!["card"]!["title"]);
Assert.Equal(409, replayed.Status);
Assert.Equal("second card", (string?)JsonNode.Parse(replayed.Body)!["card"]!["title"]);
}
}
[Fact]
public async Task A_refusal_that_is_not_stale_carries_no_card_and_asks_no_read()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
var closed = Stale(DeskReplay.ChatFork);
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(closed with { Answer = closed.Answer with { Code = "fork_closed" } });
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.Equal(409, reply.Status);
Assert.Null(JsonNode.Parse(reply.Body)!["card"]);
Assert.DoesNotContain(factory.Door.Calls, c => c.Kind == "read");
}
}
[Fact]
public async Task Card_read_failure_is_unavailable()
{
foreach (var failure in new[] { "throws", "refuses" })
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
factory.Door.ThrowOnRead = failure == "throws";
// "refuses": no fork held, so the read answers fork_unknown
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.True(503 == reply.Status, failure);
Assert.Equal("unavailable", (string?)JsonNode.Parse(reply.Body)!["code"]);
}
}
}
[Fact]
public async Task The_body_reaches_the_door_byte_for_byte()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
DeskReplay.HoldVectorForks(factory.Door);
// Whitespace, escapes and key spacing a re-serialiser would normalise.
var compact = Encoding.UTF8.GetString(DeskReplay.AnswerBody(DeskReplay.ChatFork));
var odd = compact.Replace("{", "{ \n\t", StringComparison.Ordinal).Replace(",\"", " ,\r\n \"", StringComparison.Ordinal)
.Replace("Keep", "\\u004beep", StringComparison.Ordinal) + " \n";
var bytes = Encoding.UTF8.GetBytes(odd);
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), bytes, token);
Assert.Equal(200, reply.Status);
Assert.Equal(bytes, Assert.Single(factory.Door.Calls).Body);
}
}
[Fact]
public async Task The_door_context_comes_from_the_account_and_the_configuration_never_the_request()
{
var (factory, _) = await ArrangeAsync();
using (factory)
{
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Steward, true);
var token = factory.Token(DeskWebApplicationFactory.Steward.ToUpperInvariant(), "bifrost-desk", "desk.answer");
// Query strings and headers that name a decider or an answerer change nothing.
var reply = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "nibu", null) + "?deciders=" + DeskWebApplicationFactory.Member + "&answerer=x",
DeskReplay.AnswerBody("nibu:question:glow-order"), token,
configure: http => http.Request.Headers["X-Deciders"] = DeskWebApplicationFactory.Member);
Assert.Equal(200, reply.Status);
var call = Assert.Single(factory.Door.Calls);
Assert.Equal(DeskWebApplicationFactory.Steward, call.Context.Subject);
Assert.Equal("Steward", call.Context.Authority);
Assert.Equal("nibu", call.Context.Mind);
Assert.Equal(DeskWebApplicationFactory.DecidersOf("nibu"), call.Context.Deciders);
}
}
[Fact]
public async Task Body_is_judged_after_membership_and_before_the_mind()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
// A bad body for an unknown Mind is the body's refusal; a non-Active member's bad body is forbidden.
var bad = Encoding.UTF8.GetBytes("{\"answerer\":\"x\"}");
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "ghost", null), bad, token);
Assert.Equal("body_forbidden_field", (string?)JsonNode.Parse(reply.Body)!["code"]);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, false);
var forbidden = await factory.SendAsync("POST", DeskReplay.Target("answer", "ghost", null), bad, token);
Assert.Equal("forbidden", (string?)JsonNode.Parse(forbidden.Body)!["code"]);
Assert.Empty(factory.Door.Calls);
}
}
[Fact]
public async Task Membership_is_re_read_on_every_request()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
DeskReplay.HoldVectorForks(factory.Door);
var target = DeskReplay.Target("answer", "gamecult-chat", null);
Assert.Equal(200, (await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token)).Status);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, false);
Assert.Equal(403, (await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token)).Status);
}
}
[Fact]
public async Task An_oversized_body_is_refused_before_it_is_read()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
var big = Encoding.UTF8.GetBytes("{\"x\":\"" + new string('a', 17 * 1024) + "\"}");
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), big, token);
Assert.Equal("body_invalid", (string?)JsonNode.Parse(reply.Body)!["code"]);
// Chunked: no Content-Length to judge, so the cap applies while reading.
var chunked = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "gamecult-chat", null), big, token,
configure: http => http.Request.ContentLength = null);
Assert.Equal("body_invalid", (string?)JsonNode.Parse(chunked.Body)!["code"]);
Assert.Empty(factory.Door.Calls);
}
}
[Fact]
public async Task A_door_that_sends_a_code_the_contract_does_not_name_is_unavailable()
{
var (factory, token) = await ArrangeAsync();
using (factory)
{
foreach (var script in new Func<DeskAnswerOutcome>[]
{
() => new DeskAnswerOutcome.NotStored("something_internal"),
() => new DeskAnswerOutcome.NotStored("forbidden"),
() => new DeskAnswerOutcome.Answered(Stale("x") with { Answer = Stale("x").Answer with { Code = "made_up" } }),
})
{
factory.Door.AnswerScript = (_, _, _) => script();
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.Equal(503, reply.Status);
}
}
}
}

View file

@ -0,0 +1,49 @@
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskAuthCaseTests
{
[Fact]
public async Task Vector_auth_cases()
{
var cases = DeskVectors.Rows("transport", "authCases").ToList();
Assert.Equal(22, cases.Count);
foreach (var row in cases)
{
using var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
factory.Door.Down = (bool)row["huginn_down"]!;
var token = await DeskReplay.TokenAsync(factory, row["token"]);
var route = (string)row["route"]!;
var reply = await factory.SendAsync(
route == "answer" ? "POST" : "GET", DeskReplay.Target(route, "gamecult-chat", DeskReplay.ChatFork),
route == "answer" ? DeskReplay.AnswerBody(DeskReplay.ChatFork) : null, token);
var name = (string)row["name"]!;
var expect = (string)row["expect"]!;
var body = JsonNode.Parse(reply.Body);
switch (expect)
{
case "ok":
Assert.True(200 == reply.Status, $"{name}: {reply.Status} {reply.Text}");
Assert.Single(factory.Door.Calls);
break;
case "unavailable":
Assert.True(503 == reply.Status, $"{name}: {reply.Status}");
Assert.True(DeskVectors.SameShape(body, JsonNode.Parse(System.Text.Json.JsonSerializer.Serialize(DeskUnavailables.Document(30), DeskJson.Options))), name);
break;
default:
Assert.True(DeskRefusals.StatusOf(expect) == reply.Status, $"{name}: {reply.Status}");
Assert.True(DeskVectors.SameShape(body, JsonNode.Parse(System.Text.Json.JsonSerializer.Serialize(DeskRefusals.Document(expect), DeskJson.Options))), name);
// Auth, scope and membership are judged before Huginn is asked.
Assert.True(factory.Door.Calls.Count == 0, $"{name}: the door was called");
break;
}
}
}
}

View file

@ -0,0 +1,267 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskBodyShapeTests
{
private const int Cap = 16 * 1024;
private static DeskBodyVerdict Judge(JsonNode body, int cap = Cap) => DeskBodyShape.Judge(DeskVectors.Bytes(body), cap);
private static DeskBodyVerdict Judge(string json, int cap = Cap) => DeskBodyShape.Judge(Encoding.UTF8.GetBytes(json), cap);
private static JsonObject ValidBody() =>
DeskVectors.Rows("request", "valid").First(row => (string)row["name"]! == "choice_and_note")["message"]!.DeepClone().AsObject();
private static string ExpectedCode(string name) => name switch
{
"answerer_key" or "authority_key" => DeskRefusals.BodyForbiddenField,
"answer_object_with_a_key" => DeskRefusals.BodyUnknownField,
_ => DeskRefusals.BodyInvalid,
};
public static IEnumerable<object[]> ValidRows() =>
DeskVectors.Rows("request", "valid").Select(row => new object[] { (string)row["name"]! });
public static IEnumerable<object[]> InvalidRows() =>
DeskVectors.Rows("request", "invalid").Select(row => new object[] { (string)row["name"]! });
[Theory]
[MemberData(nameof(ValidRows))]
public void Valid_request_vectors_pass(string name)
{
var message = DeskVectors.Rows("request", "valid").First(row => (string)row["name"]! == name)["message"]!;
var verdict = Judge(message);
Assert.Null(verdict.RefusalCode);
Assert.NotNull(verdict.Request);
// The parsed request is the document: same keys, same order, same values.
var echoed = JsonSerializer.SerializeToNode(verdict.Request, DeskJson.Options);
Assert.True(DeskVectors.SameShape(message, echoed), $"{name} did not round-trip: {echoed}");
}
[Theory]
[MemberData(nameof(InvalidRows))]
public void Invalid_request_vectors_refuse_with_their_code(string name)
{
var message = DeskVectors.Rows("request", "invalid").First(row => (string)row["name"]! == name)["message"]!;
var verdict = Judge(message);
Assert.Null(verdict.Request);
Assert.Equal(ExpectedCode(name), verdict.RefusalCode);
}
[Fact]
public void Forbidden_key_at_any_depth_beats_unknown_key()
{
foreach (var key in new[] { "answerer", "authority" })
{
var nested = ValidBody();
nested["answer"]!["detail"] = new JsonObject { ["deep"] = new JsonArray(new JsonObject { [key] = "x" }) };
nested["extra"] = 1;
Assert.Equal(DeskRefusals.BodyForbiddenField, Judge(nested).RefusalCode);
var top = ValidBody();
top["unknown"] = 1;
top[key] = "someone";
Assert.Equal(DeskRefusals.BodyForbiddenField, Judge(top).RefusalCode);
// Written with an escape: the name is the same once decoded, and still refused whole.
var escaped = DeskVectors.Bytes(ValidBody()).ToArray();
var text = Encoding.UTF8.GetString(escaped).TrimEnd('}') + $",\"{key[..3]}\\u{(int)key[3]:x4}{key[4..]}\":\"x\"}}";
Assert.Equal(DeskRefusals.BodyForbiddenField, Judge(text).RefusalCode);
}
// The key name is matched exactly: a near miss is only an unknown key.
var near = ValidBody();
near["Answerer"] = "x";
Assert.Equal(DeskRefusals.BodyUnknownField, Judge(near).RefusalCode);
}
[Fact]
public void Mind_and_deciders_in_the_body_are_unknown_fields()
{
foreach (var key in new[] { "mind", "deciders" })
{
var body = ValidBody();
body[key] = "x";
Assert.Equal(DeskRefusals.BodyUnknownField, Judge(body).RefusalCode);
}
}
[Fact]
public void Illegal_retraction_forms_reach_the_door()
{
var forms = new (string Answer, string Retracted, string Retracts)[]
{
("null", "true", "null"), // a retraction with no target
("{\"choice\":\"no\",\"note\":null}", "true", "\"n1\""), // a retraction carrying an answer
("{\"choice\":\"no\",\"note\":null}", "false", "\"n1\""), // a plain answer with a target
("null", "true", "\"n1\""), // the legal retraction
};
foreach (var (answer, retracted, retracts) in forms)
{
var body = ValidBody();
body["answer"] = JsonNode.Parse(answer);
body["retracted"] = JsonNode.Parse(retracted);
body["retracts"] = JsonNode.Parse(retracts);
var verdict = Judge(body);
Assert.True(verdict.RefusalCode is null, $"answer={answer} retracted={retracted} retracts={retracts}: {verdict.RefusalCode}");
Assert.NotNull(verdict.Request);
}
}
[Fact]
public void Neither_answer_nor_retraction_is_body_invalid()
{
foreach (var retracts in new[] { "null", "\"n1\"" })
{
var body = ValidBody();
body["answer"] = null;
body["retracted"] = false;
body["retracts"] = JsonNode.Parse(retracts);
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
}
}
[Fact]
public void Note_limits_are_utf8_bytes()
{
string Note(string text) => Judge(WithNote(text)).RefusalCode ?? "pass";
string Repeat(string unit, int count) => string.Concat(Enumerable.Repeat(unit, count));
var astral = char.ConvertFromUtf32(0x1F600); // 4 bytes
var combining = "e" + (char)0x0301; // 3 bytes
var twoByte = ((char)0xe9).ToString(); // 2 bytes
var spaces = new string(new[] { (char)9, (char)0xa0, (char)0x3000, (char)0x2028 });
Assert.Equal("pass", Note(new string('x', 4000)));
Assert.Equal(DeskRefusals.BodyInvalid, Note(new string('x', 4001)));
Assert.Equal("pass", Note(Repeat(astral, 1000)));
Assert.Equal(DeskRefusals.BodyInvalid, Note(Repeat(astral, 1001)));
Assert.Equal("pass", Note(Repeat(combining, 1333) + "x"));
Assert.Equal(DeskRefusals.BodyInvalid, Note(Repeat(combining, 1334)));
Assert.Equal(DeskRefusals.BodyInvalid, Note(Repeat(twoByte, 1999) + "xxx"));
Assert.Equal("pass", Note("x"));
Assert.Equal(DeskRefusals.BodyInvalid, Note(""));
Assert.Equal(DeskRefusals.BodyInvalid, Note(spaces));
Assert.Equal("pass", Note(" a "));
static JsonNode WithNote(string text)
{
var body = ValidBody();
body["answer"] = new JsonObject { ["choice"] = null, ["note"] = text };
return body;
}
}
[Fact]
public void Lone_surrogate_in_a_note_is_body_invalid()
{
var json = DeskVectors.Bytes(ValidBody()).ToArray();
var text = Encoding.UTF8.GetString(json).Replace("\"note\":\"", "\"note\":\"\\ud800");
Assert.Equal(DeskRefusals.BodyInvalid, Judge(text).RefusalCode);
}
[Fact]
public void Over_cap_refused_before_parse()
{
var body = ValidBody();
var bytes = DeskVectors.Bytes(body);
Assert.Null(DeskBodyShape.Judge(bytes, bytes.Length).RefusalCode);
Assert.Equal(DeskRefusals.BodyInvalid, DeskBodyShape.Judge(bytes, bytes.Length - 1).RefusalCode);
// Over the cap the forbidden key is never found, because the body is never parsed.
body["answerer"] = "x";
var forbidden = DeskVectors.Bytes(body);
Assert.Equal(DeskRefusals.BodyForbiddenField, DeskBodyShape.Judge(forbidden, forbidden.Length).RefusalCode);
Assert.Equal(DeskRefusals.BodyInvalid, DeskBodyShape.Judge(forbidden, forbidden.Length - 1).RefusalCode);
}
[Theory]
[InlineData("")]
[InlineData("not json")]
[InlineData("[]")]
[InlineData("null")]
[InlineData("\"text\"")]
[InlineData("{")]
public void Non_object_or_broken_json_is_body_invalid(string json) =>
Assert.Equal(DeskRefusals.BodyInvalid, Judge(json).RefusalCode);
[Fact]
public void Invalid_utf8_is_body_invalid()
{
var bytes = DeskVectors.Bytes(ValidBody());
var bad = bytes.Take(bytes.Length - 2).Concat(new byte[] { 0xff }).Concat(bytes.Skip(bytes.Length - 2)).ToArray();
Assert.Equal(DeskRefusals.BodyInvalid, DeskBodyShape.Judge(bad, Cap).RefusalCode);
}
[Fact]
public void Repeated_key_is_body_invalid()
{
var text = Encoding.UTF8.GetString(DeskVectors.Bytes(ValidBody())).TrimEnd('}') + ",\"fork_id\":\"other\"}";
Assert.Equal(DeskRefusals.BodyInvalid, Judge(text).RefusalCode);
var answer = Encoding.UTF8.GetString(DeskVectors.Bytes(ValidBody())).Replace("\"choice\":\"no\"", "\"choice\":\"no\",\"choice\":\"yes\"");
Assert.Equal(DeskRefusals.BodyInvalid, Judge(answer).RefusalCode);
}
[Fact]
public void Trailing_newline_does_not_rescue_a_bad_pattern()
{
var body = ValidBody();
body["request_id"] = ((string)body["request_id"]!) + "\n";
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
body = ValidBody();
body["options_hash"] = ((string)body["options_hash"]!) + "\n";
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
}
[Fact]
public void Wrong_schema_version_and_bad_retracts_are_body_invalid()
{
var body = ValidBody();
body["schemaVersion"] = "gamecult.fork.answer_request.v1";
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
foreach (var retracts in new[] { "n0", "n01", "x1", "n" })
{
body = ValidBody();
body["answer"] = null;
body["retracted"] = true;
body["retracts"] = retracts;
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
}
}
[Fact]
public void Missing_key_is_body_invalid_for_each_key()
{
foreach (var key in ValidBody().Select(p => p.Key).ToList())
{
var body = ValidBody();
body.Remove(key);
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
}
}
[Fact]
public void Answer_object_must_hold_both_keys()
{
var body = ValidBody();
body["answer"] = new JsonObject { ["choice"] = "no" };
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
}
}

View file

@ -0,0 +1,67 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskDocumentTests
{
private static JsonNode? Write<T>(T document) => JsonSerializer.SerializeToNode(document, DeskJson.Options);
[Fact]
public void Refusal_and_unavailable_documents_match_vectors()
{
var rows = DeskVectors.Rows("doorRefusals").ToList();
Assert.Equal(9, rows.Count);
foreach (var row in rows)
{
var code = (string)row["message"]!["code"]!;
Assert.True(DeskVectors.SameShape(row["message"], Write(DeskRefusals.Document(code))), $"refusal {code}");
Assert.Equal((int)row["httpStatus"]!, DeskRefusals.StatusOf(code));
}
var unavailable = DeskVectors.Rows("unavailable").ToList();
Assert.Equal(2, unavailable.Count);
foreach (var row in unavailable)
{
var retry = (int?)row["message"]!["retryAfterSeconds"];
Assert.True(DeskVectors.SameShape(row["message"], Write(DeskUnavailables.Document(retry))), (string)row["name"]!);
Assert.Equal(DeskUnavailables.Status, (int)row["httpStatus"]!);
}
}
[Fact]
public void Every_refusal_code_in_the_schema_has_a_document()
{
var codes = DeskVectors.Rows("doorRefusals").Select(row => (string)row["message"]!["code"]!).ToHashSet();
var written = typeof(DeskRefusals).GetFields().Where(f => f.IsLiteral).Select(f => (string)f.GetRawConstantValue()!).ToHashSet();
Assert.Equal(codes, written);
}
[Fact]
public void Answer_results_round_trip_with_their_spelling()
{
var rows = DeskVectors.Rows("results").ToList();
Assert.Equal(6, rows.Count);
foreach (var row in rows)
{
var message = row["message"]!;
var result = message.Deserialize<AnswerResult>(DeskJson.Options)!;
Assert.True(DeskVectors.SameShape(message, Write(result)), (string)row["name"]!);
}
}
[Fact]
public void Cards_round_trip_with_their_spelling()
{
var rows = DeskVectors.Rows("card", "valid").ToList();
Assert.Equal(5, rows.Count);
foreach (var row in rows)
{
var message = row["message"]!;
var card = message.Deserialize<ForkCard>(DeskJson.Options)!;
Assert.True(DeskVectors.SameShape(message, Write(card)), (string)row["name"]!);
}
}
}

View file

@ -0,0 +1,327 @@
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);
}
}
// A declared length over the cap is refused on the header alone, before any byte is read.
[Fact]
public async Task A_declared_length_over_the_cap_is_refused_before_reading()
{
var bare = DeskReplay.AnswerBody(DeskReplay.ChatFork);
var config = DeskWebApplicationFactory.DefaultConfiguration();
config["Desk:MaxBodyBytes"] = bare.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
using var factory = await ArrangeAsync(config);
var reply = await factory.SendAsync(
"POST", DeskReplay.Target("answer", Chat, null), bare, Token(factory),
configure: http => http.Request.ContentLength = bare.Length + 1);
Assert.Equal("body_invalid", Code(reply));
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

@ -0,0 +1,91 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskMindRouteTests
{
private static async Task<(DeskReply Reply, DeskWebApplicationFactory Factory)> RunAsync(JsonObject row)
{
var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
var token = await DeskReplay.TokenAsync(factory, row["token"]);
var route = (string)row["route"]!;
var forkId = (string?)row["fork_id"];
var reply = await factory.SendAsync(
route == "answer" ? "POST" : "GET",
DeskReplay.Target(route, (string)row["mind"]!, forkId),
route == "answer" ? DeskReplay.AnswerBody(forkId!) : null,
token);
return (reply, factory);
}
[Fact]
public async Task Vector_route_cases()
{
var cases = DeskVectors.Rows("transport", "mindRoutes", "cases").ToList();
Assert.Equal(57, cases.Count);
foreach (var row in cases)
{
var name = (string)row["name"]!;
var (reply, factory) = await RunAsync(row);
using (factory)
{
Assert.True((int)row["httpStatus"]! == reply.Status, $"{name}: {reply.Status} {reply.Text}");
var expect = (string)row["expect"]!;
if (expect != "ok")
{
Assert.True(
DeskVectors.SameShape(JsonNode.Parse(reply.Body), JsonNode.Parse(JsonSerializer.Serialize(DeskRefusals.Document(expect), DeskJson.Options))),
name);
}
var opened = (string?)row["opened"];
if (opened is null)
{
Assert.True(factory.Door.Calls.Count == 0, $"{name}: opened {factory.Door.Calls.FirstOrDefault()?.Socket}");
}
else
{
var call = Assert.Single(factory.Door.Calls);
Assert.True(call.Socket.EndsWith("/" + opened, StringComparison.Ordinal), $"{name}: {call.Socket}");
}
var storedIn = (string?)row["stored_in"];
if (storedIn is null)
{
Assert.Empty(factory.Door.StoredIn);
}
else
{
Assert.Equal([DeskWebApplicationFactory.SocketOf(storedIn)], factory.Door.StoredIn);
}
}
}
}
[Fact]
public async Task Same_response_pairs_are_byte_equal()
{
var cases = DeskVectors.Rows("transport", "mindRoutes", "cases").ToDictionary(r => (string)r["name"]!);
var pairs = DeskVectors.Array("transport", "mindRoutes", "sameResponse");
Assert.Equal(4, pairs.Count);
foreach (var pair in pairs)
{
var first = cases[(string)pair![0]!];
var second = cases[(string)pair[1]!];
var (a, fa) = await RunAsync(first);
var (b, fb) = await RunAsync(second);
using (fa)
using (fb)
{
Assert.Equal(a.Status, b.Status);
Assert.Equal(a.Body, b.Body);
}
}
}
}

View file

@ -0,0 +1,125 @@
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskMindRoutesTests
{
private static DeskMindRoutes Routes()
{
var minds = DeskVectors.Root["transport"]!["mindRoutes"]!["minds"]!.AsObject();
return new DeskMindRoutes(minds.Select(m => new DeskMindOptions { Id = m.Key, Socket = "/run/huginn/" + (string)m.Value! }));
}
[Fact]
public void Every_vector_segment_resolves_or_misses()
{
var routes = Routes();
var cases = DeskVectors.Rows("transport", "mindRoutes", "cases").ToList();
Assert.Equal(57, cases.Count);
foreach (var row in cases)
{
var mind = (string)row["mind"]!;
var opened = (string?)row["opened"];
var resolved = routes.Resolve(mind);
// A route that opens a socket resolves to the Mind whose socket it is; every other route is a miss.
// Auth-refused cases never reach the lookup, so only the cases that reach it are judged here.
var expect = (string)row["expect"]!;
if (expect is not ("ok" or "fork_unknown" or "unavailable"))
{
continue;
}
if (opened is null)
{
Assert.True(resolved is null, $"{row["name"]}: '{Short(mind)}' resolved to '{resolved?.Id}'");
}
else
{
Assert.True(resolved is not null, $"{row["name"]}: '{Short(mind)}' missed");
Assert.EndsWith(opened, resolved!.Socket, StringComparison.Ordinal);
}
}
}
[Fact]
public void Segments_that_do_not_name_a_configured_mind_miss()
{
var routes = Routes();
foreach (var mind in new[]
{
"ghost", ".", "..", "../x", "a/b", "a%2fb", "%2e%2e%2fx", "eureka%00", "nibu%20", "nibu ", " nibu",
"%ff", "%c3%28", "%", "%6", "%zz", "nibu%", "%256eibu", "", new string('a', 4096), "nïbu",
})
{
Assert.Null(routes.Resolve(mind));
}
}
[Fact]
public void Percent_encoded_configured_ids_are_decoded_once()
{
var routes = Routes();
Assert.Equal("nibu", routes.Resolve("%6eibu")!.Id);
Assert.Equal("nibu", routes.Resolve("%6Eibu")!.Id);
Assert.Equal("gamecult-chat", routes.Resolve("gamecult%2Dchat")!.Id);
}
[Fact]
public void Other_case_misses_despite_config_case_insensitivity()
{
var routes = Routes();
Assert.Null(routes.Resolve("GAMECULT-CHAT"));
Assert.Null(routes.Resolve("Nibu"));
Assert.Null(routes.Resolve("gamecult-chat%20"));
Assert.NotNull(routes.Resolve("gamecult-chat"));
}
[Fact]
public void Double_encoded_misses()
{
var routes = Routes();
Assert.Null(routes.Resolve("%256eibu"));
Assert.Null(routes.Resolve("gamecult%252Dchat"));
}
[Fact]
public void Configured_ids_that_differ_only_by_case_stay_distinct()
{
var routes = new DeskMindRoutes([
new DeskMindOptions { Id = "a", Socket = "/s/a" },
new DeskMindOptions { Id = "A", Socket = "/s/A" },
]);
Assert.Equal("/s/a", routes.Resolve("a")!.Socket);
Assert.Equal("/s/A", routes.Resolve("A")!.Socket);
}
[Fact]
public void A_decoding_failure_is_a_miss_even_when_the_failure_spells_a_configured_id()
{
var replacement = char.ConvertFromUtf32(0xFFFD);
var e = char.ConvertFromUtf32(0xE9);
var routes = new DeskMindRoutes([
new DeskMindOptions { Id = replacement, Socket = "/s/replacement" },
new DeskMindOptions { Id = e, Socket = "/s/e-acute" },
new DeskMindOptions { Id = "a%zz", Socket = "/s/bad-escape" },
new DeskMindOptions { Id = "100%", Socket = "/s/trailing" },
]);
Assert.Null(routes.Resolve("%ff")); // not UTF-8, not U+FFFD
Assert.Null(routes.Resolve(char.ConvertFromUtf32(0xC3) + char.ConvertFromUtf32(0xA9))); // UTF-8 bytes as raw chars
Assert.Null(routes.Resolve("a%zz"));
Assert.Null(routes.Resolve("100%"));
Assert.Equal("/s/replacement", routes.Resolve("%ef%bf%bd")!.Socket); // the real encoding still resolves
Assert.Equal("/s/e-acute", routes.Resolve("%c3%a9")!.Socket);
}
private static string Short(string value) => value.Length > 40 ? value[..40] + "..." : value;
}

View file

@ -0,0 +1,234 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Features.Shared;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
public sealed class DeskOptionsTests
{
private const string Alice = "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f";
private const string Bob = "00000000-0000-4000-8000-000000000002";
private static Dictionary<string, string?> Good() => new()
{
["Desk:AllowedOrigin"] = "https://chat.gamecult.org",
["Desk:Minds:0:Id"] = "gamecult-chat",
["Desk:Minds:0:Socket"] = "/run/huginn/huginn-gamecult-chat.sock",
["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,
};
private static DeskOptions Bind(Dictionary<string, string?> values)
{
var options = new DeskOptions();
new ConfigurationBuilder().AddInMemoryCollection(values).Build().GetSection(DeskOptions.SectionName).Bind(options);
return options;
}
private static void Validate(Dictionary<string, string?> values, string environment = "Testing")
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
new StartupConfigurationValidator(
configuration,
new StubEnvironment(environment),
Options.Create(new GitHubOAuthOptions()),
Options.Create(new GitHubAppOptions()),
Options.Create(new HeimdallOptions()),
Options.Create(new StripeOptions()),
Options.Create(new BifrostHostOptions()),
Options.Create(new ConceptVoteOptions()),
Options.Create(Bind(values))).Validate();
}
private static void Refused(Dictionary<string, string?> values, string expectedFragment)
{
var failure = Assert.Throws<InvalidOperationException>(() => Validate(values));
Assert.Contains(expectedFragment, failure.Message);
}
[Fact]
public void A_good_desk_configuration_binds_by_position_and_passes()
{
var options = Bind(Good());
Assert.Equal(["gamecult-chat", "nibu"], options.Minds.Select(m => m.Id));
Assert.Equal([Alice], options.Minds[0].Deciders);
Assert.Equal([Bob], options.Minds[1].Deciders);
Validate(Good());
}
[Fact]
public void No_desk_section_is_not_configured_and_passes()
{
Assert.False(Bind([]).IsConfigured);
Validate([]);
}
[Fact]
public void Mind_ids_keep_the_case_the_configuration_gave_them()
{
var values = Good();
values["Desk:Minds:0:Id"] = "GameCult-Chat";
Assert.Equal("GameCult-Chat", Bind(values).Minds[0].Id);
}
[Fact]
public void Startup_refuses_duplicate_ids() => Refused(Dup(), "Desk:Minds:1:Id repeats");
private static Dictionary<string, string?> Dup()
{
var values = Good();
values["Desk:Minds:1:Id"] = "gamecult-chat";
return values;
}
[Fact]
public void Startup_refuses_a_blank_id()
{
var values = Good();
values["Desk:Minds:1:Id"] = " ";
Refused(values, "Desk:Minds:1:Id must not be blank");
}
[Theory]
[InlineData("huginn.sock")]
[InlineData("run/huginn.sock")]
[InlineData("")]
public void Startup_refuses_relative_socket(string socket)
{
var values = Good();
values["Desk:Minds:1:Socket"] = socket;
Refused(values, "Desk:Minds:1:Socket must be an absolute path");
}
[Theory]
[InlineData("E0D1C2B3-A495-4687-8879-6A5B4C3D2E1F")]
[InlineData("e0d1c2b3a495468788796a5b4c3d2e1f")]
[InlineData("{e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f}")]
[InlineData("not-a-guid")]
[InlineData("")]
public void Startup_refuses_bad_decider_id(string decider)
{
var values = Good();
values["Desk:Minds:0:Deciders:1"] = decider;
Refused(values, "Desk:Minds:0:Deciders entries must be lowercase D-format");
}
[Fact]
public void Startup_refuses_a_repeated_decider()
{
var values = Good();
values["Desk:Minds:0:Deciders:1"] = Alice;
Refused(values, "Desk:Minds:0:Deciders repeats");
}
[Fact]
public void Startup_refuses_operator_in_stewards()
{
var values = Good();
values["Desk:Attribution:Stewards:1"] = Alice;
Refused(values, "must not also be a steward");
}
[Fact]
public void Startup_refuses_bad_or_repeated_attribution_ids()
{
var bad = Good();
bad["Desk:Attribution:Operator"] = "ALICE";
Refused(bad, "Desk:Attribution:Operator must be a lowercase");
var badSteward = Good();
badSteward["Desk:Attribution:Stewards:1"] = "E0D1C2B3-A495-4687-8879-6A5B4C3D2E1F";
Refused(badSteward, "Desk:Attribution:Stewards entries must be lowercase");
var repeated = Good();
repeated["Desk:Attribution:Stewards:1"] = Bob;
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()
{
var values = Good();
values["Desk:Minds:1:Socket"] = "relative";
values["Desk:Attribution:Stewards:1"] = Alice;
var failure = Assert.Throws<InvalidOperationException>(() => Validate(values, "Production"));
Assert.Contains("Desk:Minds:1:Socket", failure.Message);
Assert.Contains("must not also be a steward", failure.Message);
}
[Fact]
public void Attribution_is_operator_steward_member()
{
var attribution = Bind(Good()).Attribution;
Assert.Equal("Operator", attribution.AuthorityOf(Alice));
Assert.Equal("Steward", attribution.AuthorityOf(Bob));
Assert.Equal("Member", attribution.AuthorityOf("00000000-0000-4000-8000-000000000003"));
Assert.Equal("Member", attribution.AuthorityOf(Alice.ToUpperInvariant()));
Assert.Equal("Member", new DeskAttributionOptions().AuthorityOf(string.Empty));
}
private sealed class StubEnvironment(string name) : IHostEnvironment
{
public string EnvironmentName { get; set; } = name;
public string ApplicationName { get; set; } = "Bifrost.Web";
public string ContentRootPath { get; set; } = string.Empty;
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
}
}

View file

@ -0,0 +1,354 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskReadTests
{
private static string ReadToken(DeskWebApplicationFactory f) =>
f.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read");
private static Uri Unused => new("http://unused");
// A list_request invalid row names the query that cannot express it; the others are the shapes a query cannot hold.
private static readonly Dictionary<string, string> InvalidListQueries = new()
{
["limit_51"] = "?limit=51",
["limit_negative"] = "?limit=-1",
["status_all"] = "?status=all",
["cursor_malformed"] = "?cursor=next",
["cursor_zero"] = "?cursor=c0",
["unknown_key"] = "?sort=oldest",
};
[Fact]
public async Task List_and_read_request_invalid_vectors()
{
using var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var token = ReadToken(factory);
var invalid = DeskVectors.Rows("transport", "listRequest", "invalid").Select(r => (string)r["name"]!).ToList();
foreach (var name in invalid.Where(n => n != "mind_empty"))
{
var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks" + InvalidListQueries[name], null, token);
Assert.True(reply.Status == 400, $"{name}: {reply.Status}");
Assert.Equal("request_invalid", (string?)JsonNode.Parse(reply.Body)!["code"]);
}
// Also: repeated key, bad escape, query on a read, empty fork id.
foreach (var target in new[]
{
"/api/desk/gamecult-chat/forks?limit=1&limit=2", "/api/desk/gamecult-chat/forks?limit=%zz", "/api/desk/gamecult-chat/forks?limit=",
"/api/desk/gamecult-chat/forks?limit=+1", "/api/desk/gamecult-chat/forks?limit=1.0",
"/api/desk/gamecult-chat/forks/gamecult-chat%3Aquestion%3Aq1?x=1", "/api/desk/gamecult-chat/forks/", "/api/desk/gamecult-chat/forks/%ff",
})
{
var reply = await factory.SendAsync("GET", target, null, token);
Assert.True(reply.Status == 400, $"{target}: {reply.Status}");
}
// The invalid request is judged before the Mind: an unknown Mind with a bad query is the query's refusal.
var order = await factory.SendAsync("GET", "/api/desk/ghost/forks?limit=99", null, token);
Assert.Equal("request_invalid", (string?)JsonNode.Parse(order.Body)!["code"]);
Assert.Empty(factory.Door.Calls);
}
[Fact]
public async Task Valid_list_queries_reach_the_door_as_typed_requests()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
foreach (var row in DeskVectors.Rows("transport", "listRequest", "valid"))
{
var m = row["message"]!;
var query = $"?status={(string?)m["status"]}&cursor={(string?)m["cursor"]}&limit={(int)m["limit"]!}";
var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks" + query, null, ReadToken(factory));
Assert.Equal(200, reply.Status);
var call = factory.Door.Calls[^1];
Assert.Equal(new DeskListRequest("gamecult-chat", (string?)m["status"], (string?)m["cursor"], (int)m["limit"]!), call.List);
}
var bare = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, ReadToken(factory));
Assert.Equal(new DeskListRequest("nibu", null, null, 50), factory.Door.Calls[^1].List);
Assert.Equal(200, bare.Status);
}
[Fact]
public async Task List_scenarios_pass_through()
{
var steps = 0;
foreach (var scenario in DeskVectors.Rows("transport", "listScenarios"))
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()))
{
var request = step["request"]!;
var response = step["response"]!.AsObject();
var list = response.Deserialize<ForkList>(DeskJson.Options)!;
factory.Door.ListScript = (_, _) => new DeskListOutcome.Listed(list);
var query = $"?status={(string?)request["status"]}&cursor={(string?)request["cursor"]}&limit={(int)request["limit"]!}";
var reply = await factory.SendAsync("GET", $"/api/desk/{(string)request["mind"]!}/forks" + query, null, ReadToken(factory));
Assert.Equal(200, reply.Status);
Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), response), (string)scenario["name"]!);
steps++;
}
}
Assert.Equal(7, steps);
}
[Fact]
public async Task Read_returns_the_entry_unchanged_and_an_unknown_fork_is_refused()
{
using var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var ok = await factory.SendAsync("GET", DeskReplay.Target("read", "gamecult-chat", DeskReplay.ChatFork), null, ReadToken(factory));
var entry = JsonSerializer.SerializeToNode(DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork)), DeskJson.Options);
var missing = await factory.SendAsync("GET", DeskReplay.Target("read", "gamecult-chat", "gamecult-chat:question:ghost"), null, ReadToken(factory));
Assert.Equal(200, ok.Status);
Assert.True(DeskVectors.SameShape(JsonNode.Parse(ok.Body), entry));
Assert.Equal(DeskReplay.ChatFork, Assert.IsType<string>(factory.Door.Calls[0].ForkId));
Assert.Equal(404, missing.Status);
}
[Fact]
public async Task A_fork_too_large_for_a_page_is_a_422_that_names_the_fork_and_the_cursor_to_continue_with()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
factory.Door.ListScript = (_, _) => new DeskListOutcome.ForkTooLarge("nibu:question:huge", 7);
var reply = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, ReadToken(factory));
var doc = JsonNode.Parse(reply.Body)!;
Assert.Equal(422, reply.Status);
Assert.Equal("fork_too_large", (string?)doc["code"]);
var error = (string)doc["error"]!;
Assert.Contains("nibu:question:huge", error);
Assert.Contains("order 7", error);
Assert.Contains("c7", error);
// At order 0 there is no cursor to continue with: the list has reached its end.
factory.Door.ListScript = (_, _) => new DeskListOutcome.ForkTooLarge("nibu:question:first", 0);
var end = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, ReadToken(factory));
var endError = (string)JsonNode.Parse(end.Body)!["error"]!;
Assert.Equal(422, end.Status);
Assert.Contains("nibu:question:first", endError);
Assert.Contains("order 0", endError);
Assert.DoesNotContain("cursor c0", endError);
Assert.Contains("end", endError);
}
[Fact]
public async Task fork_too_large_is_list_only_elsewhere_the_door_saying_it_is_unavailable()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound("fork_too_large");
factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed("fork_too_large");
foreach (var target in new[] { "/api/desk/nibu/forks", DeskReplay.Target("read", "nibu", "nibu:question:glow-order") })
{
var reply = await factory.SendAsync("GET", target, null, ReadToken(factory));
Assert.Equal(503, reply.Status);
}
}
[Fact]
public async Task A_short_page_with_a_cursor_below_its_last_entry_passes_through_unchanged()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 3, 0, [], "c2"));
var reply = await factory.SendAsync("GET", "/api/desk/nibu/forks?limit=10", null, ReadToken(factory));
Assert.Equal(200, reply.Status);
Assert.Equal("c2", (string?)JsonNode.Parse(reply.Body)!["next_cursor"]);
}
[Fact]
public async Task A_door_that_refuses_a_read_or_list_for_a_body_field_such_as_authority_is_request_invalid_not_unavailable()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
foreach (var code in new[] { "body_forbidden_field", "body_unknown_field", "body_invalid" })
{
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(code);
factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed(code);
foreach (var target in new[] { "/api/desk/nibu/forks", DeskReplay.Target("read", "nibu", "nibu:question:glow-order") })
{
var reply = await factory.SendAsync("GET", target, null, ReadToken(factory));
Assert.Equal(400, reply.Status);
Assert.Equal("request_invalid", (string?)JsonNode.Parse(reply.Body)!["code"]);
}
}
}
[Fact]
public async Task request_id_conflict_is_the_answers_alone_on_read_and_list_it_is_a_door_fault()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(DeskRefusals.RequestIdConflict);
factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed(DeskRefusals.RequestIdConflict);
foreach (var target in new[] { "/api/desk/nibu/forks", DeskReplay.Target("read", "nibu", "nibu:question:glow-order") })
{
var reply = await factory.SendAsync("GET", target, null, ReadToken(factory));
Assert.Equal(503, reply.Status);
Assert.Equal("unavailable", (string?)JsonNode.Parse(reply.Body)!["code"]);
}
}
[Fact]
public async Task Door_failure_on_list_and_read_is_unavailable()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
factory.Door.Down = true;
foreach (var target in new[] { "/api/desk/nibu/forks", DeskReplay.Target("read", "nibu", "nibu:question:glow-order") })
{
var reply = await factory.SendAsync("GET", target, null, ReadToken(factory));
Assert.Equal(503, reply.Status);
Assert.Equal(30, (int?)JsonNode.Parse(reply.Body)!["retryAfterSeconds"]);
}
}
[Fact]
public async Task Every_door_call_carries_the_configured_deciders_and_nothing_in_the_request_can_change_them()
{
using var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var hostile = "deciders=" + DeskWebApplicationFactory.Member;
Action<Microsoft.AspNetCore.Http.HttpContext> headers = http =>
{
http.Request.Headers["X-Deciders"] = DeskWebApplicationFactory.Member;
http.Request.Headers["Deciders"] = DeskWebApplicationFactory.Member;
};
var read = ReadToken(factory);
var answer = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer");
await factory.SendAsync("GET", "/api/desk/nibu/forks", null, read, configure: headers);
var hostileList = await factory.SendAsync("GET", "/api/desk/nibu/forks?" + hostile, null, read, configure: headers);
Assert.Equal(400, hostileList.Status);
await factory.SendAsync("GET", DeskReplay.Target("read", "nibu", "nibu:question:glow-order"), null, read, configure: headers);
await factory.SendAsync("POST", DeskReplay.Target("answer", "nibu", null) + "?" + hostile, DeskReplay.AnswerBody("nibu:question:glow-order"), answer, configure: headers);
// A body naming deciders never reaches the door.
var body = JsonNode.Parse(DeskReplay.AnswerBody("nibu:question:glow-order"))!.AsObject();
body["deciders"] = new JsonArray(DeskWebApplicationFactory.Member);
var named = await factory.SendAsync("POST", DeskReplay.Target("answer", "nibu", null), DeskVectors.Bytes(body), answer);
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.DecidersOf("nibu"), c.Context.Deciders));
}
private const string Secret = "the decider's own words";
private static DeskAnswerDocument Doc(string id, string outcome, string? of, string? retracts, string? note) => new(
DeskSchemas.DeskAnswer, id, DeskReplay.ChatFork, outcome, of, retracts, null, "h", note == null ? null : "a", note,
"2026-11-12T19:00:00Z", "2026-11-12T19:00:01Z", false, DeskWebApplicationFactory.Alice, "Steward", "r-" + id);
// Counted, Revision, Retraction and Refused documents, every one carrying the decider's note.
private static ForkEntry NotedEntry()
{
var entry = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork));
DeskAnswerDocument[] answers =
[
Doc("n1", "Counted", null, null, Secret),
Doc("n2", "Revision", "n1", null, Secret + " 2"),
Doc("n3", "Retraction", null, "n2", Secret + " 3"),
Doc("n4", "Refused", null, null, Secret + " 4"),
];
return entry with { Record = entry.Record with { CountedAnswerId = "n1", Answers = answers } };
}
private static bool AnyNote(JsonNode? node) => node switch
{
JsonObject o => o.Any(kv => (kv.Key == "note" && kv.Value is not null) || AnyNote(kv.Value)),
JsonArray a => a.Any(AnyNote),
_ => false,
};
private static async Task<(string Read, string List)> BodiesAsync(DeskWebApplicationFactory factory, string account)
{
var entry = NotedEntry();
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.Found(entry);
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(
new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 1, 0, [entry, entry], null));
var token = factory.Token(account, "bifrost-desk", "desk.read");
var read = await factory.SendAsync("GET", DeskReplay.Target("read", "gamecult-chat", DeskReplay.ChatFork), null, token);
var list = await factory.SendAsync("GET", DeskReplay.Target("list", "gamecult-chat", null), null, token);
Assert.Equal(200, read.Status);
Assert.Equal(200, list.Status);
return (System.Text.Encoding.UTF8.GetString(read.Body), System.Text.Encoding.UTF8.GetString(list.Body));
}
[Fact]
public async Task A_non_decider_reads_and_lists_no_notes_anywhere_and_the_body_keeps_the_contract_shape()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Member, true);
Assert.DoesNotContain(DeskWebApplicationFactory.Member, DeskWebApplicationFactory.DecidersOf("gamecult-chat"));
var (read, list) = await BodiesAsync(factory, DeskWebApplicationFactory.Member);
Assert.DoesNotContain("decider's own words", read);
Assert.DoesNotContain("decider's own words", list);
Assert.False(AnyNote(JsonNode.Parse(read)));
Assert.False(AnyNote(JsonNode.Parse(list)));
// 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 })] },
}, DeskJson.Options);
Assert.True(DeskVectors.SameShape(JsonNode.Parse(read), expected));
var answers = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray();
Assert.Equal(new[] { "Counted", "Revision", "Retraction", "Refused" }, answers.Select(a => (string)a!["outcome"]!).ToArray());
Assert.All(answers, a => Assert.True(a!.AsObject().ContainsKey("note")));
Assert.Equal(2, JsonNode.Parse(list)!["forks"]!.AsArray().Count);
}
[Fact]
public async Task A_decider_reads_and_lists_the_notes()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var (read, list) = await BodiesAsync(factory, DeskWebApplicationFactory.Alice);
Assert.Contains(Secret + " 3", read);
Assert.Equal(8, JsonNode.Parse(list)!["forks"]!.AsArray().Sum(f => f!["record"]!["answers"]!.AsArray().Count(a => a!["note"] is not null)));
Assert.True(DeskVectors.SameShape(JsonNode.Parse(read), JsonSerializer.SerializeToNode(NotedEntry(), DeskJson.Options)));
}
[Fact]
public async Task Decider_status_is_per_mind_the_same_account_is_stripped_where_it_is_not_on_the_list()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Member, true);
Assert.Contains(DeskWebApplicationFactory.Member, DeskWebApplicationFactory.DecidersOf("nibu"));
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(
new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 1, 0, [NotedEntry()], null));
var token = factory.Token(DeskWebApplicationFactory.Member, "bifrost-desk", "desk.read");
var onNibu = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, token);
var onChat = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", null, token);
Assert.True(AnyNote(JsonNode.Parse(onNibu.Body)));
Assert.False(AnyNote(JsonNode.Parse(onChat.Body)));
}
}

View file

@ -0,0 +1,188 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskScenarioTests
{
private static readonly string Socket = DeskWebApplicationFactory.SocketOf("gamecult-chat");
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.
private sealed record Counts(int Answers, int Edge, int Unavailable, int Skipped);
private static async Task<Counts> ReplayAsync(Action<string, DeskReply, DeskWebApplicationFactory, JsonObject>? observe = null)
{
int answers = 0, edge = 0, unavailable = 0, skipped = 0;
foreach (var scenario in DeskVectors.Rows("scenarios"))
{
using var factory = new DeskWebApplicationFactory();
var door = factory.Door;
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(scenario["card"]!.Deserialize<ForkCard>(DeskJson.Options)!);
var scenarioName = (string)scenario["name"]!;
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()))
{
var kind = (string)step["kind"]!;
if (kind == "matrix_event")
{
skipped++; // no Matrix ingress exists, so there is no route a Matrix event could take
continue;
}
if (kind == "card")
{
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(step["card"]!.Deserialize<ForkCard>(DeskJson.Options)!);
continue;
}
var server = step["server"]!.AsObject();
var response = step["response"]!.AsObject();
var code = (string?)response["code"];
var answerer = (string?)server["answerer"];
var active = (bool?)server["active"] ?? true;
door.Calls.Clear();
door.StoredIn.Clear();
door.Down = false;
door.ThrowAfterStore = false;
door.AnswerScript = null;
if (answerer is not null)
{
await factory.EnsureAccountAsync(answerer, active);
}
var isDown = (bool?)server["door_down"] == true;
var stored = (bool?)server["stored"] == true;
if (isDown)
{
door.Down = !stored;
door.ThrowAfterStore = stored;
}
if (code is null)
{
var wire = response.Deserialize<AnswerResult>(DeskJson.Options)!;
// Huginn answers without the card; Bifrost attaches the current one for stale_fork.
door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(wire with { Card = null });
}
else if (code is "request_id_conflict" or "fork_unknown")
{
door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.NotStored(code);
}
var body = DeskVectors.Bytes(step["body"]!);
var token = answerer is null
? null
: factory.Token(answerer, "bifrost-desk", "desk.answer");
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), body, token);
var label = $"{scenarioName}";
if (code == "unavailable")
{
unavailable++;
Assert.True(503 == reply.Status, $"{label}: {reply.Status}");
Assert.Equal("30", reply.Headers.RetryAfter.ToString());
Assert.Equal(stored ? [Socket] : [], door.StoredIn);
}
else if (code is not null && DeskReplay.IsEdgeRefusal(code))
{
edge++;
Assert.True(DeskRefusals.StatusOf(code) == reply.Status, $"{label}: {reply.Status}");
Assert.True(door.Calls.Count == 0, $"{label}: the door was called for an edge refusal");
}
else
{
answers++;
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.Authority);
Assert.Equal("gamecult-chat", call.Context.Mind);
Assert.Equal(ConfiguredDeciders, call.Context.Deciders);
Assert.Equal(Socket, call.Socket);
}
Assert.True(
DeskVectors.SameShape(JsonNode.Parse(reply.Body), response),
$"{label}: response differs\n{reply.Text}\n{response.ToJsonString()}");
observe?.Invoke(scenarioName, reply, factory, step);
}
}
return new Counts(answers, edge, unavailable, skipped);
}
[Fact]
public async Task Every_scenario_answer_step_replays_through_http()
{
var counts = await ReplayAsync();
Assert.Equal(3, counts.Skipped);
Assert.Equal(2, counts.Unavailable);
Assert.Equal(15, counts.Edge);
Assert.Equal(100, counts.Answers);
}
[Fact]
public async Task Edge_refusals_never_call_the_door()
{
var edges = 0;
await ReplayAsync((_, reply, factory, step) =>
{
var code = (string?)step["response"]!["code"];
if (code is not null && DeskReplay.IsEdgeRefusal(code))
{
edges++;
Assert.Empty(factory.Door.Calls);
}
});
Assert.Equal(15, edges);
}
[Fact]
public async Task Answer_results_pass_through_unchanged()
{
var checkedResults = 0;
foreach (var row in DeskVectors.Rows("results"))
{
using var factory = new DeskWebApplicationFactory();
var message = row["message"]!.AsObject();
var answer = message["answer"]!.Deserialize<DeskAnswerDocument>(DeskJson.Options)!;
var result = message.Deserialize<AnswerResult>(DeskJson.Options)!;
factory.Door.Forks[(Socket, answer.ForkId)] = DeskReplay.Entry(
message["card"] is { } card ? card.Deserialize<ForkCard>(DeskJson.Options)! : DeskReplay.Card(answer.ForkId));
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(result with { Card = null });
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var reply = await factory.SendAsync(
"POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(answer.ForkId),
factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"));
Assert.True((int)row["httpStatus"]! == reply.Status, $"{row["name"]}: {reply.Status}");
Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), message), $"{row["name"]}");
checkedResults++;
}
Assert.Equal(6, checkedResults);
}
[Fact]
public async Task Unavailable_before_and_after_store()
{
// The scenarios hold both: door_down before the store, and door_down after it (the retry then replays).
var seen = new List<(bool Stored, int Status)>();
await ReplayAsync((_, reply, factory, step) =>
{
if ((string?)step["response"]!["code"] == "unavailable")
{
seen.Add(((bool?)step["server"]!["stored"] == true, reply.Status));
}
});
Assert.Equal([(false, 503), (true, 503)], seen.OrderBy(s => s.Stored).ToArray());
}
}

View file

@ -0,0 +1,149 @@
using System.Net.Sockets;
using System.Text;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Bifrost.Web.Tests;
public sealed class DeskSurfaceTests
{
private static readonly string AnswerTarget = DeskReplay.Target("answer", "gamecult-chat", null);
[Fact]
public async Task Not_mapped_without_config()
{
using var factory = new DeskWebApplicationFactory(configured: false);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var reply = await factory.SendAsync(
"POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork),
factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"));
Assert.Equal(404, reply.Status);
Assert.Empty(factory.Door.Calls);
var routes = factory.Services.GetRequiredService<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>();
Assert.DoesNotContain(routes, e => e.RoutePattern.RawText!.StartsWith("/api/desk", StringComparison.Ordinal));
}
[Fact]
public void A_configured_desk_without_its_ports_stops_startup()
{
using var factory = new DeskWebApplicationFactory(registerPorts: false);
var failure = Assert.ThrowsAny<Exception>(() => factory.CreateClient());
Assert.Contains("IDeskCredential", failure.ToString());
}
[Fact]
public async Task Cookie_session_does_not_authenticate()
{
using var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
// The test host signs every request in as an Active-looking site member through the default scheme. The desk ignores it.
var reply = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork));
var withCookie = await factory.SendAsync(
"POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork),
configure: http => http.Request.Headers.Cookie = "bifrost_session=anything");
Assert.Equal(401, reply.Status);
Assert.Equal(401, withCookie.Status);
Assert.Empty(factory.Door.Calls);
}
[Fact]
public async Task Cors_allows_exactly_the_configured_origin_without_credentials()
{
using var factory = new DeskWebApplicationFactory();
DeskReplay.HoldVectorForks(factory.Door);
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer");
var allowed = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), token, DeskWebApplicationFactory.Origin);
var refused = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), token, "https://evil.example");
var lookalike = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), token, DeskWebApplicationFactory.Origin + ".evil.example");
var preflight = await factory.SendAsync("OPTIONS", AnswerTarget, null, null, DeskWebApplicationFactory.Origin, http =>
{
http.Request.Headers["Access-Control-Request-Method"] = "POST";
http.Request.Headers["Access-Control-Request-Headers"] = "authorization,content-type";
});
var elsewhere = await factory.SendAsync("GET", "/healthz", null, null, DeskWebApplicationFactory.Origin);
Assert.Equal(DeskWebApplicationFactory.Origin, allowed.Headers.AccessControlAllowOrigin.ToString());
Assert.False(allowed.Headers.ContainsKey("Access-Control-Allow-Credentials"));
Assert.Contains("Origin", allowed.Headers.Vary.ToString());
Assert.False(refused.Headers.ContainsKey("Access-Control-Allow-Origin"));
Assert.False(lookalike.Headers.ContainsKey("Access-Control-Allow-Origin"));
Assert.Equal(DeskWebApplicationFactory.Origin, preflight.Headers.AccessControlAllowOrigin.ToString());
Assert.False(preflight.Headers.ContainsKey("Access-Control-Allow-Credentials"));
Assert.Contains("POST", preflight.Headers.AccessControlAllowMethods.ToString());
Assert.False(elsewhere.Headers.ContainsKey("Access-Control-Allow-Origin"));
}
[Fact]
public async Task An_unrecognised_target_is_fork_unknown_and_opens_nothing()
{
using var factory = new DeskWebApplicationFactory();
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer");
foreach (var target in new[]
{
"/api/desk", "/api/desk/", "/api/desk/gamecult-chat", "/api/desk/gamecult-chat/answers/", "/api/desk/a/b/answers",
"/api/desk/a/../gamecult-chat/answers", "/api/desk/./gamecult-chat/answers", "/api/desk//answers/x",
"/API/DESK/gamecult-chat/answers", "/api/deskx/gamecult-chat/answers", "http://localhost/api/desk/gamecult-chat/answers",
})
{
var reply = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
Assert.True(reply.Status == 404, $"{target}: {reply.Status}");
}
Assert.Empty(factory.Door.Calls);
}
// Kestrel normalises Request.Path but leaves RawTarget as received. The desk parses RawTarget, so a dot
// segment can never be folded into a configured Mind's route.
[Fact]
public async Task Dot_segments_never_reach_the_route()
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
await using var app = builder.Build();
app.Run(async http =>
{
var raw = http.Features.Get<IHttpRequestFeature>()!.RawTarget;
var text = $"{http.Request.Path}|{raw}";
http.Response.ContentLength = text.Length;
await http.Response.WriteAsync(text);
});
await app.StartAsync();
var address = app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
var port = new Uri(address).Port;
using var client = new TcpClient();
await client.ConnectAsync("127.0.0.1", port);
var stream = client.GetStream();
await stream.WriteAsync(Encoding.ASCII.GetBytes("GET /api/desk/ghost/../gamecult-chat/answers HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"));
using var reader = new StreamReader(stream, Encoding.ASCII);
var text = await reader.ReadToEndAsync();
var echoed = text[(text.LastIndexOf("\r\n\r\n", StringComparison.Ordinal) + 4)..].Trim();
var parts = echoed.Split('|');
Assert.Equal("/api/desk/gamecult-chat/answers", parts[0]);
Assert.Equal("/api/desk/ghost/../gamecult-chat/answers", parts[1]);
Assert.Null(DeskRoute.Parse("POST", parts[1]));
Assert.NotNull(DeskRoute.Parse("POST", parts[0]));
}
}

View file

@ -0,0 +1,18 @@
using System.Security.Cryptography;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
public sealed class DeskVectorPinTests
{
[Fact]
public void Vectors_are_cultlib_89755b08()
{
var sha = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(DeskVectors.Path))).ToLowerInvariant();
Assert.True(
DeskVectors.FileSha256 == sha,
$"fork-card-vectors.json is not CultLib {DeskVectors.SourceCommit}:contracts/cultnet/governance/fork-card-vectors.json (sha256 {sha}). " +
"Re-vendor from that commit or move the pin on purpose, with the whole contract.");
}
}

File diff suppressed because one or more lines are too long

View file

@ -362,7 +362,8 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
MintPermits = mintPermits,
MintWindow = mintWindow ?? TimeSpan.FromHours(1),
MaxTrackedKeys = maxTrackedKeys
})).Validate();
}),
Options.Create(new DeskOptions())).Validate();
}
private sealed class StubHostEnvironment(string environmentName) : IHostEnvironment

View file

@ -0,0 +1,75 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
namespace Bifrost.Web.Tests.Support;
// Shared plumbing for replaying CultLib's transport vectors through HTTP.
public static class DeskReplay
{
public const string ChatFork = "gamecult-chat:question:slot-mark-consent";
private static JsonNode BaseCard => DeskVectors.Root["scenarios"]![0]!["card"]!;
public static ForkCard Card(string forkId, string title = "Slot mark consent")
{
var card = JsonNode.Parse(BaseCard.ToJsonString())!.AsObject();
card["fork_id"] = forkId;
card["title"] = title;
return card.Deserialize<ForkCard>(DeskJson.Options)!;
}
public static ForkEntry Entry(ForkCard card) => new(
"gamecult.fork.entry.v0",
card,
new ForkRecord("gamecult.fork.record.v0", card.ForkId, card.OptionsHash, null, []));
// Every fork the vectors say each Mind holds, on that Mind's socket.
public static void HoldVectorForks(FakeDeskDoor door)
{
var forks = DeskVectors.Root["transport"]!["mindRoutes"]!["forks"]!.AsObject();
foreach (var (mind, ids) in forks)
{
foreach (var id in ids!.AsArray())
{
door.Forks[(DeskWebApplicationFactory.SocketOf(mind), (string)id!)] = Entry(Card((string)id!));
}
}
}
public static byte[] AnswerBody(string forkId, string requestId = "00000001-0000-4000-8000-000000000001")
{
var body = JsonNode.Parse(DeskVectors.Root["scenarios"]![0]!["steps"]![0]!["body"]!.ToJsonString())!.AsObject();
body["fork_id"] = forkId;
body["request_id"] = requestId;
return DeskVectors.Bytes(body);
}
// The raw segment as it would travel in one path segment: a literal '/' cannot, so it is sent as %2F.
public static string SegmentOnWire(string rawMind) => rawMind.Replace("/", "%2F", StringComparison.Ordinal);
public static string Target(string route, string rawMind, string? forkId) => route switch
{
"answer" => $"/api/desk/{SegmentOnWire(rawMind)}/answers",
"list" => $"/api/desk/{SegmentOnWire(rawMind)}/forks",
"read" => $"/api/desk/{SegmentOnWire(rawMind)}/forks/{Uri.EscapeDataString(forkId!)}",
_ => throw new ArgumentOutOfRangeException(nameof(route)),
};
// Seeds the account the vector token stands for and returns the header value (null for no token).
public static async Task<string?> TokenAsync(DeskWebApplicationFactory factory, JsonNode? token)
{
if (token is null)
{
return null;
}
var active = (bool)token["active"]!;
var subject = active ? DeskWebApplicationFactory.Alice : DeskWebApplicationFactory.Member;
await factory.EnsureAccountAsync(subject, active);
return factory.Token(subject, (string)token["audience"]!, token["scopes"]!.AsArray().Select(s => (string)s!).ToArray());
}
public static bool IsEdgeRefusal(string code) =>
code is "unauthenticated" or "insufficient_scope" or "forbidden" or "body_forbidden_field" or "body_unknown_field" or "body_invalid";
}

View file

@ -0,0 +1,50 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Bifrost.Web.Tests.Support;
// CultLib's fork-card vectors, vendored at commit 89755b08b5dd4bf72ed8e5907b5ff4717e51da12.
public static class DeskVectors
{
public const string SourceCommit = "89755b08b5dd4bf72ed8e5907b5ff4717e51da12";
public const string FileSha256 = "3387debd16059621202b706761519231e7ad52d0d4bd071a32b80c0d916e42fa";
public static string Path => System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", "fork-card-vectors.json");
public static JsonNode Root { get; } = JsonNode.Parse(File.ReadAllBytes(Path))!;
public static JsonArray Array(params string[] path)
{
JsonNode node = Root;
foreach (var step in path)
{
node = node[step]!;
}
return node.AsArray();
}
public static IEnumerable<JsonObject> Rows(params string[] path) => Array(path).Select(row => row!.AsObject());
// True when both are the same JSON with the same keys in the same order.
public static bool SameShape(JsonNode? left, JsonNode? right)
{
switch (left, right)
{
case (null, null):
return true;
case (JsonObject a, JsonObject b):
return a.Select(p => p.Key).SequenceEqual(b.Select(p => p.Key)) &&
a.All(p => SameShape(p.Value, b[p.Key]));
case (JsonArray a, JsonArray b):
return a.Count == b.Count && a.Zip(b).All(pair => SameShape(pair.First, pair.Second));
case (JsonValue, JsonValue):
return JsonNode.DeepEquals(left, right);
default:
return false;
}
}
public static byte[] Bytes(JsonNode node) => System.Text.Encoding.UTF8.GetBytes(node.ToJsonString());
}

View file

@ -0,0 +1,182 @@
using System.Text;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Desk;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Tests.Support;
public sealed record DeskReply(int Status, string ContentType, byte[] Body, IHeaderDictionary Headers)
{
public string Text => Encoding.UTF8.GetString(Body);
}
// The app with the Desk section configured and the two ports faked. `Configuration` is the Desk section as
// key/value pairs; null leaves the Desk section out, `RegisterPorts` false leaves the ports unregistered.
public sealed class DeskWebApplicationFactory : TestWebApplicationFactory
{
public const string Alice = "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f";
public const string Steward = "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a";
public const string Member = "11111111-2222-4333-8444-555555555555";
public const string Origin = "https://chat.gamecult.org";
private readonly Dictionary<string, string?>? configuration;
private readonly bool registerPorts;
public DeskWebApplicationFactory(Dictionary<string, string?>? configuration = null, bool configured = true, bool registerPorts = true)
{
this.configuration = configured ? configuration ?? DefaultConfiguration() : null;
this.registerPorts = registerPorts;
}
public FakeDeskDoor Door { get; } = new();
public FakeDeskCredential Credential { get; } = new();
public static Dictionary<string, string?> DefaultConfiguration()
{
var values = new Dictionary<string, string?>
{
["Desk:AllowedOrigin"] = Origin,
["Desk:RetryAfterSeconds"] = "30",
["Desk:Attribution:Operator"] = Alice,
["Desk:Attribution:Stewards:0"] = Steward,
};
var i = 0;
foreach (var mind in DeskVectors.Root["transport"]!["mindRoutes"]!["minds"]!.AsObject())
{
values[$"Desk:Minds:{i}:Id"] = mind.Key;
values[$"Desk:Minds:{i}:Socket"] = SocketOf(mind.Key);
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]!;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
if (configuration is not null)
{
foreach (var (key, value) in configuration)
{
builder.UseSetting(key, value);
}
}
if (registerPorts)
{
builder.ConfigureServices(services =>
{
services.AddSingleton<IDeskCredential>(Credential);
services.AddSingleton<IDeskDoor>(Door);
});
}
}
// Upserts the account with an Active (or Suspended) membership.
public async Task EnsureAccountAsync(string id, bool active)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var guid = Guid.Parse(id);
var account = await db.UserAccounts.FindAsync(guid);
if (account is null)
{
account = new UserAccount { Id = guid, GitHubLogin = "desk-" + id[..8], NormalizedGitHubLogin = "desk-" + id[..8], BifrostIdentity = "desk-" + id, NormalizedBifrostIdentity = "desk-" + id };
db.UserAccounts.Add(account);
db.Memberships.Add(new Domain.Membership { UserAccountId = guid, Status = active ? MembershipStatus.Active : MembershipStatus.Suspended });
}
else
{
var membership = db.Memberships.Single(m => m.UserAccountId == guid);
membership.Status = active ? MembershipStatus.Active : MembershipStatus.Suspended;
}
await db.SaveChangesAsync();
}
public string Token(string? subject, string? audience, params string[] scopes) =>
FakeDeskCredential.Encode(subject, audience, scopes);
// What routing would see for a raw path. Kestrel refuses a NUL or a malformed escape before routing; the
// placeholder keeps the request inside /api/desk so the handler's own judgment is what the test observes.
private static PathString RoutedPath(string rawPath)
{
var schemeEnd = rawPath.IndexOf("://", StringComparison.Ordinal);
if (schemeEnd > 0)
{
var pathStart = rawPath.IndexOf('/', schemeEnd + 3);
rawPath = pathStart < 0 ? "/" : rawPath[pathStart..];
}
try
{
return PathString.FromUriComponent(rawPath);
}
catch (Exception)
{
return new PathString("/api/desk/_");
}
}
// Sends one request with the RAW target set exactly as given (TestServer's HttpClient sets none). `path` is
// what routing sees; production Kestrel would hand over the decoded path the same way.
public async Task<DeskReply> SendAsync(
string method, string rawTarget, byte[]? body = null, string? token = null, string? origin = null,
Action<HttpContext>? configure = null)
{
var context = await Server.SendAsync(http =>
{
http.Request.Method = method;
http.Request.Scheme = "https";
http.Request.Host = new HostString("localhost");
var query = rawTarget.IndexOf('?');
var rawPath = query >= 0 ? rawTarget[..query] : rawTarget;
http.Request.Path = RoutedPath(rawPath);
http.Request.QueryString = query >= 0 ? new QueryString(rawTarget[query..]) : QueryString.Empty;
http.Features.GetRequiredFeature<IHttpRequestFeature>().RawTarget = rawTarget;
if (token is not null)
{
http.Request.Headers[FakeDeskCredential.Header] = token;
}
if (origin is not null)
{
http.Request.Headers.Origin = origin;
}
if (body is not null)
{
http.Request.Body = new MemoryStream(body);
http.Request.ContentLength = body.Length;
http.Request.ContentType = "application/json";
}
configure?.Invoke(http);
});
using var buffer = new MemoryStream();
await context.Response.Body.CopyToAsync(buffer);
return new DeskReply(context.Response.StatusCode, context.Response.ContentType ?? string.Empty, buffer.ToArray(), context.Response.Headers);
}
}

View file

@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Http;
using System.Text.Json;
using Bifrost.Web.Features.Desk;
namespace Bifrost.Web.Tests.Support;
// A header-driven stand-in for the bearer validation port. X-Test-Desk-Token carries {sub, aud, scopes}; no header
// means no valid token. It validates nothing: the desk's own judgments (audience, scope, membership) are the subject.
public sealed class FakeDeskCredential : IDeskCredential
{
public const string Header = "X-Test-Desk-Token";
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);
}
using var document = JsonDocument.Parse(raw.ToString());
var root = document.RootElement;
return ValueTask.FromResult<DeskToken?>(new DeskToken(
root.GetProperty("sub").GetString(),
root.GetProperty("aud").GetString(),
root.GetProperty("scopes").EnumerateArray().Select(s => s.GetString()!).ToArray()));
}
}

View file

@ -0,0 +1,125 @@
using System.Text.Json.Nodes;
using Bifrost.Web.Features.Desk;
namespace Bifrost.Web.Tests.Support;
public sealed record DeskDoorCall(string Kind, string Socket, DeskDoorContext Context, byte[]? Body, string? ForkId, DeskListRequest? List = null);
// The door as a test double: it records every call, holds the forks each socket's Mind holds, and by default stores
// an answer to a held fork as Counted. A test may script the outcome or make the door fail before or after storing.
public sealed class FakeDeskDoor : IDeskDoor
{
private readonly object gate = new();
public List<DeskDoorCall> Calls { get; } = [];
public List<string> StoredIn { get; } = [];
public Dictionary<(string Socket, string ForkId), ForkEntry> Forks { get; } = [];
public Func<string, DeskDoorContext, byte[], DeskAnswerOutcome>? AnswerScript { get; set; }
// Every call throws (Huginn is down).
public bool Down { get; set; }
// The answer is stored, then the door throws (a timeout after send).
public bool ThrowAfterStore { get; set; }
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(
string socket, DeskDoorContext context, DeskListRequest request, CancellationToken cancellationToken)
{
lock (gate)
{
Calls.Add(new DeskDoorCall("list", socket, context, null, null, request));
}
if (Down)
{
throw new IOException("huginn is down");
}
return Task.FromResult(ListScript?.Invoke(socket, request)
?? new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", request.Mind, "s1", 0, 0, [], null)));
}
public Task<DeskAnswerOutcome> AnswerAsync(
string socket, DeskDoorContext context, ReadOnlyMemory<byte> body, CancellationToken cancellationToken)
{
var bytes = body.ToArray();
lock (gate)
{
Calls.Add(new DeskDoorCall("answer", socket, context, bytes, null));
}
if (Down)
{
throw new IOException("huginn is down");
}
var outcome = AnswerScript?.Invoke(socket, context, bytes) ?? HeldForkOutcome(socket, context, bytes);
if (outcome is DeskAnswerOutcome.Answered)
{
lock (gate)
{
StoredIn.Add(socket);
}
}
if (ThrowAfterStore)
{
throw new TimeoutException("the door timed out after send");
}
return Task.FromResult(outcome);
}
public Task<DeskReadOutcome> ReadAsync(
string socket, DeskDoorContext context, string forkId, CancellationToken cancellationToken)
{
lock (gate)
{
Calls.Add(new DeskDoorCall("read", socket, context, null, forkId));
}
if (Down || ThrowOnRead)
{
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));
}
private DeskAnswerOutcome HeldForkOutcome(string socket, DeskDoorContext context, byte[] body)
{
var request = JsonNode.Parse(body)!.AsObject();
var forkId = (string)request["fork_id"]!;
if (!Forks.ContainsKey((socket, forkId)))
{
return new DeskAnswerOutcome.NotStored(DeskRefusals.ForkUnknown);
}
var answer = request["answer"]?.AsObject();
return new DeskAnswerOutcome.Answered(new AnswerResult(
DeskSchemas.AnswerResult,
new DeskAnswerDocument(
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.Authority, (string)request["request_id"]!),
null,
"n1"));
}
}