14-task plan covering JWT auth (4 tasks), subject transforms (2 tasks), OCSP support (2 tasks), and quick wins (5 tasks) + differences.md update. Includes parallelization guide, TDD steps, and task persistence.
1807 lines
55 KiB
Markdown
1807 lines
55 KiB
Markdown
# 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<string, object>
|
|
{
|
|
["SABC123"] = new { }
|
|
},
|
|
limits = new
|
|
{
|
|
conn = 100,
|
|
subs = 1000
|
|
},
|
|
revocations = new Dictionary<string, long>
|
|
{
|
|
["UREVOKED1"] = 1700000000
|
|
}
|
|
}
|
|
};
|
|
|
|
var json = System.Text.Json.JsonSerializer.Serialize(claims);
|
|
var header = Base64UrlEncode("{\"alg\":\"ed25519\",\"typ\":\"JWT\"}");
|
|
var payload = Base64UrlEncode(json);
|
|
var sig = Base64UrlEncode("fakesig");
|
|
var token = $"{header}.{payload}.{sig}";
|
|
|
|
var result = NatsJwt.DecodeAccountClaims(token);
|
|
result.ShouldNotBeNull();
|
|
result.Subject.ShouldBe("AABC456");
|
|
result.Issuer.ShouldBe("OABC789");
|
|
result.Name.ShouldBe("testaccount");
|
|
result.Limits.MaxConnections.ShouldBe(100);
|
|
result.Limits.MaxSubscriptions.ShouldBe(1000);
|
|
result.Revocations.ShouldContainKey("UREVOKED1");
|
|
}
|
|
|
|
private static string Base64UrlEncode(string input)
|
|
{
|
|
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
|
|
return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run tests, verify they fail** (NatsJwt class doesn't exist)
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JwtTests" -v normal`
|
|
|
|
**Step 3: Implement NatsJwt, UserClaims, AccountClaims**
|
|
|
|
Create `src/NATS.Server/Auth/Jwt/NatsJwt.cs`:
|
|
|
|
```csharp
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace NATS.Server.Auth.Jwt;
|
|
|
|
public static class NatsJwt
|
|
{
|
|
/// <summary>Detect NATS JWT by "eyJ" prefix (base64url for '{"').</summary>
|
|
public static bool IsJwt(string token) =>
|
|
token.Length > 3 && token.StartsWith("eyJ", StringComparison.Ordinal);
|
|
|
|
/// <summary>Decode JWT into header + raw payload JSON + signature bytes.</summary>
|
|
public static JwtToken? Decode(string token)
|
|
{
|
|
if (string.IsNullOrEmpty(token)) return null;
|
|
var parts = token.Split('.');
|
|
if (parts.Length != 3) return null;
|
|
|
|
try
|
|
{
|
|
var headerJson = Base64UrlDecode(parts[0]);
|
|
var header = JsonSerializer.Deserialize<JwtHeader>(headerJson);
|
|
if (header is null) return null;
|
|
|
|
var payloadBytes = Base64UrlDecode(parts[1]);
|
|
var signatureBytes = Base64UrlDecodeBytes(parts[2]);
|
|
|
|
return new JwtToken
|
|
{
|
|
Header = header,
|
|
PayloadJson = payloadBytes,
|
|
Signature = signatureBytes,
|
|
SigningInput = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}"),
|
|
};
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public static UserClaims? DecodeUserClaims(string token)
|
|
{
|
|
var jwt = Decode(token);
|
|
if (jwt is null) return null;
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<UserClaims>(jwt.PayloadJson, JsonOptions);
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
public static AccountClaims? DecodeAccountClaims(string token)
|
|
{
|
|
var jwt = Decode(token);
|
|
if (jwt is null) return null;
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<AccountClaims>(jwt.PayloadJson, JsonOptions);
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify Ed25519 signature using the subject's public nkey.
|
|
/// Returns true if signature is valid.
|
|
/// </summary>
|
|
public static bool Verify(string token, string publicNkey)
|
|
{
|
|
var jwt = Decode(token);
|
|
if (jwt is null) return false;
|
|
try
|
|
{
|
|
var kp = NATS.NKeys.NKeys.FromPublicKey(publicNkey.AsSpan());
|
|
return kp.Verify(jwt.SigningInput, jwt.Signature);
|
|
}
|
|
catch { return false; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify Ed25519 signature of nonce bytes using public nkey.
|
|
/// Used for CONNECT sig verification. Accepts base64 or base64url sig.
|
|
/// </summary>
|
|
public static bool VerifyNonce(byte[] nonce, string signature, string publicNkey)
|
|
{
|
|
try
|
|
{
|
|
// Try base64url first, then standard base64 (Go compat: auth.go:1002-1009)
|
|
byte[] sigBytes;
|
|
try { sigBytes = Base64UrlDecodeBytes(signature); }
|
|
catch { sigBytes = Convert.FromBase64String(signature); }
|
|
|
|
var kp = NATS.NKeys.NKeys.FromPublicKey(publicNkey.AsSpan());
|
|
return kp.Verify(nonce, sigBytes);
|
|
}
|
|
catch { return false; }
|
|
}
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
};
|
|
|
|
private static string Base64UrlDecode(string input)
|
|
{
|
|
var padded = input.Replace('-', '+').Replace('_', '/');
|
|
switch (padded.Length % 4)
|
|
{
|
|
case 2: padded += "=="; break;
|
|
case 3: padded += "="; break;
|
|
}
|
|
return Encoding.UTF8.GetString(Convert.FromBase64String(padded));
|
|
}
|
|
|
|
private static byte[] Base64UrlDecodeBytes(string input)
|
|
{
|
|
var padded = input.Replace('-', '+').Replace('_', '/');
|
|
switch (padded.Length % 4)
|
|
{
|
|
case 2: padded += "=="; break;
|
|
case 3: padded += "="; break;
|
|
}
|
|
return Convert.FromBase64String(padded);
|
|
}
|
|
}
|
|
|
|
public sealed class JwtToken
|
|
{
|
|
public required JwtHeader Header { get; init; }
|
|
public required string PayloadJson { get; init; }
|
|
public required byte[] Signature { get; init; }
|
|
public required byte[] SigningInput { get; init; }
|
|
}
|
|
|
|
public sealed class JwtHeader
|
|
{
|
|
[JsonPropertyName("alg")]
|
|
public string Algorithm { get; set; } = "";
|
|
|
|
[JsonPropertyName("typ")]
|
|
public string Type { get; set; } = "";
|
|
}
|
|
```
|
|
|
|
Create `src/NATS.Server/Auth/Jwt/UserClaims.cs`:
|
|
|
|
```csharp
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace NATS.Server.Auth.Jwt;
|
|
|
|
/// <summary>
|
|
/// NATS user JWT claims. Reference: nats-io/jwt/v2 user_claims.go.
|
|
/// </summary>
|
|
public sealed class UserClaims
|
|
{
|
|
[JsonPropertyName("sub")]
|
|
public string Subject { get; set; } = "";
|
|
|
|
[JsonPropertyName("iss")]
|
|
public string Issuer { get; set; } = "";
|
|
|
|
[JsonPropertyName("iat")]
|
|
public long IssuedAt { get; set; }
|
|
|
|
[JsonPropertyName("exp")]
|
|
public long Expires { get; set; }
|
|
|
|
[JsonPropertyName("name")]
|
|
public string Name { get; set; } = "";
|
|
|
|
[JsonPropertyName("nats")]
|
|
public UserClaimsNats? Nats { get; set; }
|
|
|
|
// Convenience accessors that delegate to Nats
|
|
[JsonIgnore] public string IssuerAccount => Nats?.IssuerAccount ?? "";
|
|
[JsonIgnore] public bool BearerToken => Nats?.BearerToken ?? false;
|
|
[JsonIgnore] public SubjectPermission? Publish => Nats?.Pub;
|
|
[JsonIgnore] public SubjectPermission? Subscribe => Nats?.Sub;
|
|
[JsonIgnore] public ResponsePermission? Response => Nats?.Resp;
|
|
[JsonIgnore] public string[] Tags => Nats?.Tags ?? [];
|
|
[JsonIgnore] public string[] AllowedSources => Nats?.Src ?? [];
|
|
[JsonIgnore] public string[] AllowedConnectionTypes => Nats?.AllowedConnectionTypes ?? [];
|
|
[JsonIgnore] public TimeRange[]? Times => Nats?.Times;
|
|
|
|
/// <summary>Check if this JWT has expired.</summary>
|
|
public bool IsExpired()
|
|
{
|
|
if (Expires == 0) return false;
|
|
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() > Expires;
|
|
}
|
|
|
|
/// <summary>Return expiry as DateTimeOffset, or null if no expiry.</summary>
|
|
public DateTimeOffset? GetExpiry()
|
|
{
|
|
if (Expires == 0) return null;
|
|
return DateTimeOffset.FromUnixTimeSeconds(Expires);
|
|
}
|
|
}
|
|
|
|
public sealed class UserClaimsNats
|
|
{
|
|
[JsonPropertyName("pub")]
|
|
public SubjectPermission? Pub { get; set; }
|
|
|
|
[JsonPropertyName("sub")]
|
|
public SubjectPermission? Sub { get; set; }
|
|
|
|
[JsonPropertyName("resp")]
|
|
public ResponsePermission? Resp { get; set; }
|
|
|
|
[JsonPropertyName("bearer_token")]
|
|
public bool BearerToken { get; set; }
|
|
|
|
[JsonPropertyName("issuer_account")]
|
|
public string IssuerAccount { get; set; } = "";
|
|
|
|
[JsonPropertyName("type")]
|
|
public string Type { get; set; } = "";
|
|
|
|
[JsonPropertyName("version")]
|
|
public int Version { get; set; }
|
|
|
|
[JsonPropertyName("tags")]
|
|
public string[] Tags { get; set; } = [];
|
|
|
|
[JsonPropertyName("src")]
|
|
public string[] Src { get; set; } = [];
|
|
|
|
[JsonPropertyName("allowed_connection_types")]
|
|
public string[] AllowedConnectionTypes { get; set; } = [];
|
|
|
|
[JsonPropertyName("times")]
|
|
public TimeRange[]? Times { get; set; }
|
|
}
|
|
|
|
public sealed class SubjectPermission
|
|
{
|
|
[JsonPropertyName("allow")]
|
|
public string[] Allow { get; set; } = [];
|
|
|
|
[JsonPropertyName("deny")]
|
|
public string[] Deny { get; set; } = [];
|
|
}
|
|
|
|
public sealed class ResponsePermission
|
|
{
|
|
[JsonPropertyName("max")]
|
|
public int MaxMsgs { get; set; } = 1;
|
|
|
|
[JsonPropertyName("ttl")]
|
|
public long TtlNanos { get; set; }
|
|
|
|
[JsonIgnore]
|
|
public TimeSpan Ttl => TimeSpan.FromTicks(TtlNanos / 100); // nanos to ticks
|
|
}
|
|
|
|
public sealed class TimeRange
|
|
{
|
|
[JsonPropertyName("start")]
|
|
public string Start { get; set; } = "";
|
|
|
|
[JsonPropertyName("end")]
|
|
public string End { get; set; } = "";
|
|
}
|
|
```
|
|
|
|
Create `src/NATS.Server/Auth/Jwt/AccountClaims.cs`:
|
|
|
|
```csharp
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace NATS.Server.Auth.Jwt;
|
|
|
|
/// <summary>
|
|
/// NATS account JWT claims. Reference: nats-io/jwt/v2 account_claims.go.
|
|
/// </summary>
|
|
public sealed class AccountClaims
|
|
{
|
|
[JsonPropertyName("sub")]
|
|
public string Subject { get; set; } = "";
|
|
|
|
[JsonPropertyName("iss")]
|
|
public string Issuer { get; set; } = "";
|
|
|
|
[JsonPropertyName("iat")]
|
|
public long IssuedAt { get; set; }
|
|
|
|
[JsonPropertyName("exp")]
|
|
public long Expires { get; set; }
|
|
|
|
[JsonPropertyName("name")]
|
|
public string Name { get; set; } = "";
|
|
|
|
[JsonPropertyName("nats")]
|
|
public AccountClaimsNats? Nats { get; set; }
|
|
|
|
[JsonIgnore] public AccountLimits Limits => Nats?.Limits ?? new();
|
|
[JsonIgnore] public Dictionary<string, long> Revocations => Nats?.Revocations ?? new();
|
|
[JsonIgnore] public Dictionary<string, object>? SigningKeys => Nats?.SigningKeys;
|
|
}
|
|
|
|
public sealed class AccountClaimsNats
|
|
{
|
|
[JsonPropertyName("type")]
|
|
public string Type { get; set; } = "";
|
|
|
|
[JsonPropertyName("version")]
|
|
public int Version { get; set; }
|
|
|
|
[JsonPropertyName("limits")]
|
|
public AccountLimits Limits { get; set; } = new();
|
|
|
|
[JsonPropertyName("signing_keys")]
|
|
public Dictionary<string, object>? SigningKeys { get; set; }
|
|
|
|
[JsonPropertyName("revocations")]
|
|
public Dictionary<string, long> Revocations { get; set; } = new();
|
|
}
|
|
|
|
public sealed class AccountLimits
|
|
{
|
|
[JsonPropertyName("conn")]
|
|
public int MaxConnections { get; set; }
|
|
|
|
[JsonPropertyName("subs")]
|
|
public int MaxSubscriptions { get; set; }
|
|
|
|
[JsonPropertyName("payload")]
|
|
public long MaxPayload { get; set; }
|
|
|
|
[JsonPropertyName("data")]
|
|
public long MaxData { get; set; }
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests, verify they pass**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JwtTests" -v normal`
|
|
|
|
**Step 5: Commit**
|
|
|
|
```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;
|
|
|
|
/// <summary>
|
|
/// Expands mustache-style permission templates in JWT subject patterns.
|
|
/// Reference: Go auth.go:424-520 processUserPermissionsTemplate().
|
|
/// </summary>
|
|
public static partial class PermissionTemplates
|
|
{
|
|
[GeneratedRegex(@"\{\{([^}]+)\}\}")]
|
|
private static partial Regex TemplateRegex();
|
|
|
|
public static List<string> Expand(
|
|
string pattern,
|
|
string name, string subject,
|
|
string accountName, string accountSubject,
|
|
string[] userTags, string[] accountTags)
|
|
{
|
|
var matches = TemplateRegex().Matches(pattern);
|
|
if (matches.Count == 0)
|
|
return [pattern];
|
|
|
|
// Build list of (placeholder, values[]) for each template in the pattern
|
|
var replacements = new List<(string Placeholder, string[] Values)>();
|
|
foreach (Match match in matches)
|
|
{
|
|
var expr = match.Groups[1].Value.Trim();
|
|
var values = ResolveTemplate(expr, name, subject, accountName, accountSubject, userTags, accountTags);
|
|
if (values.Length == 0)
|
|
return []; // No values for a required tag → deny all
|
|
replacements.Add((match.Value, values));
|
|
}
|
|
|
|
// Cartesian product of all multi-value replacements
|
|
var results = new List<string> { pattern };
|
|
foreach (var (placeholder, values) in replacements)
|
|
{
|
|
var next = new List<string>();
|
|
foreach (var current in results)
|
|
{
|
|
foreach (var value in values)
|
|
{
|
|
next.Add(current.Replace(placeholder, value));
|
|
}
|
|
}
|
|
results = next;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
public static List<string> ExpandAll(
|
|
string[] patterns,
|
|
string name, string subject,
|
|
string accountName, string accountSubject,
|
|
string[] userTags, string[] accountTags)
|
|
{
|
|
var result = new List<string>();
|
|
foreach (var pattern in patterns)
|
|
result.AddRange(Expand(pattern, name, subject, accountName, accountSubject, userTags, accountTags));
|
|
return result;
|
|
}
|
|
|
|
private static string[] ResolveTemplate(
|
|
string expr,
|
|
string name, string subject,
|
|
string accountName, string accountSubject,
|
|
string[] userTags, string[] accountTags)
|
|
{
|
|
return expr switch
|
|
{
|
|
"name()" => [name],
|
|
"subject()" => [subject],
|
|
"account-name()" => [accountName],
|
|
"account-subject()" => [accountSubject],
|
|
_ when expr.StartsWith("tag(") && expr.EndsWith(")") =>
|
|
ResolveTags(expr[4..^1], userTags),
|
|
_ when expr.StartsWith("account-tag(") && expr.EndsWith(")") =>
|
|
ResolveTags(expr[12..^1], accountTags),
|
|
_ => []
|
|
};
|
|
}
|
|
|
|
private static string[] ResolveTags(string tagName, string[] tags)
|
|
{
|
|
var prefix = tagName + ":";
|
|
var values = tags
|
|
.Where(t => t.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
|
.Select(t => t[prefix.Length..])
|
|
.ToArray();
|
|
return values;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests, verify they pass**
|
|
|
|
**Step 5: Commit**
|
|
|
|
```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;
|
|
|
|
/// <summary>
|
|
/// Interface for resolving account JWTs by nkey.
|
|
/// Reference: Go accounts.go:4035+ AccountResolver interface.
|
|
/// </summary>
|
|
public interface IAccountResolver
|
|
{
|
|
Task<string?> FetchAsync(string accountNkey);
|
|
Task StoreAsync(string accountNkey, string jwt);
|
|
bool IsReadOnly { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// In-memory account resolver for testing and simple deployments.
|
|
/// </summary>
|
|
public sealed class MemAccountResolver : IAccountResolver
|
|
{
|
|
private readonly ConcurrentDictionary<string, string> _accounts = new(StringComparer.Ordinal);
|
|
|
|
public bool IsReadOnly => false;
|
|
|
|
public Task<string?> FetchAsync(string accountNkey)
|
|
{
|
|
_accounts.TryGetValue(accountNkey, out var jwt);
|
|
return Task.FromResult(jwt);
|
|
}
|
|
|
|
public Task StoreAsync(string accountNkey, string jwt)
|
|
{
|
|
_accounts[accountNkey] = jwt;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests, verify they pass**
|
|
|
|
**Step 5: Commit**
|
|
|
|
```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<string, object>? SigningKeys { get; set; }
|
|
private readonly ConcurrentDictionary<string, long> _revokedUsers = new(StringComparer.Ordinal);
|
|
|
|
public void RevokeUser(string userNkey, long issuedAt) => _revokedUsers[userNkey] = issuedAt;
|
|
|
|
public bool IsUserRevoked(string userNkey, long issuedAt)
|
|
{
|
|
if (_revokedUsers.TryGetValue(userNkey, out var revokedAt))
|
|
return issuedAt <= revokedAt;
|
|
return false;
|
|
}
|
|
```
|
|
|
|
Add to `NatsOptions.cs`:
|
|
```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<string, string>? 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;
|
|
|
|
/// <summary>
|
|
/// OCSP stapling mode. Reference: Go ocsp.go OCSPMode constants.
|
|
/// </summary>
|
|
public enum OcspMode
|
|
{
|
|
Auto = 0, // Staple if cert has status_request extension
|
|
Always = 1, // Always staple, warn if cannot get status
|
|
Must = 2, // Must staple, fail startup if cannot get status
|
|
Never = 3, // Never attempt stapling
|
|
}
|
|
|
|
/// <summary>
|
|
/// OCSP configuration for server certificate stapling.
|
|
/// </summary>
|
|
public sealed class OcspConfig
|
|
{
|
|
public OcspMode Mode { get; init; } = OcspMode.Auto;
|
|
public string[] OverrideUrls { get; init; } = [];
|
|
}
|
|
```
|
|
|
|
Add to `NatsOptions.cs`:
|
|
```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
|
|
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
|
|
```
|
|
|
|
Add to `NATS.Server.Host.csproj`:
|
|
```xml
|
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
|
|
```
|
|
|
|
**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<string, string>? 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<Serilog.Events.LogEventLevel>(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) |
|