14-task plan covering JWT auth (4 tasks), subject transforms (2 tasks), OCSP support (2 tasks), and quick wins (5 tasks) + differences.md update. Includes parallelization guide, TDD steps, and task persistence.
55 KiB
Remaining Lower-Priority Gaps Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Resolve all remaining lower-priority gaps from differences.md — JWT authentication, subject mapping, OCSP, and quick wins — achieving full Go parity for single-server deployments.
Architecture: JWT auth adds a new Auth/Jwt/ namespace with claim types, Ed25519 verification, account resolver, and permission template expansion. Subject transforms add a compiled rule engine in Subscriptions/. OCSP extends TLS wrapper with revocation checking and stapling. Quick wins are isolated additions to existing files.
Tech Stack: .NET 10 / C# 14, NATS.NKeys (Ed25519), System.Text.Json, xUnit 3 + Shouldly, Serilog
Worktree: /Users/dohertj2/Desktop/natsdotnet/.worktrees/feature-remaining-gaps (branch feature/remaining-gaps)
Baseline: 279 tests passing
Parallelization Guide
Batch 1 (all independent — new files only):
Task 1: JWT Core (Auth/Jwt/NatsJwt.cs, UserClaims.cs, AccountClaims.cs)
Task 3: AccountResolver (Auth/Jwt/AccountResolver.cs)
Task 5: SubjectTransform (Subscriptions/SubjectTransform.cs)
Task 7: OcspConfig (Tls/OcspConfig.cs)
Task 9: Windows Service (Program.cs, csproj)
Task 11: Per-Client Trace (ClientFlags.cs)
Task 13: TLS Cert Expiry (VarzHandler.cs)
Batch 2 (depends on Batch 1):
Task 2: Permission Templates (depends on Task 1)
Task 6: Wire SubjectTransform (depends on Task 5)
Task 8: OCSP Stapling (depends on Task 7)
Task 10: Per-Subsystem Log Control (Program.cs, NatsOptions.cs)
Task 12: Per-Account Stats (Account.cs, NatsServer.cs)
Batch 3:
Task 4: JwtAuthenticator (depends on Tasks 1, 2, 3)
Final:
Task 14: Update differences.md (depends on all)
Task 1: JWT Core — Decode/Verify + Claim Structs
Files:
- Create:
src/NATS.Server/Auth/Jwt/NatsJwt.cs - Create:
src/NATS.Server/Auth/Jwt/UserClaims.cs - Create:
src/NATS.Server/Auth/Jwt/AccountClaims.cs - Test:
tests/NATS.Server.Tests/JwtTests.cs
Context:
NATS JWTs are standard JWT format (base64url header.payload.signature) with Ed25519 signing. The nats-io/jwt/v2 Go library provides claim types. We port the core decode/verify logic inline — no external JWT NuGet needed. NATS.NKeys handles Ed25519 verification. All NATS JWTs start with "eyJ" (base64url for {" ).
Reference: Go auth.go:998-1012 for signature decoding, nats-io/jwt/v2 for claim types.
Step 1: Write failing tests for JWT decode
Create tests/NATS.Server.Tests/JwtTests.cs:
namespace NATS.Server.Tests;
using NATS.Server.Auth.Jwt;
public class JwtTests
{
[Fact]
public void Decode_splits_header_payload_signature()
{
// Build a minimal test JWT (header.payload.sig)
var header = Base64UrlEncode("{\"alg\":\"ed25519\",\"typ\":\"JWT\"}");
var payload = Base64UrlEncode("{\"sub\":\"UABC\",\"name\":\"test\",\"iat\":1700000000}");
var sig = Base64UrlEncode("fakesig");
var token = $"{header}.{payload}.{sig}";
var result = NatsJwt.Decode(token);
result.ShouldNotBeNull();
result.Header.Algorithm.ShouldBe("ed25519");
result.Header.Type.ShouldBe("JWT");
}
[Fact]
public void Decode_returns_null_for_invalid_token()
{
NatsJwt.Decode("not-a-jwt").ShouldBeNull();
NatsJwt.Decode("").ShouldBeNull();
NatsJwt.Decode("a.b").ShouldBeNull(); // only 2 parts
}
[Fact]
public void IsJwt_detects_eyJ_prefix()
{
NatsJwt.IsJwt("eyJhbGciOiJlZDI1NTE5In0.payload.sig").ShouldBeTrue();
NatsJwt.IsJwt("not-a-jwt").ShouldBeFalse();
NatsJwt.IsJwt("").ShouldBeFalse();
}
[Fact]
public void DecodeUserClaims_parses_nats_fields()
{
var claims = new
{
sub = "UABC123",
iss = "AABC456",
iat = 1700000000,
exp = 1800000000,
name = "testuser",
nats = new
{
pub = new { allow = new[] { "foo.>" }, deny = new[] { "foo.secret" } },
sub = new { allow = new[] { "bar.>" } },
resp = new { max = 5, ttl = 60000000000L }, // 60s in nanoseconds
bearer_token = false,
issuer_account = "AACC789",
type = "user",
version = 2,
tags = new[] { "dept:eng", "role:admin" },
src = new[] { "192.168.1.0/24" }
}
};
var json = System.Text.Json.JsonSerializer.Serialize(claims);
var header = Base64UrlEncode("{\"alg\":\"ed25519\",\"typ\":\"JWT\"}");
var payload = Base64UrlEncode(json);
var sig = Base64UrlEncode("fakesig");
var token = $"{header}.{payload}.{sig}";
var result = NatsJwt.DecodeUserClaims(token);
result.ShouldNotBeNull();
result.Subject.ShouldBe("UABC123");
result.Issuer.ShouldBe("AABC456");
result.IssuerAccount.ShouldBe("AACC789");
result.Name.ShouldBe("testuser");
result.IssuedAt.ShouldBe(1700000000);
result.Expires.ShouldBe(1800000000);
result.Publish!.Allow.ShouldBe(["foo.>"]);
result.Publish!.Deny.ShouldBe(["foo.secret"]);
result.Subscribe!.Allow.ShouldBe(["bar.>"]);
result.Response!.MaxMsgs.ShouldBe(5);
result.Response!.Ttl.TotalSeconds.ShouldBe(60);
result.BearerToken.ShouldBeFalse();
result.Tags.ShouldBe(["dept:eng", "role:admin"]);
result.AllowedSources.ShouldBe(["192.168.1.0/24"]);
}
[Fact]
public void DecodeAccountClaims_parses_account_fields()
{
var claims = new
{
sub = "AABC456",
iss = "OABC789",
iat = 1700000000,
name = "testaccount",
nats = new
{
type = "account",
version = 2,
signing_keys = new Dictionary<string, object>
{
["SABC123"] = new { }
},
limits = new
{
conn = 100,
subs = 1000
},
revocations = new Dictionary<string, long>
{
["UREVOKED1"] = 1700000000
}
}
};
var json = System.Text.Json.JsonSerializer.Serialize(claims);
var header = Base64UrlEncode("{\"alg\":\"ed25519\",\"typ\":\"JWT\"}");
var payload = Base64UrlEncode(json);
var sig = Base64UrlEncode("fakesig");
var token = $"{header}.{payload}.{sig}";
var result = NatsJwt.DecodeAccountClaims(token);
result.ShouldNotBeNull();
result.Subject.ShouldBe("AABC456");
result.Issuer.ShouldBe("OABC789");
result.Name.ShouldBe("testaccount");
result.Limits.MaxConnections.ShouldBe(100);
result.Limits.MaxSubscriptions.ShouldBe(1000);
result.Revocations.ShouldContainKey("UREVOKED1");
}
private static string Base64UrlEncode(string input)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
}
Step 2: Run tests, verify they fail (NatsJwt class doesn't exist)
Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JwtTests" -v normal
Step 3: Implement NatsJwt, UserClaims, AccountClaims
Create src/NATS.Server/Auth/Jwt/NatsJwt.cs:
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace NATS.Server.Auth.Jwt;
public static class NatsJwt
{
/// <summary>Detect NATS JWT by "eyJ" prefix (base64url for '{"').</summary>
public static bool IsJwt(string token) =>
token.Length > 3 && token.StartsWith("eyJ", StringComparison.Ordinal);
/// <summary>Decode JWT into header + raw payload JSON + signature bytes.</summary>
public static JwtToken? Decode(string token)
{
if (string.IsNullOrEmpty(token)) return null;
var parts = token.Split('.');
if (parts.Length != 3) return null;
try
{
var headerJson = Base64UrlDecode(parts[0]);
var header = JsonSerializer.Deserialize<JwtHeader>(headerJson);
if (header is null) return null;
var payloadBytes = Base64UrlDecode(parts[1]);
var signatureBytes = Base64UrlDecodeBytes(parts[2]);
return new JwtToken
{
Header = header,
PayloadJson = payloadBytes,
Signature = signatureBytes,
SigningInput = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}"),
};
}
catch
{
return null;
}
}
public static UserClaims? DecodeUserClaims(string token)
{
var jwt = Decode(token);
if (jwt is null) return null;
try
{
return JsonSerializer.Deserialize<UserClaims>(jwt.PayloadJson, JsonOptions);
}
catch { return null; }
}
public static AccountClaims? DecodeAccountClaims(string token)
{
var jwt = Decode(token);
if (jwt is null) return null;
try
{
return JsonSerializer.Deserialize<AccountClaims>(jwt.PayloadJson, JsonOptions);
}
catch { return null; }
}
/// <summary>
/// Verify Ed25519 signature using the subject's public nkey.
/// Returns true if signature is valid.
/// </summary>
public static bool Verify(string token, string publicNkey)
{
var jwt = Decode(token);
if (jwt is null) return false;
try
{
var kp = NATS.NKeys.NKeys.FromPublicKey(publicNkey.AsSpan());
return kp.Verify(jwt.SigningInput, jwt.Signature);
}
catch { return false; }
}
/// <summary>
/// Verify Ed25519 signature of nonce bytes using public nkey.
/// Used for CONNECT sig verification. Accepts base64 or base64url sig.
/// </summary>
public static bool VerifyNonce(byte[] nonce, string signature, string publicNkey)
{
try
{
// Try base64url first, then standard base64 (Go compat: auth.go:1002-1009)
byte[] sigBytes;
try { sigBytes = Base64UrlDecodeBytes(signature); }
catch { sigBytes = Convert.FromBase64String(signature); }
var kp = NATS.NKeys.NKeys.FromPublicKey(publicNkey.AsSpan());
return kp.Verify(nonce, sigBytes);
}
catch { return false; }
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private static string Base64UrlDecode(string input)
{
var padded = input.Replace('-', '+').Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Encoding.UTF8.GetString(Convert.FromBase64String(padded));
}
private static byte[] Base64UrlDecodeBytes(string input)
{
var padded = input.Replace('-', '+').Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
}
public sealed class JwtToken
{
public required JwtHeader Header { get; init; }
public required string PayloadJson { get; init; }
public required byte[] Signature { get; init; }
public required byte[] SigningInput { get; init; }
}
public sealed class JwtHeader
{
[JsonPropertyName("alg")]
public string Algorithm { get; set; } = "";
[JsonPropertyName("typ")]
public string Type { get; set; } = "";
}
Create src/NATS.Server/Auth/Jwt/UserClaims.cs:
using System.Text.Json.Serialization;
namespace NATS.Server.Auth.Jwt;
/// <summary>
/// NATS user JWT claims. Reference: nats-io/jwt/v2 user_claims.go.
/// </summary>
public sealed class UserClaims
{
[JsonPropertyName("sub")]
public string Subject { get; set; } = "";
[JsonPropertyName("iss")]
public string Issuer { get; set; } = "";
[JsonPropertyName("iat")]
public long IssuedAt { get; set; }
[JsonPropertyName("exp")]
public long Expires { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("nats")]
public UserClaimsNats? Nats { get; set; }
// Convenience accessors that delegate to Nats
[JsonIgnore] public string IssuerAccount => Nats?.IssuerAccount ?? "";
[JsonIgnore] public bool BearerToken => Nats?.BearerToken ?? false;
[JsonIgnore] public SubjectPermission? Publish => Nats?.Pub;
[JsonIgnore] public SubjectPermission? Subscribe => Nats?.Sub;
[JsonIgnore] public ResponsePermission? Response => Nats?.Resp;
[JsonIgnore] public string[] Tags => Nats?.Tags ?? [];
[JsonIgnore] public string[] AllowedSources => Nats?.Src ?? [];
[JsonIgnore] public string[] AllowedConnectionTypes => Nats?.AllowedConnectionTypes ?? [];
[JsonIgnore] public TimeRange[]? Times => Nats?.Times;
/// <summary>Check if this JWT has expired.</summary>
public bool IsExpired()
{
if (Expires == 0) return false;
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() > Expires;
}
/// <summary>Return expiry as DateTimeOffset, or null if no expiry.</summary>
public DateTimeOffset? GetExpiry()
{
if (Expires == 0) return null;
return DateTimeOffset.FromUnixTimeSeconds(Expires);
}
}
public sealed class UserClaimsNats
{
[JsonPropertyName("pub")]
public SubjectPermission? Pub { get; set; }
[JsonPropertyName("sub")]
public SubjectPermission? Sub { get; set; }
[JsonPropertyName("resp")]
public ResponsePermission? Resp { get; set; }
[JsonPropertyName("bearer_token")]
public bool BearerToken { get; set; }
[JsonPropertyName("issuer_account")]
public string IssuerAccount { get; set; } = "";
[JsonPropertyName("type")]
public string Type { get; set; } = "";
[JsonPropertyName("version")]
public int Version { get; set; }
[JsonPropertyName("tags")]
public string[] Tags { get; set; } = [];
[JsonPropertyName("src")]
public string[] Src { get; set; } = [];
[JsonPropertyName("allowed_connection_types")]
public string[] AllowedConnectionTypes { get; set; } = [];
[JsonPropertyName("times")]
public TimeRange[]? Times { get; set; }
}
public sealed class SubjectPermission
{
[JsonPropertyName("allow")]
public string[] Allow { get; set; } = [];
[JsonPropertyName("deny")]
public string[] Deny { get; set; } = [];
}
public sealed class ResponsePermission
{
[JsonPropertyName("max")]
public int MaxMsgs { get; set; } = 1;
[JsonPropertyName("ttl")]
public long TtlNanos { get; set; }
[JsonIgnore]
public TimeSpan Ttl => TimeSpan.FromTicks(TtlNanos / 100); // nanos to ticks
}
public sealed class TimeRange
{
[JsonPropertyName("start")]
public string Start { get; set; } = "";
[JsonPropertyName("end")]
public string End { get; set; } = "";
}
Create src/NATS.Server/Auth/Jwt/AccountClaims.cs:
using System.Text.Json.Serialization;
namespace NATS.Server.Auth.Jwt;
/// <summary>
/// NATS account JWT claims. Reference: nats-io/jwt/v2 account_claims.go.
/// </summary>
public sealed class AccountClaims
{
[JsonPropertyName("sub")]
public string Subject { get; set; } = "";
[JsonPropertyName("iss")]
public string Issuer { get; set; } = "";
[JsonPropertyName("iat")]
public long IssuedAt { get; set; }
[JsonPropertyName("exp")]
public long Expires { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("nats")]
public AccountClaimsNats? Nats { get; set; }
[JsonIgnore] public AccountLimits Limits => Nats?.Limits ?? new();
[JsonIgnore] public Dictionary<string, long> Revocations => Nats?.Revocations ?? new();
[JsonIgnore] public Dictionary<string, object>? SigningKeys => Nats?.SigningKeys;
}
public sealed class AccountClaimsNats
{
[JsonPropertyName("type")]
public string Type { get; set; } = "";
[JsonPropertyName("version")]
public int Version { get; set; }
[JsonPropertyName("limits")]
public AccountLimits Limits { get; set; } = new();
[JsonPropertyName("signing_keys")]
public Dictionary<string, object>? SigningKeys { get; set; }
[JsonPropertyName("revocations")]
public Dictionary<string, long> Revocations { get; set; } = new();
}
public sealed class AccountLimits
{
[JsonPropertyName("conn")]
public int MaxConnections { get; set; }
[JsonPropertyName("subs")]
public int MaxSubscriptions { get; set; }
[JsonPropertyName("payload")]
public long MaxPayload { get; set; }
[JsonPropertyName("data")]
public long MaxData { get; set; }
}
Step 4: Run tests, verify they pass
Run: dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JwtTests" -v normal
Step 5: Commit
git add src/NATS.Server/Auth/Jwt/ tests/NATS.Server.Tests/JwtTests.cs
git commit -m "feat: add NATS JWT decode/verify and claim structs"
Task 2: Permission Templates
Files:
- Create:
src/NATS.Server/Auth/Jwt/PermissionTemplates.cs - Test:
tests/NATS.Server.Tests/PermissionTemplateTests.cs - Depends on: Task 1 (UserClaims, AccountClaims types)
Context:
Go's permission templates use mustache-style {{func()}} syntax in subject patterns. When a user connects with a JWT, template strings in their permissions are expanded using claim values. Multi-value tags produce a cartesian product of subjects.
Reference: Go auth.go:424-520 for processUserPermissionsTemplate().
Step 1: Write failing tests
Create tests/NATS.Server.Tests/PermissionTemplateTests.cs:
namespace NATS.Server.Tests;
using NATS.Server.Auth.Jwt;
public class PermissionTemplateTests
{
[Fact]
public void Expand_name_template()
{
var result = PermissionTemplates.Expand("user.{{name()}}.>",
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
userTags: [], accountTags: []);
result.ShouldBe(["user.alice.>"]);
}
[Fact]
public void Expand_subject_template()
{
var result = PermissionTemplates.Expand("inbox.{{subject()}}.>",
name: "alice", subject: "UABC123", accountName: "acct", accountSubject: "AABC",
userTags: [], accountTags: []);
result.ShouldBe(["inbox.UABC123.>"]);
}
[Fact]
public void Expand_account_name_template()
{
var result = PermissionTemplates.Expand("acct.{{account-name()}}.>",
name: "alice", subject: "UABC", accountName: "myaccount", accountSubject: "AABC",
userTags: [], accountTags: []);
result.ShouldBe(["acct.myaccount.>"]);
}
[Fact]
public void Expand_account_subject_template()
{
var result = PermissionTemplates.Expand("acct.{{account-subject()}}.>",
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC456",
userTags: [], accountTags: []);
result.ShouldBe(["acct.AABC456.>"]);
}
[Fact]
public void Expand_tag_template_single_value()
{
var result = PermissionTemplates.Expand("dept.{{tag(dept)}}.>",
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
userTags: ["dept:engineering"], accountTags: []);
result.ShouldBe(["dept.engineering.>"]);
}
[Fact]
public void Expand_tag_template_multi_value_cartesian()
{
var result = PermissionTemplates.Expand("dept.{{tag(dept)}}.>",
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
userTags: ["dept:eng", "dept:sales"], accountTags: []);
result.Count.ShouldBe(2);
result.ShouldContain("dept.eng.>");
result.ShouldContain("dept.sales.>");
}
[Fact]
public void Expand_account_tag_template()
{
var result = PermissionTemplates.Expand("region.{{account-tag(region)}}.>",
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
userTags: [], accountTags: ["region:us-east"]);
result.ShouldBe(["region.us-east.>"]);
}
[Fact]
public void Expand_no_templates_returns_original()
{
var result = PermissionTemplates.Expand("foo.bar.>",
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
userTags: [], accountTags: []);
result.ShouldBe(["foo.bar.>"]);
}
[Fact]
public void Expand_unknown_tag_returns_empty()
{
var result = PermissionTemplates.Expand("dept.{{tag(missing)}}.>",
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
userTags: ["dept:eng"], accountTags: []);
result.ShouldBeEmpty();
}
[Fact]
public void ExpandAll_expands_array_of_subjects()
{
var subjects = new[] { "user.{{name()}}.>", "inbox.{{subject()}}.>" };
var result = PermissionTemplates.ExpandAll(subjects,
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
userTags: [], accountTags: []);
result.ShouldBe(["user.alice.>", "inbox.UABC.>"]);
}
}
Step 2: Run tests, verify they fail
Step 3: Implement PermissionTemplates.cs
using System.Text.RegularExpressions;
namespace NATS.Server.Auth.Jwt;
/// <summary>
/// Expands mustache-style permission templates in JWT subject patterns.
/// Reference: Go auth.go:424-520 processUserPermissionsTemplate().
/// </summary>
public static partial class PermissionTemplates
{
[GeneratedRegex(@"\{\{([^}]+)\}\}")]
private static partial Regex TemplateRegex();
public static List<string> Expand(
string pattern,
string name, string subject,
string accountName, string accountSubject,
string[] userTags, string[] accountTags)
{
var matches = TemplateRegex().Matches(pattern);
if (matches.Count == 0)
return [pattern];
// Build list of (placeholder, values[]) for each template in the pattern
var replacements = new List<(string Placeholder, string[] Values)>();
foreach (Match match in matches)
{
var expr = match.Groups[1].Value.Trim();
var values = ResolveTemplate(expr, name, subject, accountName, accountSubject, userTags, accountTags);
if (values.Length == 0)
return []; // No values for a required tag → deny all
replacements.Add((match.Value, values));
}
// Cartesian product of all multi-value replacements
var results = new List<string> { pattern };
foreach (var (placeholder, values) in replacements)
{
var next = new List<string>();
foreach (var current in results)
{
foreach (var value in values)
{
next.Add(current.Replace(placeholder, value));
}
}
results = next;
}
return results;
}
public static List<string> ExpandAll(
string[] patterns,
string name, string subject,
string accountName, string accountSubject,
string[] userTags, string[] accountTags)
{
var result = new List<string>();
foreach (var pattern in patterns)
result.AddRange(Expand(pattern, name, subject, accountName, accountSubject, userTags, accountTags));
return result;
}
private static string[] ResolveTemplate(
string expr,
string name, string subject,
string accountName, string accountSubject,
string[] userTags, string[] accountTags)
{
return expr switch
{
"name()" => [name],
"subject()" => [subject],
"account-name()" => [accountName],
"account-subject()" => [accountSubject],
_ when expr.StartsWith("tag(") && expr.EndsWith(")") =>
ResolveTags(expr[4..^1], userTags),
_ when expr.StartsWith("account-tag(") && expr.EndsWith(")") =>
ResolveTags(expr[12..^1], accountTags),
_ => []
};
}
private static string[] ResolveTags(string tagName, string[] tags)
{
var prefix = tagName + ":";
var values = tags
.Where(t => t.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.Select(t => t[prefix.Length..])
.ToArray();
return values;
}
}
Step 4: Run tests, verify they pass
Step 5: Commit
git add src/NATS.Server/Auth/Jwt/PermissionTemplates.cs tests/NATS.Server.Tests/PermissionTemplateTests.cs
git commit -m "feat: add JWT permission template expansion with cartesian product"
Task 3: Account Resolver
Files:
- Create:
src/NATS.Server/Auth/Jwt/AccountResolver.cs - Test:
tests/NATS.Server.Tests/AccountResolverTests.cs - No dependencies on other tasks
Context:
The account resolver retrieves account JWTs by nkey. Go has multiple implementations (memory, URL, directory). We implement MemAccountResolver for testing/simple deployments.
Reference: Go accounts.go:4035+ for AccountResolver interface.
Step 1: Write failing tests
namespace NATS.Server.Tests;
using NATS.Server.Auth.Jwt;
public class AccountResolverTests
{
[Fact]
public async Task MemResolver_store_and_fetch()
{
var resolver = new MemAccountResolver();
await resolver.StoreAsync("AABC123", "eyJfaketoken");
var result = await resolver.FetchAsync("AABC123");
result.ShouldBe("eyJfaketoken");
}
[Fact]
public async Task MemResolver_fetch_unknown_returns_null()
{
var resolver = new MemAccountResolver();
var result = await resolver.FetchAsync("UNKNOWN");
result.ShouldBeNull();
}
[Fact]
public async Task MemResolver_overwrite_updates()
{
var resolver = new MemAccountResolver();
await resolver.StoreAsync("AABC123", "old-jwt");
await resolver.StoreAsync("AABC123", "new-jwt");
var result = await resolver.FetchAsync("AABC123");
result.ShouldBe("new-jwt");
}
[Fact]
public void MemResolver_is_not_readonly()
{
var resolver = new MemAccountResolver();
resolver.IsReadOnly.ShouldBeFalse();
}
}
Step 2: Run tests, verify they fail
Step 3: Implement AccountResolver.cs
using System.Collections.Concurrent;
namespace NATS.Server.Auth.Jwt;
/// <summary>
/// Interface for resolving account JWTs by nkey.
/// Reference: Go accounts.go:4035+ AccountResolver interface.
/// </summary>
public interface IAccountResolver
{
Task<string?> FetchAsync(string accountNkey);
Task StoreAsync(string accountNkey, string jwt);
bool IsReadOnly { get; }
}
/// <summary>
/// In-memory account resolver for testing and simple deployments.
/// </summary>
public sealed class MemAccountResolver : IAccountResolver
{
private readonly ConcurrentDictionary<string, string> _accounts = new(StringComparer.Ordinal);
public bool IsReadOnly => false;
public Task<string?> FetchAsync(string accountNkey)
{
_accounts.TryGetValue(accountNkey, out var jwt);
return Task.FromResult(jwt);
}
public Task StoreAsync(string accountNkey, string jwt)
{
_accounts[accountNkey] = jwt;
return Task.CompletedTask;
}
}
Step 4: Run tests, verify they pass
Step 5: Commit
git add src/NATS.Server/Auth/Jwt/AccountResolver.cs tests/NATS.Server.Tests/AccountResolverTests.cs
git commit -m "feat: add IAccountResolver interface and MemAccountResolver"
Task 4: JwtAuthenticator — Wire JWT into Auth Flow
Files:
- Create:
src/NATS.Server/Auth/JwtAuthenticator.cs - Modify:
src/NATS.Server/Auth/Account.cs— AddNkey,Issuer,SigningKeys,RevokedUsers - Modify:
src/NATS.Server/NatsOptions.cs— AddTrustedKeys,AccountResolver - Modify:
src/NATS.Server/Auth/AuthService.cs— Wire JwtAuthenticator into chain - Test:
tests/NATS.Server.Tests/JwtAuthenticatorTests.cs - Depends on: Tasks 1, 2, 3
Context:
JwtAuthenticator implements IAuthenticator. It decodes the user JWT from ClientOpts.JWT, resolves the issuing account via AccountResolver, verifies the Ed25519 signature against the connection nonce, expands permission templates, validates source IP and time restrictions, and checks user revocation.
Reference: Go auth.go:588+ for processClientOrLeafAuthentication.
Step 1: Write failing tests
namespace NATS.Server.Tests;
using NATS.Server.Auth;
using NATS.Server.Auth.Jwt;
using NATS.Server.Protocol;
public class JwtAuthenticatorTests
{
[Fact]
public async Task Authenticate_with_valid_jwt_succeeds()
{
// Create operator keypair
var operatorKp = NATS.NKeys.NKeys.FromSeed(CreateOperatorSeed());
var operatorPub = operatorKp.GetPublicKey();
// Create account keypair and JWT
var accountKp = NATS.NKeys.NKeys.FromSeed(CreateAccountSeed());
var accountPub = accountKp.GetPublicKey();
// Create user keypair
var userKp = NATS.NKeys.NKeys.FromSeed(CreateUserSeed());
var userPub = userKp.GetPublicKey();
// Build minimal user JWT
var userClaims = BuildUserClaimsJson(userPub, accountPub);
var userJwt = SignJwt(userClaims, accountKp);
// Build minimal account JWT
var accountClaims = BuildAccountClaimsJson(accountPub, operatorPub);
var accountJwt = SignJwt(accountClaims, operatorKp);
// Setup resolver with account JWT
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
// Create authenticator with operator trusted key
var authenticator = new JwtAuthenticator([operatorPub], resolver);
// Generate nonce
var nonce = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
// Sign nonce with user key
var sig = Convert.ToBase64String(userKp.Sign(nonce));
var context = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt, Sig = sig, Nkey = userPub },
Nonce = nonce,
};
var result = authenticator.Authenticate(context);
result.ShouldNotBeNull();
result.Identity.ShouldBe(userPub);
result.AccountName.ShouldBe(accountPub);
}
[Fact]
public void Authenticate_with_no_jwt_returns_null()
{
var authenticator = new JwtAuthenticator(["OABC"], new MemAccountResolver());
var context = new ClientAuthContext
{
Opts = new ClientOptions(),
Nonce = [1, 2, 3],
};
authenticator.Authenticate(context).ShouldBeNull();
}
[Fact]
public async Task Authenticate_revoked_user_fails()
{
// Setup same as valid test, but revoke user before auth
var operatorKp = NATS.NKeys.NKeys.FromSeed(CreateOperatorSeed());
var operatorPub = operatorKp.GetPublicKey();
var accountKp = NATS.NKeys.NKeys.FromSeed(CreateAccountSeed());
var accountPub = accountKp.GetPublicKey();
var userKp = NATS.NKeys.NKeys.FromSeed(CreateUserSeed());
var userPub = userKp.GetPublicKey();
// Build account JWT WITH user revocation
var accountClaims = BuildAccountClaimsJsonWithRevocation(accountPub, operatorPub, userPub);
var accountJwt = SignJwt(accountClaims, operatorKp);
var userClaims = BuildUserClaimsJson(userPub, accountPub);
var userJwt = SignJwt(userClaims, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var authenticator = new JwtAuthenticator([operatorPub], resolver);
var nonce = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
var sig = Convert.ToBase64String(userKp.Sign(nonce));
var context = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt, Sig = sig, Nkey = userPub },
Nonce = nonce,
};
var result = authenticator.Authenticate(context);
result.ShouldBeNull(); // Revoked user should fail
}
// Helper methods to create NKey seeds and build JWTs
// These build raw JWT strings by base64url-encoding JSON + signing with Ed25519
// ... (implementation in test file)
}
Note: The test file will need helper methods for creating NKey seeds, building claim JSON, and signing JWTs. These helpers use NATS.NKeys.NKeys.CreateOperatorKeyPair(), .CreateAccountKeyPair(), .CreateUserKeyPair() to generate seeds, then build JSON claims and sign with Ed25519. The exact helpers should match the patterns in the existing NKeyAuthenticatorTests.cs.
Step 2: Run tests, verify they fail
Step 3: Implement JwtAuthenticator and Account changes
Add to Account.cs:
public string? Nkey { get; set; }
public string? Issuer { get; set; }
public Dictionary<string, object>? SigningKeys { get; set; }
private readonly ConcurrentDictionary<string, long> _revokedUsers = new(StringComparer.Ordinal);
public void RevokeUser(string userNkey, long issuedAt) => _revokedUsers[userNkey] = issuedAt;
public bool IsUserRevoked(string userNkey, long issuedAt)
{
if (_revokedUsers.TryGetValue(userNkey, out var revokedAt))
return issuedAt <= revokedAt;
return false;
}
Add to NatsOptions.cs:
// JWT / Operator mode
public string[]? TrustedKeys { get; set; }
public IAccountResolver? AccountResolver { get; set; }
Create src/NATS.Server/Auth/JwtAuthenticator.cs — Implements IAuthenticator.Authenticate():
- Check
context.Opts.JWTis present and is a JWT (NatsJwt.IsJwt()) - Decode user claims via
NatsJwt.DecodeUserClaims() - Resolve issuer account:
IssuerAccountorIssuer→AccountResolver.FetchAsync()→NatsJwt.DecodeAccountClaims() - Verify account JWT issuer is in
TrustedKeys(or account signing keys) - Verify nonce signature:
NatsJwt.VerifyNonce(nonce, sig, userClaims.Subject)(skip if BearerToken) - Check user revocation:
account.IsUserRevoked(userClaims.Subject, userClaims.IssuedAt) - Validate source IP if
AllowedSourcesis non-empty - Expand permission templates via
PermissionTemplates.ExpandAll() - Build and return
AuthResultwith identity, account, permissions, expiry
Wire into AuthService.cs: When NatsOptions.TrustedKeys is non-null and non-empty, add JwtAuthenticator to the authenticator chain.
Step 4: Run tests, verify they pass
Step 5: Commit
git add src/NATS.Server/Auth/JwtAuthenticator.cs src/NATS.Server/Auth/Account.cs \
src/NATS.Server/NatsOptions.cs src/NATS.Server/Auth/AuthService.cs \
tests/NATS.Server.Tests/JwtAuthenticatorTests.cs
git commit -m "feat: add JwtAuthenticator with account resolution, revocation, and template expansion"
Task 5: Subject Transform — Core Engine
Files:
- Create:
src/NATS.Server/Subscriptions/SubjectTransform.cs - Test:
tests/NATS.Server.Tests/SubjectTransformTests.cs - No dependencies
Context:
Go's subject_transform.go implements a compiled transform engine. A transform has a source pattern (with wildcards) and a destination template with function tokens. On match, captured wildcard values are substituted into the destination.
Reference: Go subject_transform.go for transform functions and matching.
Step 1: Write failing tests
namespace NATS.Server.Tests;
using NATS.Server.Subscriptions;
public class SubjectTransformTests
{
[Fact]
public void Wildcard_replacement()
{
var t = SubjectTransform.Create("foo.*", "bar.{{wildcard(1)}}");
t.ShouldNotBeNull();
t.Apply("foo.baz").ShouldBe("bar.baz");
}
[Fact]
public void Dollar_syntax_wildcard()
{
var t = SubjectTransform.Create("foo.*.*", "bar.$2.$1");
t.ShouldNotBeNull();
t.Apply("foo.a.b").ShouldBe("bar.b.a");
}
[Fact]
public void Full_wildcard_capture()
{
var t = SubjectTransform.Create("foo.>", "bar.{{wildcard(1)}}");
t.ShouldNotBeNull();
t.Apply("foo.a.b.c").ShouldBe("bar.a.b.c");
}
[Fact]
public void No_match_returns_null()
{
var t = SubjectTransform.Create("foo.*", "bar.{{wildcard(1)}}");
t!.Apply("baz.x").ShouldBeNull();
}
[Fact]
public void Partition_function()
{
var t = SubjectTransform.Create("orders.*", "bucket.{{partition(10,1)}}");
t.ShouldNotBeNull();
var result = t.Apply("orders.customer123");
result.ShouldNotBeNull();
// Partition should be 0-9
var bucket = int.Parse(result.Split('.')[1]);
bucket.ShouldBeGreaterThanOrEqualTo(0);
bucket.ShouldBeLessThan(10);
}
[Fact]
public void Partition_deterministic()
{
var t = SubjectTransform.Create("orders.*", "bucket.{{partition(10,1)}}");
t!.Apply("orders.abc").ShouldBe(t.Apply("orders.abc")); // same input = same output
}
[Fact]
public void Split_function()
{
var t = SubjectTransform.Create("events.*", "split.{{split(1,-)}}");
t.ShouldNotBeNull();
t.Apply("events.a-b-c").ShouldBe("split.a.b.c");
}
[Fact]
public void Left_function()
{
var t = SubjectTransform.Create("data.*", "prefix.{{left(1,3)}}");
t.ShouldNotBeNull();
t.Apply("data.abcdef").ShouldBe("prefix.abc");
}
[Fact]
public void Right_function()
{
var t = SubjectTransform.Create("data.*", "suffix.{{right(1,3)}}");
t.ShouldNotBeNull();
t.Apply("data.abcdef").ShouldBe("suffix.def");
}
[Fact]
public void SplitFromLeft_function()
{
var t = SubjectTransform.Create("data.*", "parts.{{splitFromLeft(1,3)}}");
t.ShouldNotBeNull();
t.Apply("data.abcdef").ShouldBe("parts.abc.def");
}
[Fact]
public void SplitFromRight_function()
{
var t = SubjectTransform.Create("data.*", "parts.{{splitFromRight(1,3)}}");
t.ShouldNotBeNull();
t.Apply("data.abcdef").ShouldBe("parts.abc.def");
}
[Fact]
public void SliceFromLeft_function()
{
var t = SubjectTransform.Create("data.*", "chunks.{{sliceFromLeft(1,2)}}");
t.ShouldNotBeNull();
t.Apply("data.abcdef").ShouldBe("chunks.ab.cd.ef");
}
[Fact]
public void SliceFromRight_function()
{
var t = SubjectTransform.Create("data.*", "chunks.{{sliceFromRight(1,2)}}");
t.ShouldNotBeNull();
t.Apply("data.abcdef").ShouldBe("chunks.ab.cd.ef");
}
[Fact]
public void Literal_passthrough_no_wildcards()
{
var t = SubjectTransform.Create("foo.bar", "baz.qux");
t.ShouldNotBeNull();
t.Apply("foo.bar").ShouldBe("baz.qux");
}
[Fact]
public void Invalid_source_returns_null()
{
SubjectTransform.Create("", "bar").ShouldBeNull();
}
}
Step 2: Run tests, verify they fail
Step 3: Implement SubjectTransform.cs
The implementation should:
- Parse source pattern to identify wildcard positions (
*and>) - Parse destination template into a list of token operations (literal text, wildcard ref, function call)
Apply(string subject): match subject against source, capture wildcards, evaluate destination tokens- Functions:
wildcard(n),partition(num,tokens...),split(tok,delim),splitFromLeft(tok,pos),splitFromRight(tok,pos),sliceFromLeft(tok,size),sliceFromRight(tok,size),left(tok,len),right(tok,len) - FNV-1a hash for
partitionfunction (matching Go) $Nsyntax as alias for{{wildcard(N)}}
Key implementation detail: Parse at Create() time into compiled operation list. Apply() just executes the list.
Step 4: Run tests, verify they pass
Step 5: Commit
git add src/NATS.Server/Subscriptions/SubjectTransform.cs tests/NATS.Server.Tests/SubjectTransformTests.cs
git commit -m "feat: add SubjectTransform engine with all 9 transform functions"
Task 6: Wire Subject Transforms into Delivery
Files:
- Modify:
src/NATS.Server/NatsOptions.cs— AddSubjectMappings - Modify:
src/NATS.Server/NatsServer.cs— Compile transforms at startup, apply in message path - Test:
tests/NATS.Server.Tests/SubjectTransformIntegrationTests.cs - Depends on: Task 5
Step 1: Write failing test
namespace NATS.Server.Tests;
using NATS.Server.Subscriptions;
public class SubjectTransformIntegrationTests
{
[Fact]
public void Server_applies_subject_mapping_on_publish()
{
// Test that NatsServer compiles and applies SubjectMappings
// by checking that a message published to "src.x" is delivered
// to subscribers of "dest.x" when mapping "src.*" -> "dest.{{wildcard(1)}}"
// This will be an integration test using NatsServer directly
}
}
Step 2: Implement
Add to NatsOptions.cs:
public Dictionary<string, string>? SubjectMappings { get; set; }
In NatsServer.cs:
- Add
_subjectTransforms: SubjectTransform[]field - In constructor: compile
_options.SubjectMappingsintoSubjectTransform[] - In message processing (before
Match()): apply each transform to find the mapped subject
Step 3: Run tests, verify they pass
Step 4: Commit
git add src/NATS.Server/NatsOptions.cs src/NATS.Server/NatsServer.cs \
tests/NATS.Server.Tests/SubjectTransformIntegrationTests.cs
git commit -m "feat: wire subject transforms into NatsServer message delivery path"
Task 7: OCSP Config and Peer Verification
Files:
- Create:
src/NATS.Server/Tls/OcspConfig.cs - Modify:
src/NATS.Server/NatsOptions.cs— AddOcspConfig,OcspPeerVerify - Modify:
src/NATS.Server/Tls/TlsConnectionWrapper.cs— Wire peer verification - Test:
tests/NATS.Server.Tests/OcspConfigTests.cs - No dependencies
Step 1: Write failing tests
namespace NATS.Server.Tests;
using NATS.Server.Tls;
public class OcspConfigTests
{
[Fact]
public void Default_mode_is_auto()
{
var config = new OcspConfig();
config.Mode.ShouldBe(OcspMode.Auto);
}
[Fact]
public void Modes_have_correct_values()
{
((int)OcspMode.Auto).ShouldBe(0);
((int)OcspMode.Always).ShouldBe(1);
((int)OcspMode.Must).ShouldBe(2);
((int)OcspMode.Never).ShouldBe(3);
}
[Fact]
public void Override_urls_default_empty()
{
var config = new OcspConfig();
config.OverrideUrls.ShouldBeEmpty();
}
}
Step 2: Run tests, verify they fail
Step 3: Implement OcspConfig.cs
namespace NATS.Server.Tls;
/// <summary>
/// OCSP stapling mode. Reference: Go ocsp.go OCSPMode constants.
/// </summary>
public enum OcspMode
{
Auto = 0, // Staple if cert has status_request extension
Always = 1, // Always staple, warn if cannot get status
Must = 2, // Must staple, fail startup if cannot get status
Never = 3, // Never attempt stapling
}
/// <summary>
/// OCSP configuration for server certificate stapling.
/// </summary>
public sealed class OcspConfig
{
public OcspMode Mode { get; init; } = OcspMode.Auto;
public string[] OverrideUrls { get; init; } = [];
}
Add to NatsOptions.cs:
public OcspConfig? OcspConfig { get; set; }
public bool OcspPeerVerify { get; set; }
Modify TlsConnectionWrapper.cs: In the RemoteCertificateValidationCallback, when OcspPeerVerify is true, set X509RevocationMode.Online on the chain policy for client certificate validation.
Step 4: Run tests, verify they pass
Step 5: Commit
git add src/NATS.Server/Tls/OcspConfig.cs src/NATS.Server/NatsOptions.cs \
src/NATS.Server/Tls/TlsConnectionWrapper.cs \
tests/NATS.Server.Tests/OcspConfigTests.cs
git commit -m "feat: add OCSP config and peer certificate revocation verification"
Task 8: OCSP Stapling
Files:
- Modify:
src/NATS.Server/NatsServer.cs— BuildSslStreamCertificateContextwith OCSP - Modify:
src/NATS.Server/Monitoring/VarzHandler.cs— PopulateTlsOcspPeerVerify - Depends on: Task 7
Context:
.NET 8+ supports OCSP stapling via SslStreamCertificateContext.Create(cert, chain, offline: false). This fetches OCSP responses automatically when the cert has OCSP responder URLs. For Must mode, we verify the OCSP response was obtained.
Step 1: Implement OCSP stapling in NatsServer startup
In NatsServer.cs, when OcspConfig is configured and Mode != Never:
// After loading TLS certificate
if (_options.OcspConfig is { Mode: not OcspMode.Never } ocspConfig)
{
var offline = ocspConfig.Mode == OcspMode.Auto; // Auto = offline OK
var certContext = SslStreamCertificateContext.Create(cert, chain, offline: !offline);
// Store for use in SslServerAuthenticationOptions.ServerCertificateContext
// If Must mode and no OCSP response available, fail startup
}
Populate in VarzHandler.cs:
TlsOcspPeerVerify = _options.OcspPeerVerify
Step 2: Run all tests, verify they pass
Step 3: Commit
git add src/NATS.Server/NatsServer.cs src/NATS.Server/Monitoring/VarzHandler.cs
git commit -m "feat: add OCSP stapling with Auto/Always/Must/Never modes"
Task 9: Windows Service Integration
Files:
- Modify:
Directory.Packages.props— AddMicrosoft.Extensions.Hosting.WindowsServices - Modify:
src/NATS.Server.Host/NATS.Server.Host.csproj— Add package reference - Modify:
src/NATS.Server.Host/Program.cs— Add--serviceflag +UseWindowsService() - No dependencies
Step 1: Add NuGet package
Add to Directory.Packages.props:
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
Add to NATS.Server.Host.csproj:
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
Step 2: Wire into Program.cs
Add --service flag handling in the CLI parsing section:
case "--service":
windowsService = true;
break;
After building the host, conditionally add Windows Service support:
if (windowsService && OperatingSystem.IsWindows())
{
builder.UseWindowsService();
}
Step 3: Build and verify
Run: dotnet build
Step 4: Commit
git add Directory.Packages.props src/NATS.Server.Host/NATS.Server.Host.csproj \
src/NATS.Server.Host/Program.cs
git commit -m "feat: add Windows Service integration via --service flag"
Task 10: Per-Subsystem Log Control
Files:
- Modify:
src/NATS.Server/NatsOptions.cs— AddLogOverrides - Modify:
src/NATS.Server.Host/Program.cs— Add--log_level_overrideflag + Serilog overrides - Test:
tests/NATS.Server.Tests/NatsOptionsTests.cs— Add test - No dependencies (but avoid parallel with Task 9 — both modify Program.cs)
Step 1: Write failing test
Add to NatsOptionsTests.cs:
[Fact]
public void LogOverrides_defaults_to_null()
{
var options = new NatsOptions();
options.LogOverrides.ShouldBeNull();
}
Step 2: Implement
Add to NatsOptions.cs:
public Dictionary<string, string>? LogOverrides { get; set; }
Add to Program.cs CLI parsing:
case "--log_level_override":
var parts = args[++i].Split('=', 2);
if (parts.Length == 2)
{
options.LogOverrides ??= new();
options.LogOverrides[parts[0]] = parts[1];
}
break;
In Serilog configuration section:
if (options.LogOverrides is not null)
{
foreach (var (ns, level) in options.LogOverrides)
{
if (Enum.TryParse<Serilog.Events.LogEventLevel>(level, true, out var serilogLevel))
loggerConfig.MinimumLevel.Override(ns, serilogLevel);
}
}
Step 3: Run tests, verify they pass
Step 4: Commit
git add src/NATS.Server/NatsOptions.cs src/NATS.Server.Host/Program.cs \
tests/NATS.Server.Tests/NatsOptionsTests.cs
git commit -m "feat: add per-subsystem log control via --log_level_override"
Task 11: Per-Client Trace Mode
Files:
- Modify:
src/NATS.Server/ClientFlags.cs— AddTraceModeflag - Modify:
src/NATS.Server/NatsClient.cs— Wire trace flag to parser logger - Test:
tests/NATS.Server.Tests/ClientFlagTests.csor add to existing - No dependencies
Step 1: Write failing test
[Fact]
public void TraceMode_flag_can_be_set_and_cleared()
{
var holder = new ClientFlagHolder();
holder.HasFlag(ClientFlags.TraceMode).ShouldBeFalse();
holder.SetFlag(ClientFlags.TraceMode);
holder.HasFlag(ClientFlags.TraceMode).ShouldBeTrue();
holder.ClearFlag(ClientFlags.TraceMode);
holder.HasFlag(ClientFlags.TraceMode).ShouldBeFalse();
}
Step 2: Implement
Add to ClientFlags enum in ClientFlags.cs:
TraceMode = 1 << 7,
In NatsClient.cs, add a method to enable per-client tracing:
public void SetTraceMode(bool enabled)
{
if (enabled)
_flags.SetFlag(ClientFlags.TraceMode);
else
_flags.ClearFlag(ClientFlags.TraceMode);
}
The parser was created at construction time with options.Trace ? logger : null. For per-client trace to work dynamically, we'd need to either recreate the parser or add a SetLogger method to NatsParser. The simplest Go-compatible approach: add ILogger? Logger { set; } to NatsParser so it can be toggled after construction.
Step 3: Run tests, verify they pass
Step 4: Commit
git add src/NATS.Server/ClientFlags.cs src/NATS.Server/NatsClient.cs \
src/NATS.Server/Protocol/NatsParser.cs tests/NATS.Server.Tests/ClientFlagTests.cs
git commit -m "feat: add per-client trace mode flag with dynamic parser logger"
Task 12: Per-Account Stats
Files:
- Modify:
src/NATS.Server/Auth/Account.cs— Add atomic stat counters - Modify:
src/NATS.Server/NatsServer.cs— Increment stats in delivery path - Test:
tests/NATS.Server.Tests/AccountTests.cs— Add stats tests - No dependencies (but avoid parallel with Task 6 — both modify NatsServer.cs)
Step 1: Write failing tests
Add to AccountTests.cs:
[Fact]
public void Account_tracks_inbound_stats()
{
var account = new Account("test");
account.IncrementInbound(1, 100);
account.IncrementInbound(1, 200);
account.InMsgs.ShouldBe(2);
account.InBytes.ShouldBe(300);
}
[Fact]
public void Account_tracks_outbound_stats()
{
var account = new Account("test");
account.IncrementOutbound(1, 50);
account.IncrementOutbound(1, 75);
account.OutMsgs.ShouldBe(2);
account.OutBytes.ShouldBe(125);
}
Step 2: Run tests, verify they fail
Step 3: Implement
Add to Account.cs:
private long _inMsgs;
private long _outMsgs;
private long _inBytes;
private long _outBytes;
public long InMsgs => Interlocked.Read(ref _inMsgs);
public long OutMsgs => Interlocked.Read(ref _outMsgs);
public long InBytes => Interlocked.Read(ref _inBytes);
public long OutBytes => Interlocked.Read(ref _outBytes);
public void IncrementInbound(long msgs, long bytes)
{
Interlocked.Add(ref _inMsgs, msgs);
Interlocked.Add(ref _inBytes, bytes);
}
public void IncrementOutbound(long msgs, long bytes)
{
Interlocked.Add(ref _outMsgs, msgs);
Interlocked.Add(ref _outBytes, bytes);
}
In NatsServer.cs DeliverMessage: after sending message, increment outbound stats on the subscriber's account. In message processing (PUB handler): increment inbound stats on the publisher's account.
Step 4: Run tests, verify they pass
Step 5: Commit
git add src/NATS.Server/Auth/Account.cs src/NATS.Server/NatsServer.cs \
tests/NATS.Server.Tests/AccountTests.cs
git commit -m "feat: add per-account message/byte stats with Interlocked counters"
Task 13: TLS Certificate Expiry in /varz
Files:
- Modify:
src/NATS.Server/Monitoring/VarzHandler.cs— PopulateTlsCertNotAfter - Test: Verify via existing monitoring test or add targeted test
- No dependencies
Step 1: Implement
In VarzHandler.cs, in the HandleVarzAsync method where other TLS fields are populated (lines 63-65), add:
if (_options.HasTls && !string.IsNullOrEmpty(_options.TlsCert))
{
try
{
using var cert = X509CertificateLoader.LoadCertificateFromFile(_options.TlsCert);
varz.TlsCertNotAfter = cert.NotAfter;
}
catch { /* cert load failure — leave field empty */ }
}
Note: Check the existing Varz.cs model — the TlsCertNotAfter field type may be DateTime or string. Match the field type. Add using System.Security.Cryptography.X509Certificates; if not already present.
Step 2: Run all tests, verify they pass
Step 3: Commit
git add src/NATS.Server/Monitoring/VarzHandler.cs
git commit -m "feat: populate TLS certificate expiry in /varz response"
Task 14: Update differences.md
Files:
- Modify:
differences.md - Depends on: All Tasks 1-13
Step 1: Run full test suite
Run: dotnet test --verbosity quiet
Verify all tests pass.
Step 2: Update differences.md
Update these sections:
Section 2 (Client Features):
- Per-client trace mode:
N→Ywith note about dynamic parser logger
Section 2 (Stats):
- Per-account stats:
N→Ywith note about Interlocked counters
Section 3 (Protocol Parsing Gaps):
- Subject mapping:
N→Ywith note about SubjectTransform engine
Section 4 (SubList Features):
- No changes (already complete)
Section 5 (Auth Mechanisms):
- JWT validation:
N→Ywith note about Ed25519 verification, account resolver - User revocation tracking:
N→Y
Section 5 (Permissions):
- Permission templates:
N→Ywith note about 6 template types
Section 6 (CLI Flags):
- No changes needed (already covered)
Section 6 (Configuration System):
- Update option count from ~62 to reflect new additions
Section 6 (Missing Options):
- OCSP configuration: mark as implemented
Section 7 (Varz):
- TLS cert expiry info:
N→Y - TlsOcspPeerVerify: populated
Section 8 (TLS):
- OCSP stapling:
N→Ywith modes
Section 9 (Logging):
- Per-subsystem log control:
N→Ywith note about MinimumLevel.Override
Summary section:
- Mark all newly resolved items with strikethrough
- Update remaining gaps list
Step 3: Commit
git add differences.md
git commit -m "docs: update differences.md to reflect all remaining gaps resolved"
Appendix: File Dependency Matrix
| Task | Creates | Modifies | Conflicts With |
|---|---|---|---|
| 1 | Auth/Jwt/NatsJwt.cs, UserClaims.cs, AccountClaims.cs | — | None |
| 2 | Auth/Jwt/PermissionTemplates.cs | — | None |
| 3 | Auth/Jwt/AccountResolver.cs | — | None |
| 4 | Auth/JwtAuthenticator.cs | Account.cs, NatsOptions.cs, AuthService.cs | 12 (Account.cs) |
| 5 | Subscriptions/SubjectTransform.cs | — | None |
| 6 | — | NatsOptions.cs, NatsServer.cs | 4, 8, 10, 12 (shared files) |
| 7 | Tls/OcspConfig.cs | NatsOptions.cs, TlsConnectionWrapper.cs | 6, 10 (NatsOptions) |
| 8 | — | NatsServer.cs, VarzHandler.cs | 6, 12, 13 (shared files) |
| 9 | — | Program.cs, Directory.Packages.props, csproj | 10 (Program.cs) |
| 10 | — | NatsOptions.cs, Program.cs | 6, 7, 9 (shared files) |
| 11 | — | ClientFlags.cs, NatsClient.cs, NatsParser.cs | None |
| 12 | — | Account.cs, NatsServer.cs | 4, 6, 8 (shared files) |
| 13 | — | VarzHandler.cs | 8 (VarzHandler) |
| 14 | — | differences.md | None (final) |