diff --git a/docs/plans/2026-02-23-remaining-gaps-plan.md b/docs/plans/2026-02-23-remaining-gaps-plan.md new file mode 100644 index 0000000..df1375f --- /dev/null +++ b/docs/plans/2026-02-23-remaining-gaps-plan.md @@ -0,0 +1,1806 @@ +# 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) | diff --git a/docs/plans/2026-02-23-remaining-gaps-plan.md.tasks.json b/docs/plans/2026-02-23-remaining-gaps-plan.md.tasks.json new file mode 100644 index 0000000..130c5de --- /dev/null +++ b/docs/plans/2026-02-23-remaining-gaps-plan.md.tasks.json @@ -0,0 +1,20 @@ +{ + "planPath": "docs/plans/2026-02-23-remaining-gaps-plan.md", + "tasks": [ + {"id": 1, "subject": "Task 1: JWT Core — Decode/Verify + Claim Structs", "status": "pending"}, + {"id": 2, "subject": "Task 2: Permission Templates", "status": "pending", "blockedBy": [1]}, + {"id": 3, "subject": "Task 3: Account Resolver", "status": "pending"}, + {"id": 4, "subject": "Task 4: JwtAuthenticator — Wire JWT into Auth", "status": "pending", "blockedBy": [1, 2, 3]}, + {"id": 5, "subject": "Task 5: Subject Transform — Core Engine", "status": "pending"}, + {"id": 6, "subject": "Task 6: Wire Subject Transforms into Delivery", "status": "pending", "blockedBy": [5]}, + {"id": 7, "subject": "Task 7: OCSP Config and Peer Verification", "status": "pending"}, + {"id": 8, "subject": "Task 8: OCSP Stapling", "status": "pending", "blockedBy": [7]}, + {"id": 9, "subject": "Task 9: Windows Service Integration", "status": "pending"}, + {"id": 10, "subject": "Task 10: Per-Subsystem Log Control", "status": "pending"}, + {"id": 11, "subject": "Task 11: Per-Client Trace Mode", "status": "pending"}, + {"id": 12, "subject": "Task 12: Per-Account Stats", "status": "pending"}, + {"id": 13, "subject": "Task 13: TLS Cert Expiry in /varz", "status": "pending"}, + {"id": 14, "subject": "Task 14: Update differences.md", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]} + ], + "lastUpdated": "2026-02-23T00:00:00Z" +}