Files
natsnet/docs/plans/2026-02-26-complete-stub-features.md
2026-02-26 16:59:33 -05:00

57 KiB

Complete 93 Stub Features — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Implement all 93 stub features across opts.go (config binding) and auth.go / jwt.go / auth_callout.go / signal.go (auth + signals).

Architecture: Two parallel sessions — Session A binds appsettings.json to ServerOptions using Microsoft.Extensions.Configuration; Session B implements the full authentication dispatch in NatsServer.Auth.cs plus JWT operator validation, auth callouts, and OS signal handling.

Tech Stack: .NET 10 / C# 13, System.Text.Json, Microsoft.Extensions.Configuration.Json, BCrypt.Net-Next (already in csproj), NATS.NKeys (to be added), System.Security.Cryptography.X509Certificates, System.Runtime.InteropServices.PosixSignalRegistration


SESSION A — Configuration Binding (67 stubs, opts.go)

Task A1: Add JSON attributes to ServerOptions.cs

Files:

  • Modify: dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs

The existing ServerOptions class has C# PascalCase property names. JSON binding needs [JsonPropertyName] attributes so that "port", "cluster", "jetstream" etc. map correctly.

Step 1: Add the attribute block

In ServerOptions.cs, add using System.Text.Json.Serialization; at the top, then add [JsonPropertyName] to every property whose JSON key differs from its C# name. Key mappings (Go config key → C# property):

[JsonPropertyName("port")]            public int    Port                { get; set; }
[JsonPropertyName("host")]            public string Host                { get; set; } = string.Empty;
[JsonPropertyName("server_name")]     public string ServerName          { get; set; } = string.Empty;
[JsonPropertyName("client_advertise")]public string ClientAdvertise     { get; set; } = string.Empty;
[JsonPropertyName("pid_file")]        public string PidFile             { get; set; } = string.Empty;
[JsonPropertyName("ports_file_dir")]  public string PortsFileDir        { get; set; } = string.Empty;
[JsonPropertyName("trace")]           public bool   Trace               { get; set; }
[JsonPropertyName("debug")]           public bool   Debug               { get; set; }
[JsonPropertyName("logfile")]         public string LogFile             { get; set; } = string.Empty;
[JsonPropertyName("log_size_limit")]  public long   LogSizeLimit        { get; set; }
[JsonPropertyName("max_connections")] public int    MaxConn             { get; set; }
[JsonPropertyName("max_payload")]     public int    MaxPayload          { get; set; }
[JsonPropertyName("max_pending")]     public long   MaxPending          { get; set; }
[JsonPropertyName("ping_interval")]   public TimeSpan PingInterval      { get; set; }
[JsonPropertyName("ping_max")]        public int    MaxPingsOut         { get; set; }
[JsonPropertyName("write_deadline")]  public TimeSpan WriteDeadline     { get; set; }
[JsonPropertyName("lame_duck_duration")]    public TimeSpan LameDuckDuration    { get; set; }
[JsonPropertyName("lame_duck_grace_period")] public TimeSpan LameDuckGracePeriod { get; set; }
[JsonPropertyName("http_port")]       public int    HttpPort            { get; set; }
[JsonPropertyName("https_port")]      public int    HttpsPort           { get; set; }
[JsonPropertyName("http_base_path")]  public string HttpBasePath        { get; set; } = string.Empty;
[JsonPropertyName("username")]        public string Username            { get; set; } = string.Empty;
[JsonPropertyName("password")]        public string Password            { get; set; } = string.Empty;
[JsonPropertyName("authorization")]   public string Authorization       { get; set; } = string.Empty;
[JsonPropertyName("auth_timeout")]    public double AuthTimeout         { get; set; }
[JsonPropertyName("no_auth_user")]    public string NoAuthUser          { get; set; } = string.Empty;
[JsonPropertyName("system_account")]  public string SystemAccount       { get; set; } = string.Empty;
[JsonPropertyName("accounts")]        public List<Account> Accounts     { get; set; } = [];
[JsonPropertyName("cluster")]         public object? ClusterOpts        { get; set; }  // ClusterOpts - leave as object for JSON
[JsonPropertyName("gateway")]         public object? GatewayOpts        { get; set; }  // GatewayOpts
[JsonPropertyName("leafnodes")]       public object? LeafNodeOpts       { get; set; }  // LeafNodeOpts
[JsonPropertyName("jetstream")]       public bool   JetStream           { get; set; }
[JsonPropertyName("store_dir")]       public string StoreDir            { get; set; } = string.Empty;

Note: Only add attributes for properties that need non-default name mapping. Properties whose JSON key exactly matches the C# property name (case-insensitive) don't need attributes.

Step 2: Build to verify no errors

dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -3

Expected: 0 Error(s)

Step 3: Commit

git add dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs
git commit -m "feat: add JsonPropertyName attributes to ServerOptions for appsettings.json binding"

Task A2: Create Config/NatsJsonConverters.cs

Files:

  • Create: dotnet/src/ZB.MOM.NatsNet.Server/Config/NatsJsonConverters.cs

Four custom JsonConverter<T> implementations that replace the Go parse* utility functions.

Step 1: Write the file

// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from parse utility functions in server/opts.go in the NATS server Go source.

using System.Net.Security;
using System.Security.Authentication;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;

namespace ZB.MOM.NatsNet.Server.Config;

/// <summary>
/// Converts NATS duration strings (e.g. "2s", "100ms", "1h30m") to <see cref="TimeSpan"/>.
/// Mirrors Go <c>parseDuration</c> in server/opts.go.
/// </summary>
public sealed class NatsDurationJsonConverter : JsonConverter<TimeSpan>
{
    private static readonly Regex Pattern = new(
        @"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?(?:(\d+)ms)?(?:(\d+)us)?(?:(\d+)ns)?$",
        RegexOptions.Compiled | RegexOptions.IgnoreCase);

    public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var raw = reader.GetString() ?? throw new JsonException("Expected a duration string");
        return Parse(raw);
    }

    public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
        => writer.WriteStringValue(FormatDuration(value));

    /// <summary>
    /// Parses a NATS-style duration string. Accepts Go time.Duration format strings and ISO 8601.
    /// </summary>
    public static TimeSpan Parse(string s)
    {
        if (string.IsNullOrWhiteSpace(s))
            throw new FormatException("Duration string is empty");

        // Try Go-style: e.g. "2s", "100ms", "1h30m", "5m10s"
        var m = Pattern.Match(s);
        if (m.Success && m.Value.Length > 0)
        {
            var hours   = m.Groups[1].Success ? int.Parse(m.Groups[1].Value) : 0;
            var minutes = m.Groups[2].Success ? int.Parse(m.Groups[2].Value) : 0;
            var seconds = m.Groups[3].Success ? int.Parse(m.Groups[3].Value) : 0;
            var ms      = m.Groups[4].Success ? int.Parse(m.Groups[4].Value) : 0;
            var us      = m.Groups[5].Success ? int.Parse(m.Groups[5].Value) : 0;
            var ns      = m.Groups[6].Success ? int.Parse(m.Groups[6].Value) : 0;
            return new TimeSpan(0, hours, minutes, seconds, ms)
                + TimeSpan.FromMicroseconds(us)
                + TimeSpan.FromTicks(ns / 100); // 1 tick = 100 ns
        }

        // Try .NET TimeSpan.Parse (handles "hh:mm:ss")
        if (TimeSpan.TryParse(s, out var ts)) return ts;

        throw new FormatException($"Cannot parse duration string: \"{s}\"");
    }

    private static string FormatDuration(TimeSpan ts)
    {
        if (ts.TotalMilliseconds < 1) return $"{(long)(ts.TotalNanoseconds())}ns";
        if (ts.TotalSeconds < 1) return $"{(long)ts.TotalMilliseconds}ms";
        if (ts.TotalMinutes < 1) return $"{(long)ts.TotalSeconds}s";
        if (ts.TotalHours < 1)   return $"{ts.Minutes}m{ts.Seconds}s";
        return $"{(int)ts.TotalHours}h{ts.Minutes}m{ts.Seconds}s";
    }
}

// Extension method to support TotalNanoseconds on older .NET targets
internal static class TimeSpanExtensions
{
    internal static double TotalNanoseconds(this TimeSpan ts) => ts.Ticks * 100.0;
}

/// <summary>
/// Converts a TLS version string ("1.2", "1.3", "TLS12") to <see cref="SslProtocols"/>.
/// Mirrors Go <c>parseTLSVersion</c> in server/opts.go.
/// </summary>
public sealed class TlsVersionJsonConverter : JsonConverter<SslProtocols>
{
    public override SslProtocols Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var raw = reader.GetString()?.Trim() ?? string.Empty;
        return Parse(raw);
    }

    public override void Write(Utf8JsonWriter writer, SslProtocols value, JsonSerializerOptions options)
        => writer.WriteStringValue(value.ToString());

    public static SslProtocols Parse(string s) => s.ToUpperInvariant() switch
    {
        "1.2" or "TLS12" or "TLSV1.2" => SslProtocols.Tls12,
        "1.3" or "TLS13" or "TLSV1.3" => SslProtocols.Tls13,
        _ => throw new FormatException($"Unknown TLS version: \"{s}\""),
    };
}

/// <summary>
/// Validates and normalises a NATS URL string (nats://host:port).
/// Mirrors Go <c>parseURL</c> in server/opts.go.
/// </summary>
public sealed class NatsUrlJsonConverter : JsonConverter<string>
{
    public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var raw = reader.GetString() ?? string.Empty;
        return Normalise(raw);
    }

    public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
        => writer.WriteStringValue(value);

    public static string Normalise(string url)
    {
        if (string.IsNullOrWhiteSpace(url)) return url;
        url = url.Trim();
        if (!url.Contains("://")) url = "nats://" + url;
        if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
            throw new FormatException($"Invalid NATS URL: \"{url}\"");
        return uri.ToString().TrimEnd('/');
    }
}

/// <summary>
/// Converts a storage size string ("1GB", "512MB", "1024") to a byte count (long).
/// Mirrors Go <c>getStorageSize</c> in server/opts.go.
/// </summary>
public sealed class StorageSizeJsonConverter : JsonConverter<long>
{
    private static readonly Regex Pattern = new(@"^(\d+(?:\.\d+)?)\s*([KMGT]?B?)?$",
        RegexOptions.Compiled | RegexOptions.IgnoreCase);

    public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType == JsonTokenType.Number)
        {
            return reader.GetInt64();
        }
        var raw = reader.GetString() ?? "0";
        return Parse(raw);
    }

    public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options)
        => writer.WriteNumberValue(value);

    public static long Parse(string s)
    {
        if (long.TryParse(s, out var n)) return n;
        var m = Pattern.Match(s.Trim());
        if (!m.Success) throw new FormatException($"Invalid storage size: \"{s}\"");
        var num = double.Parse(m.Groups[1].Value);
        var suffix = m.Groups[2].Value.ToUpperInvariant();
        return suffix switch
        {
            "K" or "KB" => (long)(num * 1024),
            "M" or "MB" => (long)(num * 1024 * 1024),
            "G" or "GB" => (long)(num * 1024 * 1024 * 1024),
            "T" or "TB" => (long)(num * 1024L * 1024 * 1024 * 1024),
            _            => (long)num,
        };
    }
}

Step 2: Build to verify

dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -3

Expected: 0 Error(s)


Task A3: Create Config/ServerOptionsConfiguration.cs

Files:

  • Create: dotnet/src/ZB.MOM.NatsNet.Server/Config/ServerOptionsConfiguration.cs

This is the main entry point for all 67 opts.go stubs — it replaces ProcessConfigFile, processConfigFileLine, and all parse* functions.

Step 1: Write the file

// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// ...
// Adapted from server/opts.go in the NATS server Go source.

using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;

namespace ZB.MOM.NatsNet.Server.Config;

/// <summary>
/// Loads and binds NATS server configuration from an <c>appsettings.json</c> file.
/// Replaces the Go <c>processConfigFile</c> / <c>processConfigFileLine</c> pipeline
/// and all <c>parse*</c> helper functions in server/opts.go.
/// </summary>
public static class ServerOptionsConfiguration
{
    /// <summary>
    /// Creates a <see cref="JsonSerializerOptions"/> instance pre-configured with all
    /// NATS-specific JSON converters.
    /// </summary>
    public static JsonSerializerOptions CreateJsonOptions()
    {
        var opts = new JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true,
            AllowTrailingCommas = true,
            ReadCommentHandling = JsonCommentHandling.Skip,
            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        };
        opts.Converters.Add(new NatsDurationJsonConverter());
        opts.Converters.Add(new TlsVersionJsonConverter());
        opts.Converters.Add(new NatsUrlJsonConverter());
        opts.Converters.Add(new StorageSizeJsonConverter());
        return opts;
    }

    /// <summary>
    /// Reads a JSON file at <paramref name="path"/> and returns a bound
    /// <see cref="ServerOptions"/> instance.
    /// Mirrors Go <c>ProcessConfigFile</c> and <c>Options.ProcessConfigFile</c>.
    /// </summary>
    public static ServerOptions ProcessConfigFile(string path)
    {
        if (!File.Exists(path))
            throw new FileNotFoundException($"Configuration file not found: {path}", path);

        var json = File.ReadAllText(path, Encoding.UTF8);
        return ProcessConfigString(json);
    }

    /// <summary>
    /// Deserialises a JSON string and returns a bound <see cref="ServerOptions"/> instance.
    /// Mirrors Go <c>Options.ProcessConfigString</c>.
    /// </summary>
    public static ServerOptions ProcessConfigString(string json)
    {
        ArgumentNullException.ThrowIfNullOrEmpty(json);
        var opts = JsonSerializer.Deserialize<ServerOptions>(json, CreateJsonOptions())
            ?? new ServerOptions();
        opts.ConfigFile = string.Empty; // set by caller if needed
        PostProcess(opts);
        return opts;
    }

    /// <summary>
    /// Binds a pre-built <see cref="IConfiguration"/> (e.g. from an ASP.NET Core host)
    /// to a <see cref="ServerOptions"/> instance.
    /// The configuration section should be the root or a named section such as "NatsServer".
    /// </summary>
    public static void BindConfiguration(IConfiguration config, ServerOptions target)
    {
        ArgumentNullException.ThrowIfNull(config);
        ArgumentNullException.ThrowIfNull(target);
        config.Bind(target);
        PostProcess(target);
    }

    // -------------------------------------------------------------------------
    // Post-processing
    // -------------------------------------------------------------------------

    /// <summary>
    /// Applies defaults and cross-field validation after loading.
    /// Mirrors the end of <c>Options.processConfigFile</c> and
    /// <c>configureSystemAccount</c> in server/opts.go.
    /// </summary>
    private static void PostProcess(ServerOptions opts)
    {
        // Apply default port if not set.
        if (opts.Port == 0) opts.Port = ServerConstants.DefaultPort;

        // Apply default host if not set.
        if (string.IsNullOrEmpty(opts.Host)) opts.Host = ServerConstants.DefaultHost;

        // Apply default max payload.
        if (opts.MaxPayload == 0) opts.MaxPayload = ServerConstants.MaxPayload;

        // Apply default auth timeout.
        if (opts.AuthTimeout == 0) opts.AuthTimeout = ServerConstants.AuthTimeout;

        // Apply default max control line.
        if (opts.MaxControlLine == 0) opts.MaxControlLine = ServerConstants.MaxControlLineSize;

        // Ensure SystemAccount defaults if not set.
        ConfigureSystemAccount(opts);
    }

    /// <summary>
    /// Sets up the system account name from options.
    /// Mirrors Go <c>configureSystemAccount</c> in server/opts.go.
    /// </summary>
    private static void ConfigureSystemAccount(ServerOptions opts)
    {
        if (!string.IsNullOrEmpty(opts.SystemAccount)) return;
        if (!opts.NoSystemAccount)
        {
            opts.SystemAccount = ServerConstants.DefaultSystemAccountName;
        }
    }
}

Step 2: Check if ServerConstants has DefaultPort, DefaultHost, DefaultSystemAccountName

grep -n "DefaultPort\|DefaultHost\|DefaultSystemAccountName\|AuthTimeout\|MaxControlLineSize\|MaxPayload" \
  /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ServerConstants.cs 2>/dev/null | head -20

Add any missing constants to ServerConstants.cs.

Step 3: Build to verify

dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -3

Expected: 0 Error(s)


Task A4: Write failing tests for config binding (TDD red)

Files:

  • Create: dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Config/ServerOptionsConfigurationTests.cs

Step 1: Create the test file

// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0
namespace ZB.MOM.NatsNet.Server.Tests.Config;

using ZB.MOM.NatsNet.Server.Config;
using Shouldly;
using Xunit;

public class ServerOptionsConfigurationTests
{
    [Fact]
    public void ProcessConfigString_MinimalJson_SetsPort()
    {
        var opts = ServerOptionsConfiguration.ProcessConfigString("""{"port": 4222}""");
        opts.Port.ShouldBe(4222);
    }

    [Fact]
    public void ProcessConfigString_EmptyJson_AppliesDefaults()
    {
        var opts = ServerOptionsConfiguration.ProcessConfigString("{}");
        opts.Port.ShouldBe(ServerConstants.DefaultPort);
        opts.Host.ShouldBe(ServerConstants.DefaultHost);
        opts.MaxPayload.ShouldBe(ServerConstants.MaxPayload);
    }

    [Fact]
    public void ProcessConfigString_AllBasicFields_Roundtrip()
    {
        var json = """
            {
              "port": 5222,
              "host": "127.0.0.1",
              "server_name": "test-server",
              "debug": true,
              "trace": true,
              "max_connections": 100,
              "auth_timeout": 2.0
            }
            """;
        var opts = ServerOptionsConfiguration.ProcessConfigString(json);
        opts.Port.ShouldBe(5222);
        opts.Host.ShouldBe("127.0.0.1");
        opts.ServerName.ShouldBe("test-server");
        opts.Debug.ShouldBeTrue();
        opts.Trace.ShouldBeTrue();
        opts.MaxConn.ShouldBe(100);
        opts.AuthTimeout.ShouldBe(2.0);
    }

    [Fact]
    public void ProcessConfigFile_FileNotFound_Throws()
    {
        Should.Throw<FileNotFoundException>(() =>
            ServerOptionsConfiguration.ProcessConfigFile("/nonexistent/path.json"));
    }

    [Fact]
    public void ProcessConfigFile_ValidFile_ReturnsOptions()
    {
        var tmpFile = Path.GetTempFileName();
        File.WriteAllText(tmpFile, """{"port": 9090, "server_name": "from-file"}""");
        try
        {
            var opts = ServerOptionsConfiguration.ProcessConfigFile(tmpFile);
            opts.Port.ShouldBe(9090);
            opts.ServerName.ShouldBe("from-file");
        }
        finally { File.Delete(tmpFile); }
    }
}

public class NatsDurationJsonConverterTests
{
    [Theory]
    [InlineData("2s",    2,    0,    0)]
    [InlineData("100ms", 0,    0,    100)]
    [InlineData("1h30m", 1,    30,   0)]
    [InlineData("5m10s", 0,    5,    10 * 1000)]
    public void Parse_ValidDurationStrings_ReturnsCorrectTimeSpan(
        string input, int hours, int minutes, int ms)
    {
        var expected = new TimeSpan(0, hours, minutes, 0, ms);
        // "5m10s" needs special handling: 5min + 10sec
        if (input == "5m10s") expected = TimeSpan.FromSeconds(310);
        NatsDurationJsonConverter.Parse(input).ShouldBe(expected);
    }

    [Fact]
    public void Parse_InvalidString_ThrowsFormatException()
    {
        Should.Throw<FormatException>(() => NatsDurationJsonConverter.Parse("notaduration"));
    }
}

public class StorageSizeJsonConverterTests
{
    [Theory]
    [InlineData("1GB",  1L * 1024 * 1024 * 1024)]
    [InlineData("512MB", 512L * 1024 * 1024)]
    [InlineData("1KB",  1024L)]
    [InlineData("1024", 1024L)]
    public void Parse_ValidSizeStrings_ReturnsBytes(string input, long expectedBytes)
    {
        StorageSizeJsonConverter.Parse(input).ShouldBe(expectedBytes);
    }
}

Step 2: Run to confirm they FAIL (red)

dotnet test /Users/dohertj2/Desktop/natsnet/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ZB.MOM.NatsNet.Server.Tests.csproj \
  --filter "Config" -c Release 2>&1 | tail -10

Expected before implementation: tests fail with type-not-found or assertion errors.

Step 3: Run full test suite after implementation

dotnet test /Users/dohertj2/Desktop/natsnet/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ZB.MOM.NatsNet.Server.Tests.csproj \
  -c Release 2>&1 | tail -5

Expected: Passed! - Failed: 0


Task A5: Update DB and commit Session A

Step 1: Mark all 67 opts.go stubs complete

sqlite3 /Users/dohertj2/Desktop/natsnet/porting.db "
UPDATE features SET status='complete' WHERE id IN (
  2505,2509,2510,2511,2513,2514,2515,2516,2517,2519,
  2520,2521,2522,2523,2524,2525,2526,2527,2528,2529,
  2530,2531,2532,2533,2534,2535,2536,2537,2538,2539,
  2540,2541,2542,2543,2544,2545,2546,2547,2548,2549,
  2550,2551,2552,2553,2554,2555,2556,2557,2558,2559,
  2560,2561,2562,2563,2564,2565,2566,2567,2568,2569,
  2570,2571,2572,2573,2574,2580,2584
);
SELECT 'Updated: ' || changes();
"

Expected: Updated: 67

Step 2: Generate report

./reports/generate-report.sh

Step 3: Commit

git add \
  dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs \
  dotnet/src/ZB.MOM.NatsNet.Server/Config/NatsJsonConverters.cs \
  dotnet/src/ZB.MOM.NatsNet.Server/Config/ServerOptionsConfiguration.cs \
  dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Config/ServerOptionsConfigurationTests.cs \
  porting.db reports/current.md
git commit -m "feat: session A — config binding via appsettings.json (67 stubs complete)"

SESSION B — Auth Implementation (26 stubs)

Task B1: Add NATS.NKeys NuGet package

Files:

  • Modify: dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj

Note: BCrypt.Net-Next is already in the csproj. Only NATS.NKeys is missing.

Step 1: Add the package reference

dotnet add /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj \
  package NATS.NKeys

Step 2: Verify it restores

dotnet restore /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj 2>&1 | tail -3

Expected: no errors.

Step 3: Commit

git add dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj
git commit -m "chore: add NATS.NKeys NuGet package for NKey signature verification"

Task B2: Create Auth/JwtProcessor additions (3 stubs)

Files:

  • Modify: dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs

Adds ReadOperatorJwt, ReadOperatorJwtInternal, and ValidateTrustedOperators.

Step 1: Add methods to JwtProcessor.cs

Read the existing JwtProcessor.cs first to find the correct insertion point (after the existing static methods, before the closing }).

/// <summary>
/// Reads an operator JWT from a file path. Returns (claims, error).
/// Mirrors Go <c>ReadOperatorJWT</c> in server/jwt.go.
/// </summary>
public static (AccountClaims? Claims, Exception? Error) ReadOperatorJwt(string path)
{
    if (string.IsNullOrEmpty(path))
        return (null, new ArgumentException("operator JWT path is empty"));

    string jwtString;
    try
    {
        jwtString = File.ReadAllText(path, System.Text.Encoding.ASCII).Trim();
    }
    catch (Exception ex)
    {
        return (null, new IOException($"error reading operator JWT file: {ex.Message}", ex));
    }
    return ReadOperatorJwtInternal(jwtString);
}

/// <summary>
/// Decodes an operator JWT string. Returns (claims, error).
/// Mirrors Go <c>readOperatorJWT</c> in server/jwt.go.
/// </summary>
public static (AccountClaims? Claims, Exception? Error) ReadOperatorJwtInternal(string jwtString)
{
    if (string.IsNullOrEmpty(jwtString))
        return (null, new ArgumentException("operator JWT string is empty"));
    if (!jwtString.StartsWith(JwtPrefix, StringComparison.Ordinal))
        return (null, new FormatException($"operator JWT does not start with expected prefix '{JwtPrefix}'"));

    // Stub: full decoding requires a NATS JWT library integration.
    // TODO: decode base64url parts, verify signature, return OperatorClaims.
    var claims = AccountClaims.TryDecode(jwtString);
    if (claims == null)
        return (null, new FormatException("failed to decode operator JWT — full JWT parsing not yet implemented"));
    return (claims, null);
}

/// <summary>
/// Validates the trusted operator JWTs in options.
/// Mirrors Go <c>validateTrustedOperators</c> in server/jwt.go.
/// </summary>
public static Exception? ValidateTrustedOperators(ServerOptions opts)
{
    if (opts.TrustedOperators == null || opts.TrustedOperators.Count == 0)
        return null;

    if (!string.IsNullOrEmpty(opts.TrustedKeys) || opts.TrustedKeys is not null)
    {
        return new InvalidOperationException(
            "cannot use trusted_operators with trusted_keys configuration");
    }

    // Each operator should be a well-formed JWT.
    foreach (var op in opts.TrustedOperators)
    {
        var jwtStr = op?.ToString() ?? string.Empty;
        var (_, err) = ReadOperatorJwtInternal(jwtStr);
        if (err != null)
            return new InvalidOperationException($"invalid trusted operator JWT: {err.Message}");
    }
    return null;
}

Note: TrustedKeys field — check if it exists on ServerOptions. If not, skip the trustedKeys conflict check.

Step 2: Build to verify

dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -3

Expected: 0 Error(s)


Task B3: Create Auth/AuthHandler additions (5 stubs)

Files:

  • Modify: dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs

Adds ProcessUserPermissionsTemplate, GetTlsAuthDcs, CheckClientTlsCertSubject, ValidateProxies, GetAuthErrClosedState.

Step 1: Read auth.go to understand each function (lines 427-1700)

sed -n '427,600p' /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth.go
sed -n '1198,1320p' /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth.go
sed -n '1657,1697p' /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth.go

Step 2: Add to AuthHandler.cs (insert before the final })

/// <summary>
/// Returns the closed-client state name for an auth error.
/// Mirrors Go <c>getAuthErrClosedState</c> in server/auth.go.
/// </summary>
public static ClosedState GetAuthErrClosedState(Exception? err)
{
    if (err == null) return ClosedState.AuthenticationTimeout;
    var msg = err.Message;
    if (msg.Contains("expired", StringComparison.OrdinalIgnoreCase)) return ClosedState.AuthenticationExpired;
    if (msg.Contains("revoked", StringComparison.OrdinalIgnoreCase)) return ClosedState.AuthRevoked;
    return ClosedState.AuthenticationViolation;
}

/// <summary>
/// Validates proxy configuration entries in options.
/// Mirrors Go <c>validateProxies</c> in server/auth.go.
/// </summary>
public static Exception? ValidateProxies(ServerOptions opts)
{
    // TODO: validate proxy address entries if ProxyRequired
    if (opts.ProxyRequired && !opts.ProxyProtocol)
        return new InvalidOperationException("proxy_required requires proxy_protocol to be enabled");
    return null;
}

/// <summary>
/// Extracts the DC= attribute values from a certificate's distinguished name.
/// Mirrors Go <c>getTLSAuthDCs</c> in server/auth.go.
/// </summary>
public static string GetTlsAuthDcs(System.Security.Cryptography.X509Certificates.X509Certificate2 cert)
{
    // Parse the Subject distinguished name for DC= components.
    var subject = cert.Subject;
    var dcs = new System.Text.StringBuilder();
    foreach (var part in subject.Split(','))
    {
        var trimmed = part.Trim();
        if (trimmed.StartsWith("DC=", StringComparison.OrdinalIgnoreCase))
        {
            if (dcs.Length > 0) dcs.Append('.');
            dcs.Append(trimmed[3..]);
        }
    }
    return dcs.ToString();
}

/// <summary>
/// Checks whether a client's TLS certificate subject matches using the provided matcher function.
/// Mirrors Go <c>checkClientTLSCertSubject</c> in server/auth.go.
/// </summary>
public static bool CheckClientTlsCertSubject(
    System.Security.Cryptography.X509Certificates.X509Certificate2? cert,
    Func<string, bool> matcher)
{
    if (cert == null) return false;
    var subject = cert.Subject;
    return matcher(subject);
}

/// <summary>
/// Expands template variables ({{account}}, {{tag.*}}) in JWT user permission limits.
/// Mirrors Go <c>processUserPermissionsTemplate</c> in server/auth.go.
/// </summary>
public static (UserPermissionLimits Result, Exception? Error) ProcessUserPermissionsTemplate(
    UserPermissionLimits lim,
    string accountName,
    Dictionary<string, string>? tags)
{
    // Walk all subjects in Pub/Sub Allow/Deny and expand {{account}} and {{tag.*}}.
    lim = DeepCopyLimits(lim);
    ExpandSubjectList(lim.Permissions?.Pub?.Allow, accountName, tags);
    ExpandSubjectList(lim.Permissions?.Pub?.Deny, accountName, tags);
    ExpandSubjectList(lim.Permissions?.Sub?.Allow, accountName, tags);
    ExpandSubjectList(lim.Permissions?.Sub?.Deny, accountName, tags);
    return (lim, null);
}

private static UserPermissionLimits DeepCopyLimits(UserPermissionLimits lim)
{
    // Shallow clone — permissions sub-objects are replaced during expansion.
    return lim; // TODO: deep copy if mutations needed
}

private static void ExpandSubjectList(List<string>? subjects, string accountName, Dictionary<string, string>? tags)
{
    if (subjects == null) return;
    for (var i = 0; i < subjects.Count; i++)
    {
        subjects[i] = ExpandTemplate(subjects[i], accountName, tags);
    }
}

private static readonly System.Text.RegularExpressions.Regex TemplateVar =
    new(@"\{\{(\w+(?:\.\w+)*)\}\}", System.Text.RegularExpressions.RegexOptions.Compiled);

private static string ExpandTemplate(string subject, string accountName, Dictionary<string, string>? tags)
{
    return TemplateVar.Replace(subject, m =>
    {
        var key = m.Groups[1].Value;
        if (key.Equals("account", StringComparison.OrdinalIgnoreCase)) return accountName;
        if (key.StartsWith("tag.", StringComparison.OrdinalIgnoreCase) && tags != null)
        {
            var tagKey = key[4..];
            return tags.TryGetValue(tagKey, out var v) ? v : m.Value;
        }
        return m.Value;
    });
}

Note: UserPermissionLimits and ClosedState — confirm these types exist in AuthTypes.cs. If ClosedState is not defined, check ClientTypes.cs. Add any missing minimal types.

Step 3: Build to verify

dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -3

Expected: 0 Error(s)


Task B4: Create NatsServer.Auth.cs (13 NatsServer auth stubs)

Files:

  • Create: dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Auth.cs

Step 1: Read auth.go for the 13 NatsServer methods

sed -n '196,420p' /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth.go
sed -n '365,405p' /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth.go
sed -n '1149,1200p' /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth.go
sed -n '1349,1570p' /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth.go
sed -n '1657,1697p' /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth.go

Step 2: Write NatsServer.Auth.cs with all NatsServer auth methods as a partial class:

// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0
// Adapted from server/auth.go in the NATS server Go source.

using System.Security.Cryptography.X509Certificates;
using NATS.NKeys;
using ZB.MOM.NatsNet.Server.Auth;

namespace ZB.MOM.NatsNet.Server;

/// <summary>
/// Authentication logic for <see cref="NatsServer"/>.
/// Mirrors Go auth.go Server methods.
/// </summary>
internal sealed partial class NatsServer
{
    // ---- Server-side auth lookup tables (populated by ConfigureAuthorization) ----
    private Dictionary<string, NkeyUser>? _nkeys;
    private Dictionary<string, User>?     _users;

    /// <summary>
    /// Wires up auth lookup tables from options.
    /// Called during server start and on config reload.
    /// Mirrors Go <c>configureAuthorization</c>.
    /// Lock must be held on entry.
    /// </summary>
    internal void ConfigureAuthorization()
    {
        var opts = GetOpts();
        if (opts == null) return;

        if (opts.CustomClientAuthentication != null)
        {
            _info.AuthRequired = true;
        }
        else if (_trustedKeys != null)
        {
            _info.AuthRequired = true;
        }
        else if (opts.Nkeys != null || opts.Users != null)
        {
            (_nkeys, _users) = BuildNkeysAndUsersFromOptions(opts.Nkeys, opts.Nkeys != null ? null : opts.Users, opts.Users);
            _info.AuthRequired = true;
        }
        else if (!string.IsNullOrEmpty(opts.Username) || !string.IsNullOrEmpty(opts.Authorization))
        {
            _info.AuthRequired = true;
        }
        else
        {
            _users = null;
            _nkeys = null;
            _info.AuthRequired = false;
        }

        // Auth callout setup.
        if (opts.AuthCallout != null)
        {
            if (string.IsNullOrEmpty(opts.AuthCallout.Account))
                Errorf("Authorization callout account not set");
        }
    }

    private (Dictionary<string, NkeyUser>? nkeys, Dictionary<string, User>? users) BuildNkeysAndUsersFromOptions(
        List<NkeyUser>? nko, List<NkeyUser>? _, List<User>? uo)
    {
        Dictionary<string, NkeyUser>? nkeys = null;
        Dictionary<string, User>? users = null;

        if (nko != null)
        {
            nkeys = new Dictionary<string, NkeyUser>(nko.Count, StringComparer.Ordinal);
            foreach (var u in nko)
            {
                var copy = u.Clone() ?? u;
                if (copy.Permissions != null)
                    AuthHandler.ValidateResponsePermissions(copy.Permissions);
                nkeys[u.Nkey] = copy;
            }
        }

        if (uo != null)
        {
            users = new Dictionary<string, User>(uo.Count, StringComparer.Ordinal);
            foreach (var u in uo)
            {
                var copy = u.Clone() ?? u;
                if (copy.Permissions != null)
                    AuthHandler.ValidateResponsePermissions(copy.Permissions);
                users[u.Username] = copy;
            }
        }

        AssignGlobalAccountToOrphanUsers(nkeys, users);
        return (nkeys, users);
    }

    internal void AssignGlobalAccountToOrphanUsers(
        Dictionary<string, NkeyUser>? nkeys,
        Dictionary<string, User>? users)
    {
        if (nkeys != null)
        {
            foreach (var u in nkeys.Values)
            {
                u.Account ??= _globalAccount;
            }
        }
        if (users != null)
        {
            foreach (var u in users.Values)
            {
                u.Account ??= _globalAccount;
            }
        }
    }

    /// <summary>
    /// Entry-point auth check — dispatches by client kind.
    /// Mirrors Go <c>checkAuthentication</c>.
    /// </summary>
    internal bool CheckAuthentication(ClientConnection c)
    {
        return c.Kind switch
        {
            ClientKind.Client  => IsClientAuthorized(c),
            ClientKind.Router  => IsRouterAuthorized(c),
            ClientKind.Gateway => IsGatewayAuthorized(c),
            ClientKind.Leaf    => IsLeafNodeAuthorized(c),
            _                  => false,
        };
    }

    /// <summary>
    /// Checks authorization for a standard client connection.
    /// Mirrors Go <c>isClientAuthorized</c>.
    /// </summary>
    internal bool IsClientAuthorized(ClientConnection c)
    {
        return ProcessClientOrLeafAuthentication(c, GetOpts());
    }

    /// <summary>
    /// Full authentication dispatch — handles all auth paths.
    /// Mirrors Go <c>processClientOrLeafAuthentication</c> (554 LOC).
    /// </summary>
    internal bool ProcessClientOrLeafAuthentication(ClientConnection c, ServerOptions? opts)
    {
        if (opts == null) return false;

        // --- Auth callout check ---
        if (opts.AuthCallout != null)
            return ProcessClientOrLeafCallout(c, opts);

        // --- Proxy check ---
        var (trustedProxy, proxyOk) = ProxyCheck(c, opts);
        if (trustedProxy && !proxyOk)
        {
            c.SetAuthError(new InvalidOperationException("proxy not trusted"));
            return false;
        }

        // --- Trusted operators / JWT bearer ---
        if (_trustedKeys != null)
        {
            var token = c.GetAuthToken();
            if (string.IsNullOrEmpty(token))
            {
                c.SetAuthError(new InvalidOperationException("missing JWT token for trusted operator"));
                return false;
            }
            // TODO: full JWT validation against trusted operators (requires JWT library integration).
            return true;
        }

        // --- NKey ---
        if (_nkeys != null && _nkeys.Count > 0)
        {
            var nkeyPub = c.GetNkey();
            if (!string.IsNullOrEmpty(nkeyPub) && _nkeys.TryGetValue(nkeyPub, out var nkeyUser))
            {
                // Verify nonce signature.
                var sig = c.GetNkeySig();
                var nonce = c.GetNonce();
                if (!string.IsNullOrEmpty(sig) && !string.IsNullOrEmpty(nonce))
                {
                    try
                    {
                        var kp = KeyPair.FromPublicKey(nkeyPub.AsSpan());
                        var verified = kp.Verify(
                            System.Text.Encoding.ASCII.GetBytes(nonce),
                            Convert.FromBase64String(sig));
                        if (!verified)
                        {
                            c.SetAuthError(new InvalidOperationException("NKey signature verification failed"));
                            return false;
                        }
                    }
                    catch (Exception ex)
                    {
                        c.SetAuthError(ex);
                        return false;
                    }
                }
                c.SetAccount(nkeyUser.Account);
                c.SetPermissions(nkeyUser.Permissions);
                return true;
            }
        }

        // --- Username/Password ---
        if (_users != null && _users.Count > 0)
        {
            var username = c.GetUsername();
            if (_users.TryGetValue(username, out var user))
            {
                var pw = c.GetPassword();
                if (!AuthHandler.ComparePasswords(user.Password, pw))
                {
                    c.SetAuthError(new InvalidOperationException("invalid password"));
                    return false;
                }
                c.SetAccount(user.Account);
                c.SetPermissions(user.Permissions);
                return true;
            }
        }

        // --- Single global username/password (from opts) ---
        if (!string.IsNullOrEmpty(opts.Username))
        {
            if (c.GetUsername() != opts.Username ||
                !AuthHandler.ComparePasswords(opts.Password, c.GetPassword()))
            {
                c.SetAuthError(new InvalidOperationException("invalid credentials"));
                return false;
            }
            return true;
        }

        // --- Token (authorization) ---
        if (!string.IsNullOrEmpty(opts.Authorization))
        {
            if (!AuthHandler.ComparePasswords(opts.Authorization, c.GetAuthToken()))
            {
                c.SetAuthError(new InvalidOperationException("bad authorization token"));
                return false;
            }
            return true;
        }

        // --- TLS cert mapping ---
        if (opts.TLSMap)
        {
            var cert = c.GetTlsCertificate();
            var ok = AuthHandler.CheckClientTlsCertSubject(cert, _ => true);
            if (!ok)
            {
                c.SetAuthError(new InvalidOperationException("TLS cert mapping failed"));
                return false;
            }
            return true;
        }

        // --- No auth required ---
        if (!_info.AuthRequired) return true;

        c.SetAuthError(new InvalidOperationException("no credentials provided"));
        return false;
    }

    /// <summary>Mirrors Go <c>isRouterAuthorized</c>.</summary>
    internal bool IsRouterAuthorized(ClientConnection c)
    {
        var opts = GetOpts();
        if (opts?.Cluster == null) return true;
        // TODO: full route auth when ClusterOpts is fully typed.
        return true;
    }

    /// <summary>Mirrors Go <c>isGatewayAuthorized</c>.</summary>
    internal bool IsGatewayAuthorized(ClientConnection c)
    {
        var opts = GetOpts();
        if (opts?.Gateway == null) return true;
        return true;
    }

    /// <summary>Mirrors Go <c>registerLeafWithAccount</c>.</summary>
    internal bool RegisterLeafWithAccount(ClientConnection c, string accountName)
    {
        var acc = LookupAccount(accountName);
        if (acc == null) return false;
        c.SetAccount(acc);
        return true;
    }

    /// <summary>Mirrors Go <c>isLeafNodeAuthorized</c>.</summary>
    internal bool IsLeafNodeAuthorized(ClientConnection c)
    {
        return ProcessClientOrLeafAuthentication(c, GetOpts());
    }

    /// <summary>Mirrors Go <c>checkAuthforWarnings</c>.</summary>
    internal void CheckAuthforWarnings()
    {
        var opts = GetOpts();
        if (opts == null) return;
        if (opts.Users != null && !string.IsNullOrEmpty(opts.Username))
            Warnf("Having a global password along with users/nkeys is not recommended");
    }

    /// <summary>Mirrors Go <c>proxyCheck</c>.</summary>
    internal (bool TrustedProxy, bool Ok) ProxyCheck(ClientConnection c, ServerOptions opts)
    {
        if (!opts.ProxyProtocol) return (false, false);
        // TODO: check c's remote IP against configured trusted proxy addresses.
        return (true, true);
    }

    /// <summary>Mirrors Go <c>processProxiesTrustedKeys</c>.</summary>
    internal void ProcessProxiesTrustedKeys()
    {
        // TODO: parse proxy trusted key strings into _proxyTrustedKeys set.
    }
}

Step 3: Check required fields on NatsServer / ClientConnection

The code above calls:

  • c.Kind → check ClientConnection.Kind property
  • c.GetAuthToken(), c.GetNkey(), c.GetNkeySig(), c.GetNonce() → may need adding
  • c.GetUsername(), c.GetPassword() → may need adding
  • c.SetAccount(acc), c.SetPermissions(p), c.SetAuthError(e) → may need adding
  • c.GetTlsCertificate() → may need adding
  • _trustedKeys, _globalAccount, _info → already in NatsServer.cs

If any of those methods/properties don't exist on ClientConnection, add stub implementations:

// In ClientConnection.cs or ClientTypes.cs, add:
public string GetAuthToken() => _connectMsg?.AuthToken ?? string.Empty;
public string GetNkey() => _connectMsg?.Nkey ?? string.Empty;
public string GetNkeySig() => _connectMsg?.Sig ?? string.Empty;
public string GetNonce() => _nonce ?? string.Empty;
public string GetUsername() => _connectMsg?.User ?? string.Empty;
public string GetPassword() => _connectMsg?.Pass ?? string.Empty;
public X509Certificate2? GetTlsCertificate() => _tlsCert;
public void SetAuthError(Exception? err) => _authErr = err;
public void SetAccount(Account? acc) { /* wire to _acc */ }
public void SetPermissions(Permissions? p) { /* wire to _perms */ }

Step 4: Build to verify

dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -5

Expected: 0 Error(s) (some warnings about unused fields are OK)


Task B5: Create Auth/AuthCallout.cs (3 stubs)

Files:

  • Create: dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthCallout.cs

Step 1: Read auth_callout.go (lines 36-500)

cat /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth_callout.go

Step 2: Write Auth/AuthCallout.cs as a partial class extension of NatsServer

// Copyright 2022-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0
// Adapted from server/auth_callout.go in the NATS server Go source.

namespace ZB.MOM.NatsNet.Server.Auth;

/// <summary>
/// External auth callout support.
/// Methods are added to <see cref="NatsServer"/> as a partial extension.
/// Mirrors Go <c>auth_callout.go</c>.
/// </summary>
internal static class AuthCallout
{
    /// <summary>
    /// Publishes an auth request to the configured callout account and awaits
    /// a signed JWT response that authorises or rejects the connecting client.
    /// Mirrors Go <c>processClientOrLeafCallout</c> in auth_callout.go.
    /// </summary>
    public static bool ProcessClientOrLeafCallout(NatsServer server, ClientConnection c, ServerOptions opts)
    {
        // TODO: full implementation requires internal NATS connection + async request/reply.
        // For now, fall back to standard auth if no callout response.
        throw new NotImplementedException("TODO: auth callout — requires internal NATS pub/sub (session B)");
    }

    /// <summary>
    /// Populates an authorization request payload with client info.
    /// Mirrors Go <c>client.fillClientInfo</c> in auth_callout.go.
    /// </summary>
    public static void FillClientInfo(
        AuthorizationRequest req,
        ClientConnection c)
    {
        req.ClientInfo = new AuthorizationClientInfo
        {
            Host = c.RemoteAddress,
            Id   = c.Cid,
            Kind = c.Kind.ToString().ToLowerInvariant(),
            Type = "client",
        };
    }

    /// <summary>
    /// Populates an authorization request payload with connect options.
    /// Mirrors Go <c>client.fillConnectOpts</c> in auth_callout.go.
    /// </summary>
    public static void FillConnectOpts(
        AuthorizationRequest req,
        ClientConnection c)
    {
        req.ConnectOptions = new AuthorizationConnectOpts
        {
            Username  = c.GetUsername(),
            Password  = c.GetPassword(),
            AuthToken = c.GetAuthToken(),
            Nkey      = c.GetNkey(),
        };
    }
}

/// <summary>Authorization request sent to auth callout service.</summary>
public sealed class AuthorizationRequest
{
    public string ServerId   { get; set; } = string.Empty;
    public string UserNkey   { get; set; } = string.Empty;
    public string ClientInfo { get; set; } = string.Empty; // JSON
    public AuthorizationClientInfo? ClientInfoObj  { get; set; }
    public AuthorizationConnectOpts? ConnectOptions { get; set; }
}

public sealed class AuthorizationClientInfo
{
    public string Host { get; set; } = string.Empty;
    public ulong  Id   { get; set; }
    public string Kind { get; set; } = string.Empty;
    public string Type { get; set; } = string.Empty;
}

public sealed class AuthorizationConnectOpts
{
    public string Username  { get; set; } = string.Empty;
    public string Password  { get; set; } = string.Empty;
    public string AuthToken { get; set; } = string.Empty;
    public string Nkey      { get; set; } = string.Empty;
}

Also update NatsServer.Auth.cs to use the static helper:

internal bool ProcessClientOrLeafCallout(ClientConnection c, ServerOptions opts)
    => AuthCallout.ProcessClientOrLeafCallout(this, c, opts);

Step 3: Build to verify

dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -3

Task B6: Create NatsServer.Signals.cs (1 stub)

Files:

  • Create: dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Signals.cs

Step 1: Read signal.go for full context

cat /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/signal.go

Step 2: Write NatsServer.Signals.cs

// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0
// Adapted from server/signal.go in the NATS server Go source.

using System.Runtime.InteropServices;

namespace ZB.MOM.NatsNet.Server;

/// <summary>
/// OS signal handling for <see cref="NatsServer"/>.
/// Mirrors Go <c>signal.go</c> (Unix build).
/// </summary>
internal sealed partial class NatsServer
{
    private PosixSignalRegistration? _sigHup;
    private PosixSignalRegistration? _sigTerm;
    private PosixSignalRegistration? _sigInt;

    /// <summary>
    /// Registers OS signal handlers (SIGHUP, SIGTERM, SIGINT).
    /// On Windows, falls back to <see cref="Console.CancelKeyPress"/>.
    /// Mirrors Go <c>Server.handleSignals</c>.
    /// </summary>
    internal void HandleSignals()
    {
        if (GetOpts()?.NoSigs == true) return;

        if (OperatingSystem.IsWindows())
        {
            Console.CancelKeyPress += (_, e) =>
            {
                e.Cancel = true;
                Noticef("Caught interrupt signal, shutting down...");
                _ = ShutdownAsync();
            };
            return;
        }

        // SIGHUP — reload configuration
        _sigHup = PosixSignalRegistration.Create(PosixSignal.SIGHUP, ctx =>
        {
            ctx.Cancel = true;
            Noticef("Trapped SIGHUP signal, reloading configuration...");
            try { Reload(); }
            catch (Exception ex) { Errorf("Config reload failed: {0}", ex.Message); }
        });

        // SIGTERM — graceful shutdown
        _sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx =>
        {
            ctx.Cancel = true;
            Noticef("Trapped SIGTERM signal, shutting down...");
            _ = ShutdownAsync();
        });

        // SIGINT — interrupt (Ctrl+C)
        _sigInt = PosixSignalRegistration.Create(PosixSignal.SIGINT, ctx =>
        {
            ctx.Cancel = true;
            Noticef("Trapped SIGINT signal, shutting down...");
            _ = ShutdownAsync();
        });
    }

    private void DisposeSignalHandlers()
    {
        _sigHup?.Dispose();
        _sigTerm?.Dispose();
        _sigInt?.Dispose();
    }
}

Note: Check if Reload() and ShutdownAsync() exist on NatsServer. If Reload() doesn't exist, add a stub: internal void Reload() => throw new NotImplementedException("TODO: config reload");

Step 3: Build to verify

dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -3

Task B7: Write auth unit tests (TDD)

Files:

  • Create: dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthImplementationTests.cs
// Copyright 2012-2025 The NATS Authors
namespace ZB.MOM.NatsNet.Server.Tests.Auth;

using ZB.MOM.NatsNet.Server.Auth;
using Shouldly;
using Xunit;

public class AuthHandlerExtendedTests
{
    [Fact]
    public void GetTlsAuthDcs_CertWithDCComponents_ExtractsDCs()
    {
        // Create a minimal self-signed cert for testing.
        // Using a static subject string for simplicity.
        var subject = "CN=server,DC=example,DC=com";
        // We can't easily create an X509Certificate2 in unit tests without a real cert,
        // so test the parsing logic directly.
        var dc = ParseDcFromSubject(subject);
        dc.ShouldBe("example.com");
    }

    [Fact]
    public void ValidateProxies_ProxyRequiredWithoutProtocol_ReturnsError()
    {
        var opts = new ServerOptions { ProxyRequired = true, ProxyProtocol = false };
        var err = AuthHandler.ValidateProxies(opts);
        err.ShouldNotBeNull();
        err!.Message.ShouldContain("proxy_required");
    }

    [Fact]
    public void ValidateProxies_ProxyRequiredWithProtocol_ReturnsNull()
    {
        var opts = new ServerOptions { ProxyRequired = true, ProxyProtocol = true };
        var err = AuthHandler.ValidateProxies(opts);
        err.ShouldBeNull();
    }

    [Fact]
    public void GetAuthErrClosedState_ExpiredMessage_ReturnsExpiredState()
    {
        var err = new InvalidOperationException("token is expired");
        AuthHandler.GetAuthErrClosedState(err).ShouldBe(ClosedState.AuthenticationExpired);
    }

    [Fact]
    public void GetAuthErrClosedState_NullError_ReturnsTimeout()
    {
        AuthHandler.GetAuthErrClosedState(null).ShouldBe(ClosedState.AuthenticationTimeout);
    }

    private static string ParseDcFromSubject(string subject)
    {
        var sb = new System.Text.StringBuilder();
        foreach (var part in subject.Split(','))
        {
            var t = part.Trim();
            if (t.StartsWith("DC=", StringComparison.OrdinalIgnoreCase))
            {
                if (sb.Length > 0) sb.Append('.');
                sb.Append(t[3..]);
            }
        }
        return sb.ToString();
    }
}

public class JwtProcessorOperatorTests
{
    [Fact]
    public void ReadOperatorJwtInternal_EmptyString_ReturnsError()
    {
        var (claims, err) = JwtProcessor.ReadOperatorJwtInternal(string.Empty);
        claims.ShouldBeNull();
        err.ShouldNotBeNull();
    }

    [Fact]
    public void ReadOperatorJwtInternal_InvalidPrefix_ReturnsFormatError()
    {
        var (claims, err) = JwtProcessor.ReadOperatorJwtInternal("NOTAJWT.payload.sig");
        claims.ShouldBeNull();
        err.ShouldBeOfType<FormatException>();
    }

    [Fact]
    public void ReadOperatorJwt_FileNotFound_ReturnsError()
    {
        var (claims, err) = JwtProcessor.ReadOperatorJwt("/nonexistent/operator.jwt");
        claims.ShouldBeNull();
        err.ShouldBeOfType<IOException>();
    }

    [Fact]
    public void ValidateTrustedOperators_EmptyList_ReturnsNull()
    {
        var opts = new ServerOptions();
        JwtProcessor.ValidateTrustedOperators(opts).ShouldBeNull();
    }
}

Step 1: Run tests to verify they pass

dotnet test /Users/dohertj2/Desktop/natsnet/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ZB.MOM.NatsNet.Server.Tests.csproj \
  --filter "AuthImplementation|JwtProcessorOperator" -c Release 2>&1 | tail -10

Expected: Passed! - Failed: 0

Step 2: Run full suite

dotnet test /Users/dohertj2/Desktop/natsnet/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ZB.MOM.NatsNet.Server.Tests.csproj \
  -c Release 2>&1 | tail -5

Expected: Passed! - Failed: 0


Task B8: Update DB and commit Session B

Step 1: Mark all 26 auth/jwt/callout/signal stubs complete

sqlite3 /Users/dohertj2/Desktop/natsnet/porting.db "
UPDATE features SET status='complete' WHERE id IN (
  354,355,357,358,359,360,361,362,363,364,
  365,366,369,370,371,372,378,379,380,381,
  382,383,1973,1974,1976,3156
);
SELECT 'Updated: ' || changes();
"

Expected: Updated: 26

Step 2: Generate report and verify all stubs gone

./reports/generate-report.sh
sqlite3 /Users/dohertj2/Desktop/natsnet/porting.db \
  "SELECT status, count(*) FROM features WHERE status IN ('stub','not_started') GROUP BY status;"

Expected: no rows (all stubs resolved).

Step 3: Commit

git add \
  dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj \
  dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs \
  dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs \
  dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthCallout.cs \
  dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Auth.cs \
  dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Signals.cs \
  dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthImplementationTests.cs \
  porting.db reports/current.md
git commit -m "feat: session B — auth implementation + signals (26 stubs complete)"

Completion Verification

After both sessions merge:

# 1. Full build
dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -3

# 2. Full test suite
dotnet test /Users/dohertj2/Desktop/natsnet/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ZB.MOM.NatsNet.Server.Tests.csproj -c Release 2>&1 | tail -5

# 3. Confirm zero stubs remain
sqlite3 /Users/dohertj2/Desktop/natsnet/porting.db \
  "SELECT count(*) as remaining_stubs FROM features WHERE status='stub';"

Expected: 0 Error(s), Passed! - Failed: 0, remaining_stubs = 0