diff --git a/CLAUDE.md b/CLAUDE.md
index 7df35c21..97cc8b09 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -127,3 +127,10 @@ without a type.
Handlers using `RequireAuthedUsername` differ from those calling `ValidateCode`
directly only in that the former sends the 401 for you - the validation is the
same, so it is never the explanation for one route failing while another passes.
+
+A 401 from `ValidateCode` is always a verdict from the chain's answer (no such
+account, a key that does not match, a malformed or non-session code). When the
+keys could not be read at all (node error, timeout, an unusable answer), it
+throws `TokenCheckUnavailableException` and `ServerErrors` answers 503
+"Service Unavailable" with `Retry-After`, so clients do not take a node outage
+for a refused session. Do not catch that exception in a handler.
diff --git a/dotnet/EcencyApi.Tests/TokenCheckUnavailableTests.cs b/dotnet/EcencyApi.Tests/TokenCheckUnavailableTests.cs
new file mode 100644
index 00000000..0627b5c0
--- /dev/null
+++ b/dotnet/EcencyApi.Tests/TokenCheckUnavailableTests.cs
@@ -0,0 +1,390 @@
+using System.Text;
+using System.Text.Json.Nodes;
+using EcencyApi.Handlers;
+using EcencyApi.Infrastructure;
+using Microsoft.AspNetCore.Http;
+using NBitcoin.Secp256k1;
+using Xunit;
+
+namespace EcencyApi.Tests;
+
+///
+/// A session code whose keys could not be read is a server error, not a refused
+/// session: the request answers 503 through the pipeline and the handler goes no
+/// further. A code the chain does answer for (unknown account, wrong key) is
+/// still a 401. Clients sign the user out on 401, so a node outage answered as
+/// 401 signed everyone out.
+///
+[Collection("session-token")]
+public class TokenCheckUnavailableTests : IDisposable
+{
+ private static readonly ECPrivKey Key = HiveCrypto.FromLogin("alice", "test-password");
+ private static readonly string Pub = HiveCrypto.PublicKeyFromLogin("alice", "test-password");
+ private static readonly ECPrivKey HsKey = HiveCrypto.FromLogin("hivesigner", "test-password");
+ private static readonly string HsPub = HiveCrypto.PublicKeyFromLogin("hivesigner", "test-password");
+ private static readonly ECPrivKey OtherKey = HiveCrypto.FromLogin("mallory", "test-password");
+
+ public TokenCheckUnavailableTests() => PrivateApi.ResetHivesignerCache();
+
+ public void Dispose()
+ {
+ PrivateApi.ValidationAccounts = names => HiveClients.Default.GetAccounts(names);
+ PrivateApi.ResetHivesignerCache();
+ }
+
+ private static JsonObject Account(string name, string pub) => new()
+ {
+ ["name"] = name,
+ ["posting"] = new JsonObject { ["key_auths"] = new JsonArray(new JsonArray(pub, 1)) },
+ };
+
+ private static JsonArray Known(string? name) =>
+ new(Account(name!, name == "hivesigner" ? HsPub : Pub));
+
+ private static string Code(ECPrivKey key, string author = "alice")
+ {
+ var message = new JsonObject
+ {
+ ["signed_message"] = new JsonObject { ["type"] = "code", ["app"] = "ecency.app" },
+ ["authors"] = new JsonArray(author),
+ ["timestamp"] = 1726650000,
+ };
+ var digest = HiveCrypto.Sha256Utf8(JsJson.Stringify(message));
+ message["signatures"] = new JsonArray(HiveCrypto.Sign(key, digest));
+ return B64u.Encode(JsJson.Stringify(message));
+ }
+
+ private static async Task<(int Status, string Body, string RetryAfter)> Run(
+ RequestDelegate handler, JsonObject body)
+ {
+ var ctx = new DefaultHttpContext();
+ var bytes = Encoding.UTF8.GetBytes(body.ToJsonString());
+ ctx.Request.Method = "POST";
+ ctx.Request.ContentType = "application/json";
+ ctx.Request.Body = new MemoryStream(bytes);
+ ctx.Request.ContentLength = bytes.Length;
+ ctx.Response.Body = new MemoryStream();
+
+ await ServerErrors.Handle(ctx, () => handler(ctx));
+
+ ctx.Response.Body.Position = 0;
+ var text = await new StreamReader(ctx.Response.Body).ReadToEndAsync();
+ return (ctx.Response.StatusCode, text, ctx.Response.Headers.RetryAfter.ToString());
+ }
+
+ private static Task<(int Status, string Body, string RetryAfter)> Unread(ECPrivKey key) =>
+ Run(PrivateApi.UnreadNotifications, new JsonObject { ["code"] = Code(key) });
+
+ // A spend: reaching the upstream would charge Points.
+ private static Task<(int Status, string Body, string RetryAfter)> StreakFreezeBuy(ECPrivKey key) =>
+ Run(PrivateApi.StreakFreezeBuy,
+ new JsonObject { ["code"] = Code(key), ["idempotency_key"] = "k-1" });
+
+ private static void AssertUnavailable((int Status, string Body, string RetryAfter) r)
+ {
+ Assert.Equal(503, r.Status);
+ // Only the 503 was written: the handler stopped before any upstream call.
+ Assert.Equal("Service Unavailable", r.Body);
+ Assert.Equal(ServerErrors.RetryAfterSeconds.ToString(), r.RetryAfter);
+ }
+
+ [Fact]
+ public async Task ANodeFailureIsUnavailableNotUnauthorized()
+ {
+ PrivateApi.ValidationAccounts = _ => throw new HttpRequestException("node down");
+
+ AssertUnavailable(await Unread(Key));
+ AssertUnavailable(await StreakFreezeBuy(Key));
+ }
+
+ [Fact]
+ public async Task ATimeoutIsUnavailable()
+ {
+ PrivateApi.ValidationAccounts = _ => throw new TaskCanceledException("timed out");
+
+ AssertUnavailable(await Unread(Key));
+ }
+
+ [Fact]
+ public async Task ANonIterableAnswerIsUnavailable()
+ {
+ PrivateApi.ValidationAccounts = _ => Task.FromResult(null);
+
+ AssertUnavailable(await Unread(Key));
+ AssertUnavailable(await StreakFreezeBuy(Key));
+ }
+
+ [Fact]
+ public async Task AnAccountWithoutPostingKeysIsAGarbageAnswer()
+ {
+ PrivateApi.ValidationAccounts = _ =>
+ Task.FromResult(new JsonArray(new JsonObject { ["name"] = "alice" }));
+
+ AssertUnavailable(await Unread(Key));
+ }
+
+ [Fact]
+ public async Task AFailedHivesignerReadWithNothingCachedIsUnavailable()
+ {
+ // alice's own key does not match, so @hivesigner's keys decide; they cannot be read.
+ PrivateApi.ValidationAccounts = names =>
+ names.Single() == "hivesigner"
+ ? throw new HttpRequestException("node down")
+ : Task.FromResult(Known("alice"));
+
+ AssertUnavailable(await Unread(HsKey));
+ AssertUnavailable(await StreakFreezeBuy(HsKey));
+ }
+
+ [Fact]
+ public async Task AGarbageHivesignerAnswerIsUnavailableAndNotCached()
+ {
+ PrivateApi.ValidationAccounts = names =>
+ Task.FromResult(names.Single() == "hivesigner"
+ ? new JsonArray(new JsonObject { ["name"] = "hivesigner" })
+ : Known("alice"));
+ AssertUnavailable(await Unread(HsKey));
+
+ // The node recovers: the next check reads @hivesigner again.
+ PrivateApi.ValidationAccounts = names => Task.FromResult(Known(names.Single()));
+ Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) }));
+ }
+
+ [Fact]
+ public async Task AFailedHivesignerRefreshKeepsTheStaleKeys()
+ {
+ PrivateApi.ValidationAccounts = names => Task.FromResult(Known(names.Single()));
+ Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) }));
+
+ // Due for refresh, still inside the stale grace.
+ PrivateApi.AgeHivesignerCache(PrivateApi.HiveSignerStaleMaxMs - 60_000);
+ PrivateApi.ValidationAccounts = names =>
+ names.Single() == "hivesigner"
+ ? throw new HttpRequestException("node down")
+ : Task.FromResult(Known("alice"));
+
+ Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) }));
+ }
+
+ private static Func, Task> HivesignerDown => names =>
+ names.Single() == "hivesigner"
+ ? throw new HttpRequestException("node down")
+ : Task.FromResult(Known("alice"));
+
+ [Fact]
+ public async Task AMismatchAgainstStaleKeysAfterAFailedRefreshIsUnavailable()
+ {
+ // Cached @hivesigner keys that no longer sign (HiveSigner rotated its key).
+ PrivateApi.ValidationAccounts = names =>
+ Task.FromResult(names.Single() == "hivesigner"
+ ? new JsonArray(Account("hivesigner", Pub.Replace(Pub[^1], Pub[^1] == 'A' ? 'B' : 'A')))
+ : Known("alice"));
+ Assert.Null(await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) }));
+
+ // The refresh that would have read the new key fails: no verdict either way.
+ PrivateApi.AgeHivesignerCache(PrivateApi.HiveSignerStaleMaxMs - 60_000);
+ PrivateApi.ValidationAccounts = HivesignerDown;
+ AssertUnavailable(await Unread(HsKey));
+ }
+
+ [Fact]
+ public async Task StaleHivesignerKeysPastTheGraceAreNotUsed()
+ {
+ PrivateApi.ValidationAccounts = names => Task.FromResult(Known(names.Single()));
+ Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) }));
+
+ PrivateApi.AgeHivesignerCache(PrivateApi.HiveSignerStaleMaxMs + 60_000);
+ PrivateApi.ValidationAccounts = HivesignerDown;
+ AssertUnavailable(await Unread(HsKey));
+ }
+
+ [Theory]
+ [InlineData("[[123, 1]]")]
+ [InlineData("[[\"\", 1]]")]
+ [InlineData("[[null, 1]]")]
+ public async Task AnAccountKeyThatIsNotAKeyIsAGarbageAnswer(string keyAuths)
+ {
+ PrivateApi.ValidationAccounts = _ => Task.FromResult(new JsonArray(new JsonObject
+ {
+ ["name"] = "alice",
+ ["posting"] = new JsonObject { ["key_auths"] = JsonNode.Parse(keyAuths) },
+ }));
+
+ AssertUnavailable(await Unread(Key));
+ }
+
+ [Theory]
+ [InlineData("[[123, 1]]")]
+ [InlineData("[]")]
+ public async Task AnUnusableHivesignerKeyListIsUnavailableAndNotCached(string keyAuths)
+ {
+ PrivateApi.ValidationAccounts = names =>
+ Task.FromResult(names.Single() == "hivesigner"
+ ? new JsonArray(new JsonObject
+ {
+ ["name"] = "hivesigner",
+ ["posting"] = new JsonObject { ["key_auths"] = JsonNode.Parse(keyAuths) },
+ })
+ : Known("alice"));
+ AssertUnavailable(await Unread(HsKey));
+
+ PrivateApi.ValidationAccounts = names => Task.FromResult(Known(names.Single()));
+ Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) }));
+ }
+
+ [Fact]
+ public async Task AnAccountWhosePostingIsHeldByAccountsOnlyIsStillChecked()
+ {
+ // No posting keys of its own is a real account shape (account_auths only),
+ // not a garbage answer: @hivesigner's keys still decide.
+ PrivateApi.ValidationAccounts = names =>
+ Task.FromResult(names.Single() == "hivesigner"
+ ? Known("hivesigner")
+ : new JsonArray(new JsonObject
+ {
+ ["name"] = "alice",
+ ["posting"] = new JsonObject { ["key_auths"] = new JsonArray() },
+ }));
+
+ Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) }));
+ Assert.Equal((401, "Unauthorized", ""), await Unread(OtherKey));
+ }
+
+ [Fact]
+ public void TheUnavailableLogNamesTheCauseButNotTheToken()
+ {
+ var timeout = PrivateApi.UnavailableLog("account read failed", new TaskCanceledException("timed out"));
+ var transport = PrivateApi.UnavailableLog("account read failed", new HttpRequestException("refused"));
+
+ Assert.Contains("TaskCanceledException", timeout.Line);
+ Assert.Contains("timed out", timeout.Line);
+ Assert.Contains("HttpRequestException", transport.Line);
+ // One line per reason and cause type, however the messages vary.
+ Assert.NotEqual(timeout.Key, transport.Key);
+ Assert.Equal(transport.Key,
+ PrivateApi.UnavailableLog("account read failed", new HttpRequestException("other")).Key);
+ }
+
+ [Fact]
+ public async Task AWrongSignatureIsStillUnauthorized()
+ {
+ PrivateApi.ValidationAccounts = names => Task.FromResult(Known(names.Single()));
+
+ Assert.Equal((401, "Unauthorized", ""), await Unread(OtherKey));
+ Assert.Equal((401, "Unauthorized", ""), await StreakFreezeBuy(OtherKey));
+ }
+
+ [Fact]
+ public async Task AnUnknownAccountIsStillUnauthorized()
+ {
+ // The chain answered: there is no such account.
+ PrivateApi.ValidationAccounts = _ => Task.FromResult(new JsonArray());
+
+ Assert.Equal((401, "Unauthorized", ""), await Unread(Key));
+ Assert.Equal((401, "Unauthorized", ""), await StreakFreezeBuy(Key));
+ }
+
+ [Theory]
+ [InlineData("Internal Error (-32603)")]
+ [InlineData("Unable to acquire database lock (-32000)")]
+ public async Task AnRpcErrorForAWellFormedNameIsUnavailable(string message)
+ {
+ // A node can answer an internal error for a valid read: no verdict.
+ PrivateApi.ValidationAccounts = _ => throw new HiveRpcClient.RpcException(message);
+
+ AssertUnavailable(await Unread(Key));
+ AssertUnavailable(await StreakFreezeBuy(Key));
+ }
+
+ [Fact]
+ public async Task AnRpcErrorReadingHivesignerIsUnavailable()
+ {
+ PrivateApi.ValidationAccounts = names =>
+ names.Single() == "hivesigner"
+ ? throw new HiveRpcClient.RpcException("Internal Error (-32603)")
+ : Task.FromResult(Known("alice"));
+
+ AssertUnavailable(await Unread(HsKey));
+ }
+
+ [Theory]
+ [InlineData("Alice")]
+ [InlineData("al ice")]
+ [InlineData("al_ice")]
+ [InlineData("ab")]
+ [InlineData("averyveryverylongname")]
+ [InlineData("")]
+ public async Task AMalformedNameIsUnauthorizedWithoutAChainRead(string author)
+ {
+ var reads = 0;
+ PrivateApi.ValidationAccounts = _ =>
+ {
+ reads++;
+ throw new HiveRpcClient.RpcException("Internal Error (-32603)");
+ };
+
+ Assert.Equal((401, "Unauthorized", ""),
+ await Run(PrivateApi.UnreadNotifications, new JsonObject { ["code"] = Code(Key, author) }));
+ Assert.Equal(0, reads);
+ }
+
+ [Theory]
+ [InlineData("a.b-1")]
+ [InlineData("1ab")]
+ [InlineData("abc-")]
+ public async Task ALenientlyWellFormedNameIsRead(string author)
+ {
+ // No segment rules: an old name that today's rules refuse is still looked up.
+ var reads = new List();
+ PrivateApi.ValidationAccounts = names =>
+ {
+ reads.AddRange(names);
+ return Task.FromResult(new JsonArray());
+ };
+
+ Assert.Null(await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(Key, author) }));
+ Assert.Equal(new[] { author }, reads);
+ }
+
+ [Theory]
+ [InlineData("[]")]
+ [InlineData("[null]")]
+ public async Task AHivesignerAnswerWithoutTheAccountIsUnavailableAndNotCached(string answer)
+ {
+ PrivateApi.ValidationAccounts = names =>
+ Task.FromResult(names.Single() == "hivesigner"
+ ? (JsonArray)JsonNode.Parse(answer)!
+ : Known("alice"));
+ AssertUnavailable(await Unread(HsKey));
+
+ PrivateApi.ValidationAccounts = names => Task.FromResult(Known(names.Single()));
+ Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) }));
+ }
+
+ [Theory]
+ [InlineData("[]")]
+ [InlineData("[null]")]
+ public async Task AHivesignerAnswerWithoutTheAccountKeepsTheCachedKeys(string answer)
+ {
+ PrivateApi.ValidationAccounts = names => Task.FromResult(Known(names.Single()));
+ Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) }));
+
+ PrivateApi.AgeHivesignerCache(PrivateApi.HiveSignerStaleMaxMs - 60_000);
+ PrivateApi.ValidationAccounts = names =>
+ Task.FromResult(names.Single() == "hivesigner"
+ ? (JsonArray)JsonNode.Parse(answer)!
+ : Known("alice"));
+ Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) }));
+ // Still held for the next check.
+ Assert.Equal("alice", await PrivateApi.ValidateCode(new JsonObject { ["code"] = Code(HsKey) }));
+ }
+
+ [Fact]
+ public async Task AMalformedCodeIsStillUnauthorized()
+ {
+ PrivateApi.ValidationAccounts = _ => throw new HttpRequestException("never read");
+
+ Assert.Equal((401, "Unauthorized", ""),
+ await Run(PrivateApi.UnreadNotifications, new JsonObject { ["code"] = "not-a-token" }));
+ }
+}
diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs
index 6471e25e..f464d33b 100644
--- a/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs
+++ b/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs
@@ -149,8 +149,17 @@ timestampNode is JsonValue timestampValue
var digest = HiveCrypto.Sha256Utf8(rawMessage);
var recoveredPubKey = HiveCrypto.RecoverPublicKey(signature, digest);
- var accounts = await ValidationAccounts(new[] { author })
- ?? throw new InvalidOperationException("getAccounts result is not iterable");
+ // A read that fails (node error, timeout, unusable answer) is not a
+ // verdict on the code: it throws TokenCheckUnavailableException, which
+ // the pipeline answers 503 (ServerErrors) instead of the 401 that tells
+ // clients their session was refused. Only the chain's answer decides,
+ // so a name that can never be an account is refused without a read.
+ if (!IsAccountNameShape(author))
+ {
+ LogOnce("Token author is not an account name");
+ return null;
+ }
+ var accounts = await ReadValidationAccounts(author);
var account = accounts.Count > 0 ? accounts[0] : null;
if (account is null)
{
@@ -158,31 +167,46 @@ timestampNode is JsonValue timestampValue
return null;
}
- // account.posting.key_auths.map(([key]) => key) — unexpected shapes
- // throw and land in the catch below, like the TS TypeError would.
- var postingPubKeys = MapKeyAuths(account["posting"]!["key_auths"]);
+ var postingPubKeys = PostingKeys(account);
if (recoveredPubKey != null && postingPubKeys.Contains(recoveredPubKey))
{
return author;
}
// Use cached hivesigner account, refresh every 24h
+ var hsRefreshFailed = false;
if (_hivesignerAccountCache is null
|| currentTime - _hivesignerCacheTime > HiveSignerCacheTtlMs)
{
try
{
- var hsAccounts = await ValidationAccounts(new[] { "hivesigner" })
- ?? throw new InvalidOperationException("getAccounts result is not iterable");
- // TS caches whatever destructures out — undefined included —
- // and stamps the time either way.
- _hivesignerAccountCache = hsAccounts.Count > 0 ? hsAccounts[0] : null;
+ var hsAccounts = await ReadValidationAccounts("hivesigner");
+ var hsAccount = hsAccounts.Count > 0 ? hsAccounts[0] : null;
+ // An unusable answer is not cached for a day. @hivesigner exists
+ // and signs with its posting key, so an answer without the
+ // account or with an empty key list is unusable too.
+ if (hsAccount is null)
+ {
+ throw Unavailable("hivesigner answer without the account");
+ }
+ if (PostingKeys(hsAccount).Count == 0)
+ {
+ throw Unavailable("hivesigner answer without posting keys");
+ }
+ // Only a usable answer is cached (TS cached whatever came back,
+ // undefined included).
+ _hivesignerAccountCache = hsAccount;
_hivesignerCacheTime = currentTime;
}
- catch (Exception err)
+ catch (TokenCheckUnavailableException)
+ when (_hivesignerAccountCache is not null
+ && currentTime - _hivesignerCacheTime <= HiveSignerStaleMaxMs)
{
- Console.Error.WriteLine($"Failed to fetch hivesigner account {err}");
- return null;
+ // A failed refresh keeps the keys already held (they change
+ // rarely) for a bounded time; the time is not stamped, so the
+ // next check retries. They may only accept: a mismatch below
+ // could be a key the failed read would have shown.
+ hsRefreshFailed = true;
}
}
@@ -195,17 +219,84 @@ timestampNode is JsonValue timestampValue
return author;
}
+ if (hsRefreshFailed)
+ {
+ throw Unavailable("hivesigner keys could not be refreshed");
+ }
+
Console.WriteLine(
$"Posting key mismatch {recoveredPubKey} [{string.Join(", ", postingPubKeys)}] [{string.Join(", ", hsPostingKeys)}]");
return null;
}
- catch (Exception err)
+ catch (Exception err) when (err is not TokenCheckUnavailableException)
{
Console.Error.WriteLine($"Token validation error {err}");
return null;
}
}
+ ///
+ /// The accounts read for ValidateCode, for a name of account shape. Any
+ /// failure, RPC-level errors included (a node can answer an internal error
+ /// for a valid read), and an answer that is not an array, means the keys
+ /// could not be read.
+ ///
+ private static async Task ReadValidationAccounts(string name)
+ {
+ JsonArray? accounts;
+ try
+ {
+ accounts = await ValidationAccounts(new[] { name });
+ }
+ catch (Exception err)
+ {
+ throw Unavailable("account read failed", err);
+ }
+
+ return accounts ?? throw Unavailable("account read answered no array");
+ }
+
+ ///
+ /// A shape every existing Hive account name has: 3 to 16 characters of a-z,
+ /// 0-9, '.' and '-'. Deliberately looser than HiveNames.IsAccountName (no
+ /// segment rules), so no older account is ever refused by it.
+ ///
+ internal static bool IsAccountNameShape(string name) =>
+ name.Length is >= 3 and <= 16
+ && name.All(c => c is >= 'a' and <= 'z' or >= '0' and <= '9' or '.' or '-');
+
+ ///
+ /// account.posting.key_auths.map(([key]) => key). Every account has a posting
+ /// authority, so an answer without a key list, or with an entry that is not a
+ /// key, is unusable, not a refusal. An empty list is a real account shape
+ /// (posting held by other accounts only) and is returned as is.
+ ///
+ private static List PostingKeys(JsonNode account)
+ {
+ List keys;
+ try
+ {
+ keys = MapKeyAuths(account["posting"]!["key_auths"]);
+ }
+ catch (Exception err)
+ {
+ throw Unavailable("account answer without posting keys", err);
+ }
+
+ if (keys.Any(string.IsNullOrEmpty))
+ {
+ throw Unavailable("account answer with a posting key that is not a key");
+ }
+ return keys;
+ }
+
+ private static TokenCheckUnavailableException Unavailable(string reason, Exception? inner = null)
+ {
+ var (key, line) = UnavailableLog(reason, inner);
+ LogOnce(key, line);
+ return new TokenCheckUnavailableException(reason, inner);
+ }
+
/// Forgets the cached @hivesigner account (tests).
internal static void ResetHivesignerCache()
{
@@ -213,6 +304,25 @@ internal static void ResetHivesignerCache()
_hivesignerCacheTime = 0;
}
+ /// Makes the cached @hivesigner account `ageMs` old, keeping it (tests).
+ internal static void AgeHivesignerCache(long ageMs) =>
+ _hivesignerCacheTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - ageMs;
+
+ /// How old cached @hivesigner keys may be when a refresh fails.
+ internal const long HiveSignerStaleMaxMs = 2 * HiveSignerCacheTtlMs;
+
+ ///
+ /// The log line for an unavailable check, and the key it is written once under:
+ /// the cause's type and message only, never the code or the request, once per
+ /// reason and cause type, since a node outage fails every request the same way.
+ ///
+ internal static (string Key, string Line) UnavailableLog(string reason, Exception? inner)
+ {
+ var type = inner?.GetType().Name ?? "-";
+ var cause = inner is null ? "" : $" ({type}: {inner.Message})";
+ return ($"Token check unavailable: {reason} [{type}]", $"Token check unavailable: {reason}{cause}");
+ }
+
/// The chain read behind token validation (tests answer it).
internal static Func, Task> ValidationAccounts =
names => HiveClients.Default.GetAccounts(names);
@@ -241,10 +351,12 @@ signedMessage is JsonObject obj
private static int _tokenLogLines;
private static readonly ConcurrentDictionary TokenLogSeen = new();
- private static void LogOnce(string line)
+ private static void LogOnce(string line) => LogOnce(line, line);
+
+ private static void LogOnce(string key, string line)
{
- if (Volatile.Read(ref _tokenLogLines) >= MaxTokenLogLines || TokenLogSeen.ContainsKey(line)) return;
- if (!TokenLogSeen.TryAdd(line, 0)) return;
+ if (Volatile.Read(ref _tokenLogLines) >= MaxTokenLogLines || TokenLogSeen.ContainsKey(key)) return;
+ if (!TokenLogSeen.TryAdd(key, 0)) return;
if (Interlocked.Increment(ref _tokenLogLines) > MaxTokenLogLines) return;
Console.WriteLine(line);
}
diff --git a/dotnet/EcencyApi/Infrastructure/ServerErrors.cs b/dotnet/EcencyApi/Infrastructure/ServerErrors.cs
new file mode 100644
index 00000000..ec18d7c7
--- /dev/null
+++ b/dotnet/EcencyApi/Infrastructure/ServerErrors.cs
@@ -0,0 +1,51 @@
+namespace EcencyApi.Infrastructure;
+
+///
+/// A session code could not be checked because the account keys it is checked
+/// against could not be read (node error, timeout, unusable answer). Not a
+/// verdict on the code: the request is answered 503, never 401, so a client
+/// does not take a passing node problem for a refused session and sign out.
+///
+public sealed class TokenCheckUnavailableException : Exception
+{
+ public TokenCheckUnavailableException(string message, Exception? inner = null)
+ : base(message, inner) { }
+}
+
+/// The request pipeline's error handling, wrapped around every route.
+public static class ServerErrors
+{
+ /// Seconds a client is asked to wait before retrying a 503.
+ public const int RetryAfterSeconds = 5;
+
+ public static async Task Handle(HttpContext ctx, Func next)
+ {
+ try
+ {
+ await next();
+ }
+ catch (TokenCheckUnavailableException)
+ {
+ // Quiet on purpose: during a node outage this fires per request, and
+ // ValidateCode already notes the cause once.
+ if (!ctx.Response.HasStarted)
+ {
+ ctx.Response.Headers.RetryAfter = RetryAfterSeconds.ToString();
+ await ctx.SendText(503, "Service Unavailable");
+ }
+ }
+ catch (Exception e)
+ {
+ // The Node app's error middleware sends a plain 500 "Server Error"
+ // for anything thrown by handlers (including body-parser
+ // SyntaxErrors), so mirror that exactly.
+ Console.Error.WriteLine($"Unhandled server error: {e}");
+ if (!ctx.Response.HasStarted)
+ {
+ ctx.Response.StatusCode = 500;
+ ctx.Response.ContentType = "text/html; charset=utf-8";
+ await ctx.Response.WriteAsync("Server Error");
+ }
+ }
+ }
+}
diff --git a/dotnet/EcencyApi/Program.cs b/dotnet/EcencyApi/Program.cs
index c1f614ec..62d85adc 100644
--- a/dotnet/EcencyApi/Program.cs
+++ b/dotnet/EcencyApi/Program.cs
@@ -53,26 +53,10 @@
$"PRIVATE_API_AUTH has no {ApiClient.IdentityHeader} header: backend calls that need this service's identity will be refused");
}
-// Global error handling — the Node app's error middleware sends a plain
-// 500 "Server Error" for anything thrown by handlers (including body-parser
-// SyntaxErrors), so mirror that exactly.
-app.Use(async (ctx, next) =>
-{
- try
- {
- await next();
- }
- catch (Exception e)
- {
- Console.Error.WriteLine($"Unhandled server error: {e}");
- if (!ctx.Response.HasStarted)
- {
- ctx.Response.StatusCode = 500;
- ctx.Response.ContentType = "text/html; charset=utf-8";
- await ctx.Response.WriteAsync("Server Error");
- }
- }
-});
+// Global error handling: a 503 for a session code that could not be checked,
+// and the Node app's plain 500 "Server Error" for anything else a handler
+// throws (see ServerErrors).
+app.Use(ServerErrors.Handle);
// cors() — allow-all, like the Node service
app.UseCors(policy => policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());
diff --git a/dotnet/parity/driver.py b/dotnet/parity/driver.py
index 1f42b3fa..781040c3 100644
--- a/dotnet/parity/driver.py
+++ b/dotnet/parity/driver.py
@@ -380,6 +380,14 @@ def norm_body(text):
# code, so that difference is covered by AiAssistHandlerTests rather than an entry
# here — an entry would skip status, content-type, and body and hide unrelated
# regressions on the route.
+#
+# Also not listed: the 503 "Service Unavailable" (with Retry-After) that every
+# route validating a session code answers when the account keys could not be read
+# (node error, timeout, unusable answer); the reference build answered 401. It
+# happens only when the chain is read and the read fails, and no catalog case
+# reaches that read (every catalog code is malformed and refused before it), so
+# every case still matches and an entry would only skip whole routes.
+# Covered by TokenCheckUnavailableTests instead.
def diff(a_name, b_name, loose_name=None):