# 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`: ```csharp 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 { ["SABC123"] = new { } }, limits = new { conn = 100, subs = 1000 }, revocations = new Dictionary { ["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`: ```csharp using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace NATS.Server.Auth.Jwt; public static class NatsJwt { /// Detect NATS JWT by "eyJ" prefix (base64url for '{"'). public static bool IsJwt(string token) => token.Length > 3 && token.StartsWith("eyJ", StringComparison.Ordinal); /// Decode JWT into header + raw payload JSON + signature bytes. 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(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(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(jwt.PayloadJson, JsonOptions); } catch { return null; } } /// /// Verify Ed25519 signature using the subject's public nkey. /// Returns true if signature is valid. /// 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; } } /// /// Verify Ed25519 signature of nonce bytes using public nkey. /// Used for CONNECT sig verification. Accepts base64 or base64url sig. /// 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`: ```csharp using System.Text.Json.Serialization; namespace NATS.Server.Auth.Jwt; /// /// NATS user JWT claims. Reference: nats-io/jwt/v2 user_claims.go. /// 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; /// Check if this JWT has expired. public bool IsExpired() { if (Expires == 0) return false; return DateTimeOffset.UtcNow.ToUnixTimeSeconds() > Expires; } /// Return expiry as DateTimeOffset, or null if no expiry. 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`: ```csharp using System.Text.Json.Serialization; namespace NATS.Server.Auth.Jwt; /// /// NATS account JWT claims. Reference: nats-io/jwt/v2 account_claims.go. /// 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 Revocations => Nats?.Revocations ?? new(); [JsonIgnore] public Dictionary? 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? SigningKeys { get; set; } [JsonPropertyName("revocations")] public Dictionary 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** ```bash 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`: ```csharp 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** ```csharp using System.Text.RegularExpressions; namespace NATS.Server.Auth.Jwt; /// /// Expands mustache-style permission templates in JWT subject patterns. /// Reference: Go auth.go:424-520 processUserPermissionsTemplate(). /// public static partial class PermissionTemplates { [GeneratedRegex(@"\{\{([^}]+)\}\}")] private static partial Regex TemplateRegex(); public static List 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 { pattern }; foreach (var (placeholder, values) in replacements) { var next = new List(); foreach (var current in results) { foreach (var value in values) { next.Add(current.Replace(placeholder, value)); } } results = next; } return results; } public static List ExpandAll( string[] patterns, string name, string subject, string accountName, string accountSubject, string[] userTags, string[] accountTags) { var result = new List(); 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** ```bash 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** ```csharp 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** ```csharp using System.Collections.Concurrent; namespace NATS.Server.Auth.Jwt; /// /// Interface for resolving account JWTs by nkey. /// Reference: Go accounts.go:4035+ AccountResolver interface. /// public interface IAccountResolver { Task FetchAsync(string accountNkey); Task StoreAsync(string accountNkey, string jwt); bool IsReadOnly { get; } } /// /// In-memory account resolver for testing and simple deployments. /// public sealed class MemAccountResolver : IAccountResolver { private readonly ConcurrentDictionary _accounts = new(StringComparer.Ordinal); public bool IsReadOnly => false; public Task 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** ```bash 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` — Add `Nkey`, `Issuer`, `SigningKeys`, `RevokedUsers` - Modify: `src/NATS.Server/NatsOptions.cs` — Add `TrustedKeys`, `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** ```csharp 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`: ```csharp public string? Nkey { get; set; } public string? Issuer { get; set; } public Dictionary? SigningKeys { get; set; } private readonly ConcurrentDictionary _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`: ```csharp // JWT / Operator mode public string[]? TrustedKeys { get; set; } public IAccountResolver? AccountResolver { get; set; } ``` Create `src/NATS.Server/Auth/JwtAuthenticator.cs` — Implements `IAuthenticator.Authenticate()`: 1. Check `context.Opts.JWT` is present and is a JWT (`NatsJwt.IsJwt()`) 2. Decode user claims via `NatsJwt.DecodeUserClaims()` 3. Resolve issuer account: `IssuerAccount` or `Issuer` → `AccountResolver.FetchAsync()` → `NatsJwt.DecodeAccountClaims()` 4. Verify account JWT issuer is in `TrustedKeys` (or account signing keys) 5. Verify nonce signature: `NatsJwt.VerifyNonce(nonce, sig, userClaims.Subject)` (skip if BearerToken) 6. Check user revocation: `account.IsUserRevoked(userClaims.Subject, userClaims.IssuedAt)` 7. Validate source IP if `AllowedSources` is non-empty 8. Expand permission templates via `PermissionTemplates.ExpandAll()` 9. Build and return `AuthResult` with 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** ```bash 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** ```csharp 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: 1. Parse source pattern to identify wildcard positions (`*` and `>`) 2. Parse destination template into a list of token operations (literal text, wildcard ref, function call) 3. `Apply(string subject)`: match subject against source, capture wildcards, evaluate destination tokens 4. 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)` 5. FNV-1a hash for `partition` function (matching Go) 6. `$N` syntax 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** ```bash 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` — Add `SubjectMappings` - 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** ```csharp 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`: ```csharp public Dictionary? SubjectMappings { get; set; } ``` In `NatsServer.cs`: - Add `_subjectTransforms: SubjectTransform[]` field - In constructor: compile `_options.SubjectMappings` into `SubjectTransform[]` - In message processing (before `Match()`): apply each transform to find the mapped subject **Step 3: Run tests, verify they pass** **Step 4: Commit** ```bash 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` — Add `OcspConfig`, `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** ```csharp 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** ```csharp namespace NATS.Server.Tls; /// /// OCSP stapling mode. Reference: Go ocsp.go OCSPMode constants. /// 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 } /// /// OCSP configuration for server certificate stapling. /// public sealed class OcspConfig { public OcspMode Mode { get; init; } = OcspMode.Auto; public string[] OverrideUrls { get; init; } = []; } ``` Add to `NatsOptions.cs`: ```csharp 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** ```bash 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` — Build `SslStreamCertificateContext` with OCSP - Modify: `src/NATS.Server/Monitoring/VarzHandler.cs` — Populate `TlsOcspPeerVerify` - **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`: ```csharp // 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`: ```csharp TlsOcspPeerVerify = _options.OcspPeerVerify ``` **Step 2: Run all tests, verify they pass** **Step 3: Commit** ```bash 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` — Add `Microsoft.Extensions.Hosting.WindowsServices` - Modify: `src/NATS.Server.Host/NATS.Server.Host.csproj` — Add package reference - Modify: `src/NATS.Server.Host/Program.cs` — Add `--service` flag + `UseWindowsService()` - No dependencies **Step 1: Add NuGet package** Add to `Directory.Packages.props`: ```xml ``` Add to `NATS.Server.Host.csproj`: ```xml ``` **Step 2: Wire into Program.cs** Add `--service` flag handling in the CLI parsing section: ```csharp case "--service": windowsService = true; break; ``` After building the host, conditionally add Windows Service support: ```csharp if (windowsService && OperatingSystem.IsWindows()) { builder.UseWindowsService(); } ``` **Step 3: Build and verify** Run: `dotnet build` **Step 4: Commit** ```bash 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` — Add `LogOverrides` - Modify: `src/NATS.Server.Host/Program.cs` — Add `--log_level_override` flag + 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`: ```csharp [Fact] public void LogOverrides_defaults_to_null() { var options = new NatsOptions(); options.LogOverrides.ShouldBeNull(); } ``` **Step 2: Implement** Add to `NatsOptions.cs`: ```csharp public Dictionary? LogOverrides { get; set; } ``` Add to `Program.cs` CLI parsing: ```csharp 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: ```csharp if (options.LogOverrides is not null) { foreach (var (ns, level) in options.LogOverrides) { if (Enum.TryParse(level, true, out var serilogLevel)) loggerConfig.MinimumLevel.Override(ns, serilogLevel); } } ``` **Step 3: Run tests, verify they pass** **Step 4: Commit** ```bash 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` — Add `TraceMode` flag - Modify: `src/NATS.Server/NatsClient.cs` — Wire trace flag to parser logger - Test: `tests/NATS.Server.Tests/ClientFlagTests.cs` or add to existing - No dependencies **Step 1: Write failing test** ```csharp [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`: ```csharp TraceMode = 1 << 7, ``` In `NatsClient.cs`, add a method to enable per-client tracing: ```csharp 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** ```bash 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`: ```csharp [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`: ```csharp 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** ```bash 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` — Populate `TlsCertNotAfter` - 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: ```csharp 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** ```bash 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` → `Y` with note about dynamic parser logger **Section 2 (Stats):** - Per-account stats: `N` → `Y` with note about Interlocked counters **Section 3 (Protocol Parsing Gaps):** - Subject mapping: `N` → `Y` with note about SubjectTransform engine **Section 4 (SubList Features):** - No changes (already complete) **Section 5 (Auth Mechanisms):** - JWT validation: `N` → `Y` with note about Ed25519 verification, account resolver - User revocation tracking: `N` → `Y` **Section 5 (Permissions):** - Permission templates: `N` → `Y` with 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` → `Y` with modes **Section 9 (Logging):** - Per-subsystem log control: `N` → `Y` with note about MinimumLevel.Override **Summary section:** - Mark all newly resolved items with strikethrough - Update remaining gaps list **Step 3: Commit** ```bash 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) |