diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs
index 7735173..2e3371f 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs
@@ -407,6 +407,74 @@ public sealed class Account : INatsAccount
///
private int _traceDestSampling;
+ ///
+ /// Sets account-level message trace destination subject.
+ /// Mirrors writes to Go acc.traceDest during config parsing.
+ ///
+ internal void SetMessageTraceDestination(string subject)
+ {
+ _mu.EnterWriteLock();
+ try
+ {
+ _traceDest = subject ?? string.Empty;
+ }
+ finally
+ {
+ _mu.ExitWriteLock();
+ }
+ }
+
+ ///
+ /// Returns account-level message trace destination subject.
+ /// Mirrors reads of Go acc.traceDest during config parsing.
+ ///
+ internal string GetMessageTraceDestination()
+ {
+ _mu.EnterReadLock();
+ try
+ {
+ return _traceDest;
+ }
+ finally
+ {
+ _mu.ExitReadLock();
+ }
+ }
+
+ ///
+ /// Sets account-level message trace sampling percentage.
+ /// Mirrors writes to Go acc.traceDestSampling during config parsing.
+ ///
+ internal void SetMessageTraceSampling(int sampling)
+ {
+ _mu.EnterWriteLock();
+ try
+ {
+ _traceDestSampling = sampling;
+ }
+ finally
+ {
+ _mu.ExitWriteLock();
+ }
+ }
+
+ ///
+ /// Returns account-level message trace sampling percentage.
+ /// Mirrors reads of Go acc.traceDestSampling during config parsing.
+ ///
+ internal int GetMessageTraceSampling()
+ {
+ _mu.EnterReadLock();
+ try
+ {
+ return _traceDestSampling;
+ }
+ finally
+ {
+ _mu.ExitReadLock();
+ }
+ }
+
// -------------------------------------------------------------------------
// Factory
// -------------------------------------------------------------------------
diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs
index 7e93482..a514938 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs
@@ -21,6 +21,7 @@ using System.Security.Cryptography.X509Certificates;
using System.Net.Security;
using ZB.MOM.NatsNet.Server.Auth;
using ZB.MOM.NatsNet.Server.Config;
+using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server;
@@ -2107,6 +2108,1711 @@ public sealed partial class ServerOptions
};
}
+ // -------------------------------------------------------------------------
+ // Batch 6: opts.go package-level parse/config helpers (F3)
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns true for reserved account names.
+ /// Mirrors isReservedAccount in opts.go.
+ ///
+ public static bool IsReservedAccount(string name) =>
+ string.Equals(name, ServerConstants.DefaultGlobalAccount, StringComparison.Ordinal);
+
+ ///
+ /// Parsed account export entry used during account-config parsing.
+ ///
+ public sealed class AccountExportConfig
+ {
+ public Account? Account { get; set; }
+ public string Subject { get; set; } = string.Empty;
+ public List AccountNames { get; set; } = [];
+ public ServiceRespType ResponseType { get; set; } = ServiceRespType.Singleton;
+ public object? Latency { get; set; }
+ public TimeSpan ResponseThreshold { get; set; }
+ public uint AccountTokenPosition { get; set; }
+ public bool AllowTrace { get; set; }
+ }
+
+ ///
+ /// Parsed account stream-import entry used during account-config parsing.
+ ///
+ public sealed class AccountImportStreamConfig
+ {
+ public Account? Account { get; set; }
+ public string AccountName { get; set; } = string.Empty;
+ public string Subject { get; set; } = string.Empty;
+ public string Prefix { get; set; } = string.Empty;
+ public string To { get; set; } = string.Empty;
+ public bool AllowTrace { get; set; }
+ }
+
+ ///
+ /// Parsed account service-import entry used during account-config parsing.
+ ///
+ public sealed class AccountImportServiceConfig
+ {
+ public Account? Account { get; set; }
+ public string AccountName { get; set; } = string.Empty;
+ public string Subject { get; set; } = string.Empty;
+ public string To { get; set; } = string.Empty;
+ public bool Share { get; set; }
+ }
+
+ ///
+ /// Parsed authorization block used for top-level/config-subtree authorization parsing.
+ ///
+ public sealed class ParsedAuthorizationBlock
+ {
+ public string User { get; set; } = string.Empty;
+ public string Pass { get; set; } = string.Empty;
+ public string Token { get; set; } = string.Empty;
+ public double TimeoutSeconds { get; set; }
+ public bool ProxyRequired { get; set; }
+ public List Users { get; set; } = [];
+ public List Nkeys { get; set; } = [];
+ public Permissions? DefaultPermissions { get; set; }
+ public AuthCalloutOpts? Callout { get; set; }
+ }
+
+ ///
+ /// Parses a weighted account mapping destination entry.
+ /// Mirrors parseAccountMapDest in opts.go.
+ ///
+ public static MapDest? ParseAccountMapDest(object? value, ICollection? errors = null)
+ {
+ if (!TryGetMap(value, out var map))
+ {
+ errors?.Add(new InvalidOperationException("Expected an entry for the mapping destination"));
+ return null;
+ }
+
+ var mdest = new MapDest();
+ var sawWeight = false;
+ foreach (var (rawKey, rawValue) in map)
+ {
+ var key = rawKey.ToLowerInvariant();
+ var entry = NormalizeConfigValue(rawValue);
+ switch (key)
+ {
+ case "dest":
+ case "destination":
+ if (entry is string subject)
+ {
+ mdest.Subject = subject;
+ }
+ else
+ {
+ errors?.Add(new InvalidOperationException("mapping destination must be a string"));
+ return null;
+ }
+
+ break;
+ case "weight":
+ {
+ long parsedWeight;
+ switch (entry)
+ {
+ case string weightString:
+ {
+ var normalizedWeight = weightString.Trim().TrimEnd('%');
+ if (!long.TryParse(normalizedWeight, out parsedWeight))
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Invalid weight \"{weightString}\" for mapping destination"));
+ return null;
+ }
+
+ break;
+ }
+ default:
+ if (!TryConvertToLong(entry, out parsedWeight))
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Unknown entry type for weight \"{entry?.GetType().Name ?? "null"}\""));
+ return null;
+ }
+
+ break;
+ }
+
+ if (parsedWeight is > 100 or < 0)
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Invalid weight {parsedWeight} for mapping destination"));
+ return null;
+ }
+
+ mdest.Weight = (byte)parsedWeight;
+ sawWeight = true;
+ break;
+ }
+ case "cluster":
+ mdest.Cluster = entry as string ?? string.Empty;
+ break;
+ default:
+ errors?.Add(new InvalidOperationException(
+ $"Unknown field \"{rawKey}\" for mapping destination"));
+ return null;
+ }
+ }
+
+ if (!sawWeight)
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Missing weight for mapping destination \"{mdest.Subject}\""));
+ return null;
+ }
+
+ return mdest;
+ }
+
+ ///
+ /// Parses account subject mappings.
+ /// Mirrors parseAccountMappings in opts.go.
+ ///
+ public static Exception? ParseAccountMappings(
+ object? value,
+ Account account,
+ ICollection? errors = null)
+ {
+ ArgumentNullException.ThrowIfNull(account);
+ if (!TryGetMap(value, out var map))
+ return new InvalidOperationException($"Expected account mappings map, got {value?.GetType().Name ?? "null"}");
+
+ foreach (var (subject, rawDestination) in map)
+ {
+ if (!SubscriptionIndex.IsValidSubject(subject))
+ {
+ errors?.Add(new InvalidOperationException($"Subject \"{subject}\" is not a valid subject"));
+ continue;
+ }
+
+ var destination = NormalizeConfigValue(rawDestination);
+ switch (destination)
+ {
+ case string mappedSubject:
+ {
+ var mapError = account.AddMapping(subject, mappedSubject);
+ if (mapError != null)
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Error adding mapping for \"{subject}\" to \"{mappedSubject}\": {mapError.Message}",
+ mapError));
+ }
+
+ break;
+ }
+ default:
+ if (TryGetArray(destination, out var destinationArray))
+ {
+ var destinations = new List(destinationArray.Count);
+ foreach (var entry in destinationArray)
+ {
+ var parsedDestination = ParseAccountMapDest(entry, errors);
+ if (parsedDestination != null)
+ destinations.Add(parsedDestination);
+ }
+
+ if (destinations.Count == 0)
+ break;
+
+ var mapError = account.AddWeightedMappings(subject, [.. destinations]);
+ if (mapError != null)
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Error adding mapping for \"{subject}\": {mapError.Message}",
+ mapError));
+ }
+
+ break;
+ }
+
+ if (TryGetMap(destination, out _))
+ {
+ var parsedDestination = ParseAccountMapDest(destination, errors);
+ if (parsedDestination == null)
+ break;
+
+ var mapError = account.AddWeightedMappings(subject, parsedDestination);
+ if (mapError != null)
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Error adding mapping for \"{subject}\": {mapError.Message}",
+ mapError));
+ }
+
+ break;
+ }
+
+ errors?.Add(new InvalidOperationException(
+ $"Unknown type {destination?.GetType().Name ?? "null"} for mapping destination"));
+ break;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Parses account-level connection/subscription/payload limits.
+ /// Mirrors parseAccountLimits in opts.go.
+ ///
+ public static Exception? ParseAccountLimits(
+ object? value,
+ Account account,
+ ICollection? errors = null)
+ {
+ ArgumentNullException.ThrowIfNull(account);
+ if (!TryGetMap(value, out var map))
+ return new InvalidOperationException(
+ $"Expected account limits to be a map/struct, got {value?.GetType().Name ?? "null"}");
+
+ foreach (var (rawKey, rawValue) in map)
+ {
+ var key = rawKey.ToLowerInvariant();
+ var entry = NormalizeConfigValue(rawValue);
+ if (!TryConvertToLong(entry, out var numericValue))
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Expected numeric value parsing account limit \"{rawKey}\""));
+ continue;
+ }
+
+ switch (key)
+ {
+ case "max_connections":
+ case "max_conn":
+ account.MaxConnections = checked((int)numericValue);
+ break;
+ case "max_subscriptions":
+ case "max_subs":
+ account.MaxSubscriptions = checked((int)numericValue);
+ break;
+ case "max_payload":
+ case "max_pay":
+ account.MaxPayload = checked((int)numericValue);
+ break;
+ case "max_leafnodes":
+ case "max_leafs":
+ account.MaxLeafNodes = checked((int)numericValue);
+ break;
+ default:
+ if (!ConfigFlags.AllowUnknownTopLevelField)
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Unknown field \"{rawKey}\" parsing account limits"));
+ }
+
+ break;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Parses account message trace destination/sampling configuration.
+ /// Mirrors parseAccountMsgTrace in opts.go.
+ ///
+ public static Exception? ParseAccountMsgTrace(object? value, string topKey, Account account)
+ {
+ ArgumentNullException.ThrowIfNull(account);
+
+ static Exception? ProcessDestination(Account targetAccount, string key, object? rawValue)
+ {
+ if (rawValue is not string destination)
+ {
+ return new InvalidOperationException(
+ $"Field \"{key}\" should be a string, got {rawValue?.GetType().Name ?? "null"}");
+ }
+
+ if (!SubscriptionIndex.IsValidPublishSubject(destination))
+ return new InvalidOperationException($"Trace destination \"{destination}\" is not valid");
+
+ targetAccount.SetMessageTraceDestination(destination);
+ return null;
+ }
+
+ static Exception? ProcessSampling(Account targetAccount, int sampling)
+ {
+ if (sampling is <= 0 or > 100)
+ {
+ return new InvalidOperationException(
+ $"Trace destination sampling value {sampling} is invalid, needs to be [1..100]");
+ }
+
+ targetAccount.SetMessageTraceSampling(sampling);
+ return null;
+ }
+
+ var normalized = NormalizeConfigValue(value);
+ switch (normalized)
+ {
+ case string:
+ return ProcessDestination(account, topKey, normalized);
+ default:
+ if (!TryGetMap(normalized, out var map))
+ {
+ return new InvalidOperationException(
+ $"Expected account message trace \"{topKey}\" to be a string or map/struct, got {normalized?.GetType().Name ?? "null"}");
+ }
+
+ foreach (var (rawKey, rawValue) in map)
+ {
+ var key = rawKey.ToLowerInvariant();
+ var entry = NormalizeConfigValue(rawValue);
+ switch (key)
+ {
+ case "dest":
+ {
+ var destinationError = ProcessDestination(account, rawKey, entry);
+ if (destinationError != null)
+ return destinationError;
+ break;
+ }
+ case "sampling":
+ {
+ int parsedSampling;
+ switch (entry)
+ {
+ case string samplingString:
+ {
+ var normalizedSampling = samplingString.Trim().TrimEnd('%');
+ if (!int.TryParse(normalizedSampling, out parsedSampling))
+ {
+ return new InvalidOperationException(
+ $"Invalid trace destination sampling value \"{samplingString}\"");
+ }
+
+ break;
+ }
+ default:
+ if (!TryConvertToLong(entry, out var longSampling))
+ {
+ return new InvalidOperationException(
+ $"Trace destination sampling field \"{rawKey}\" should be an integer or a percentage, got {entry?.GetType().Name ?? "null"}");
+ }
+
+ parsedSampling = checked((int)longSampling);
+ break;
+ }
+
+ var samplingError = ProcessSampling(account, parsedSampling);
+ if (samplingError != null)
+ return samplingError;
+ break;
+ }
+ default:
+ if (!ConfigFlags.AllowUnknownTopLevelField)
+ {
+ return new InvalidOperationException(
+ $"Unknown field \"{rawKey}\" parsing account message trace map/struct \"{topKey}\"");
+ }
+
+ break;
+ }
+ }
+
+ return null;
+ }
+ }
+
+ ///
+ /// Parses the top-level accounts block.
+ /// Mirrors parseAccounts in opts.go.
+ ///
+ public static Exception? ParseAccounts(
+ object? value,
+ ServerOptions options,
+ ICollection? errors = null,
+ ICollection? warnings = null)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var pendingImportStreams = new List();
+ var pendingImportServices = new List();
+ var pendingExportStreams = new List();
+ var pendingExportServices = new List();
+
+ var normalized = NormalizeConfigValue(value);
+ if (TryGetArray(normalized, out var accountArray))
+ {
+ var seen = new HashSet(StringComparer.Ordinal);
+ foreach (var accountEntry in accountArray)
+ {
+ var accountName = NormalizeConfigValue(accountEntry) as string ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(accountName))
+ {
+ errors?.Add(new InvalidOperationException("Expected account name to be a string"));
+ continue;
+ }
+
+ if (IsReservedAccount(accountName))
+ {
+ errors?.Add(new InvalidOperationException($"\"{accountName}\" is a reserved account"));
+ continue;
+ }
+
+ if (!seen.Add(accountName))
+ {
+ errors?.Add(new InvalidOperationException($"Duplicate Account Entry: {accountName}"));
+ continue;
+ }
+
+ options.Accounts.Add(Account.NewAccount(accountName));
+ }
+
+ return null;
+ }
+
+ if (!TryGetMap(normalized, out var accountsMap))
+ {
+ return new InvalidOperationException(
+ $"Expected accounts to be an array or map, got {normalized?.GetType().Name ?? "null"}");
+ }
+
+ options.Users ??= [];
+ options.Nkeys ??= [];
+ var identities = SetupUsersAndNKeysDuplicateCheckMap(options);
+
+ foreach (var (accountName, accountValueRaw) in accountsMap)
+ {
+ var accountValue = NormalizeConfigValue(accountValueRaw);
+ if (!TryGetMap(accountValue, out var accountMap))
+ {
+ errors?.Add(new InvalidOperationException("Expected map entries for accounts"));
+ continue;
+ }
+
+ if (IsReservedAccount(accountName))
+ {
+ errors?.Add(new InvalidOperationException($"\"{accountName}\" is a reserved account"));
+ continue;
+ }
+
+ var account = Account.NewAccount(accountName);
+ options.Accounts.Add(account);
+
+ var parsedUsers = new List();
+ var parsedNkeys = new List();
+
+ foreach (var (rawKey, rawValue) in accountMap)
+ {
+ var key = rawKey.ToLowerInvariant();
+ var entry = NormalizeConfigValue(rawValue);
+ switch (key)
+ {
+ case "nkey":
+ {
+ var accountKey = entry as string ?? string.Empty;
+ if (!IsLikelyPublicNkey(accountKey, 'A'))
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Not a valid public nkey for an account: \"{accountKey}\""));
+ break;
+ }
+
+ account.Nkey = accountKey;
+ break;
+ }
+ case "imports":
+ {
+ var (importStreams, importServices, importError) = ParseAccountImports(entry, account, errors);
+ if (importError != null)
+ {
+ errors?.Add(importError);
+ break;
+ }
+
+ pendingImportStreams.AddRange(importStreams);
+ pendingImportServices.AddRange(importServices);
+ break;
+ }
+ case "exports":
+ {
+ var (exportStreams, exportServices, exportError) = ParseAccountExports(entry, account, errors);
+ if (exportError != null)
+ {
+ errors?.Add(exportError);
+ break;
+ }
+
+ pendingExportStreams.AddRange(exportStreams);
+ pendingExportServices.AddRange(exportServices);
+ break;
+ }
+ case "jetstream":
+ {
+ var jsError = ParseJetStreamForAccount(entry, account, errors);
+ if (jsError != null)
+ errors?.Add(jsError);
+ break;
+ }
+ case "users":
+ {
+ var (accountNkeys, accountUsers, usersError) = ParseUsers(entry, errors);
+ if (usersError != null)
+ {
+ errors?.Add(usersError);
+ break;
+ }
+
+ parsedUsers = accountUsers;
+ parsedNkeys = accountNkeys;
+ break;
+ }
+ case "default_permissions":
+ {
+ var permissions = ParsePermissionsValue(entry, errors);
+ if (permissions != null)
+ account.DefaultPerms = permissions;
+ break;
+ }
+ case "mappings":
+ case "maps":
+ {
+ var mappingsError = ParseAccountMappings(entry, account, errors);
+ if (mappingsError != null)
+ errors?.Add(mappingsError);
+ break;
+ }
+ case "limits":
+ {
+ var limitsError = ParseAccountLimits(entry, account, errors);
+ if (limitsError != null)
+ errors?.Add(limitsError);
+ break;
+ }
+ case "msg_trace":
+ case "trace_dest":
+ {
+ var traceError = ParseAccountMsgTrace(entry, key, account);
+ if (traceError != null)
+ {
+ errors?.Add(traceError);
+ break;
+ }
+
+ if (!string.IsNullOrEmpty(account.GetMessageTraceDestination()) &&
+ account.GetMessageTraceSampling() == 0)
+ {
+ account.SetMessageTraceSampling(100);
+ }
+ else if (account.GetMessageTraceSampling() > 0 &&
+ string.IsNullOrEmpty(account.GetMessageTraceDestination()))
+ {
+ warnings?.Add(new InvalidOperationException(
+ "Trace destination sampling ignored since no destination was set"));
+ account.SetMessageTraceSampling(0);
+ }
+
+ break;
+ }
+ default:
+ if (!ConfigFlags.AllowUnknownTopLevelField)
+ errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
+ break;
+ }
+ }
+
+ if (parsedNkeys.Count > 0 || parsedUsers.Count > 0)
+ {
+ if (!string.IsNullOrEmpty(options.Username))
+ errors?.Add(new InvalidOperationException("Cannot have a single user/pass and accounts"));
+ if (!string.IsNullOrEmpty(options.Authorization))
+ errors?.Add(new InvalidOperationException("Cannot have a token and accounts"));
+ }
+
+ ApplyDefaultPermissions(parsedUsers, parsedNkeys, account.DefaultPerms);
+
+ foreach (var nkeyUser in parsedNkeys)
+ {
+ if (!identities.Add(nkeyUser.Nkey))
+ {
+ errors?.Add(new InvalidOperationException($"Duplicate nkey \"{nkeyUser.Nkey}\" detected"));
+ continue;
+ }
+
+ nkeyUser.Account = account;
+ options.Nkeys.Add(nkeyUser);
+ }
+
+ foreach (var user in parsedUsers)
+ {
+ if (!identities.Add(user.Username))
+ {
+ errors?.Add(new InvalidOperationException($"Duplicate user \"{user.Username}\" detected"));
+ continue;
+ }
+
+ user.Account = account;
+ options.Users.Add(user);
+ }
+ }
+
+ if (errors is { Count: > 0 })
+ return null;
+
+ var accountLookup = options.Accounts.ToDictionary(a => a.Name, StringComparer.Ordinal);
+
+ foreach (var streamExport in pendingExportStreams)
+ {
+ if (streamExport.Account == null)
+ continue;
+
+ streamExport.Account.Exports.Streams ??= new Dictionary(StringComparer.Ordinal);
+ var export = new StreamExport
+ {
+ AccountPosition = streamExport.AccountTokenPosition,
+ };
+
+ if (streamExport.AccountNames.Count > 0)
+ {
+ export.Approved = new Dictionary(StringComparer.Ordinal);
+ foreach (var accountName in streamExport.AccountNames)
+ {
+ if (!accountLookup.TryGetValue(accountName, out var approvedAccount))
+ {
+ errors?.Add(new InvalidOperationException(
+ $"\"{accountName}\" account not defined for stream export"));
+ continue;
+ }
+
+ export.Approved[accountName] = approvedAccount;
+ }
+ }
+
+ streamExport.Account.Exports.Streams[streamExport.Subject] = export;
+ }
+
+ foreach (var serviceExport in pendingExportServices)
+ {
+ if (serviceExport.Account == null)
+ continue;
+
+ serviceExport.Account.Exports.Services ??= new Dictionary(StringComparer.Ordinal);
+ var export = new ServiceExportEntry
+ {
+ Account = serviceExport.Account,
+ ResponseType = serviceExport.ResponseType,
+ Latency = serviceExport.Latency as InternalServiceLatency,
+ ResponseThreshold = serviceExport.ResponseThreshold,
+ AllowTrace = serviceExport.AllowTrace,
+ AccountPosition = serviceExport.AccountTokenPosition,
+ };
+
+ if (serviceExport.AccountNames.Count > 0)
+ {
+ export.Approved = new Dictionary(StringComparer.Ordinal);
+ foreach (var accountName in serviceExport.AccountNames)
+ {
+ if (!accountLookup.TryGetValue(accountName, out var approvedAccount))
+ {
+ errors?.Add(new InvalidOperationException(
+ $"\"{accountName}\" account not defined for service export"));
+ continue;
+ }
+
+ export.Approved[accountName] = approvedAccount;
+ }
+ }
+
+ serviceExport.Account.Exports.Services[serviceExport.Subject] = export;
+ }
+
+ foreach (var streamImport in pendingImportStreams)
+ {
+ if (streamImport.Account == null)
+ continue;
+
+ if (!accountLookup.TryGetValue(streamImport.AccountName, out var importedAccount))
+ {
+ errors?.Add(new InvalidOperationException(
+ $"\"{streamImport.AccountName}\" account not defined for stream import"));
+ continue;
+ }
+
+ streamImport.Account.Imports.Streams ??= [];
+ var targetSubject = !string.IsNullOrEmpty(streamImport.To)
+ ? streamImport.To
+ : !string.IsNullOrEmpty(streamImport.Prefix)
+ ? streamImport.Prefix
+ : streamImport.Subject;
+
+ streamImport.Account.Imports.Streams.Add(new StreamImportEntry
+ {
+ Account = importedAccount,
+ From = streamImport.Subject,
+ To = targetSubject,
+ AllowTrace = streamImport.AllowTrace,
+ });
+ }
+
+ foreach (var serviceImport in pendingImportServices)
+ {
+ if (serviceImport.Account == null)
+ continue;
+
+ if (!accountLookup.TryGetValue(serviceImport.AccountName, out var importedAccount))
+ {
+ errors?.Add(new InvalidOperationException(
+ $"\"{serviceImport.AccountName}\" account not defined for service import"));
+ continue;
+ }
+
+ var targetSubject = string.IsNullOrEmpty(serviceImport.To)
+ ? serviceImport.Subject
+ : serviceImport.To;
+
+ serviceImport.Account.Imports.Services ??= new Dictionary>(StringComparer.Ordinal);
+ if (!serviceImport.Account.Imports.Services.TryGetValue(targetSubject, out var importsForSubject))
+ {
+ importsForSubject = [];
+ serviceImport.Account.Imports.Services[targetSubject] = importsForSubject;
+ }
+
+ importsForSubject.Add(new ServiceImportEntry
+ {
+ Account = importedAccount,
+ From = serviceImport.Subject,
+ To = targetSubject,
+ Share = serviceImport.Share,
+ });
+ }
+
+ return null;
+ }
+
+ ///
+ /// Parses account exports list.
+ /// Mirrors parseAccountExports in opts.go.
+ ///
+ public static (List Streams, List Services, Exception? Error) ParseAccountExports(
+ object? value,
+ Account account,
+ ICollection? errors = null)
+ {
+ ArgumentNullException.ThrowIfNull(account);
+ if (!TryGetArray(value, out var exportArray))
+ {
+ return ([], [], new InvalidOperationException(
+ $"Exports should be an array, got {value?.GetType().Name ?? "null"}"));
+ }
+
+ var streamExports = new List();
+ var serviceExports = new List();
+ foreach (var exportValue in exportArray)
+ {
+ var (stream, service, parseError) = ParseExportStreamOrService(exportValue, errors);
+ if (parseError != null)
+ {
+ errors?.Add(parseError);
+ continue;
+ }
+
+ if (stream != null)
+ {
+ stream.Account = account;
+ streamExports.Add(stream);
+ }
+
+ if (service != null)
+ {
+ service.Account = account;
+ serviceExports.Add(service);
+ }
+ }
+
+ return (streamExports, serviceExports, null);
+ }
+
+ ///
+ /// Parses account imports list.
+ /// Mirrors parseAccountImports in opts.go.
+ ///
+ public static (List Streams, List Services, Exception? Error) ParseAccountImports(
+ object? value,
+ Account account,
+ ICollection? errors = null)
+ {
+ ArgumentNullException.ThrowIfNull(account);
+ if (!TryGetArray(value, out var importArray))
+ {
+ return ([], [], new InvalidOperationException(
+ $"Imports should be an array, got {value?.GetType().Name ?? "null"}"));
+ }
+
+ var streamImports = new List();
+ var serviceImports = new List();
+ var serviceSubjects = new Dictionary>(StringComparer.Ordinal);
+
+ foreach (var importValue in importArray)
+ {
+ var (stream, service, parseError) = ParseImportStreamOrService(importValue, errors);
+ if (parseError != null)
+ {
+ errors?.Add(parseError);
+ continue;
+ }
+
+ if (service != null)
+ {
+ var targetSubject = string.IsNullOrEmpty(service.To) ? service.Subject : service.To;
+ if (!serviceSubjects.TryGetValue(targetSubject, out var seenAccounts))
+ {
+ seenAccounts = new HashSet(StringComparer.Ordinal);
+ serviceSubjects[targetSubject] = seenAccounts;
+ }
+
+ if (!seenAccounts.Add(service.AccountName))
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Duplicate service import subject \"{targetSubject}\", previously used in import for account \"{service.AccountName}\""));
+ continue;
+ }
+
+ service.Account = account;
+ serviceImports.Add(service);
+ }
+
+ if (stream != null)
+ {
+ stream.Account = account;
+ streamImports.Add(stream);
+ }
+ }
+
+ return (streamImports, serviceImports, null);
+ }
+
+ ///
+ /// Parses account descriptor maps used inside import entries.
+ /// Mirrors parseAccount in opts.go.
+ ///
+ public static (string AccountName, string Subject, Exception? Error) ParseAccount(
+ object? value,
+ ICollection? errors = null)
+ {
+ if (!TryGetMap(value, out var map))
+ {
+ return (string.Empty, string.Empty, new InvalidOperationException(
+ $"Expected account descriptor map, got {value?.GetType().Name ?? "null"}"));
+ }
+
+ var accountName = string.Empty;
+ var subject = string.Empty;
+ foreach (var (rawKey, rawValue) in map)
+ {
+ var key = rawKey.ToLowerInvariant();
+ var entry = NormalizeConfigValue(rawValue);
+ switch (key)
+ {
+ case "account":
+ accountName = entry as string ?? string.Empty;
+ break;
+ case "subject":
+ subject = entry as string ?? string.Empty;
+ break;
+ default:
+ if (!ConfigFlags.AllowUnknownTopLevelField)
+ errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
+ break;
+ }
+ }
+
+ return (accountName, subject, null);
+ }
+
+ ///
+ /// Parses a single export entry (stream or service).
+ /// Mirrors parseExportStreamOrService in opts.go.
+ ///
+ public static (AccountExportConfig? Stream, AccountExportConfig? Service, Exception? Error) ParseExportStreamOrService(
+ object? value,
+ ICollection? errors = null)
+ {
+ if (!TryGetMap(value, out var map))
+ {
+ return (null, null, new InvalidOperationException(
+ $"Export items should be a map with type entry, got {value?.GetType().Name ?? "null"}"));
+ }
+
+ AccountExportConfig? stream = null;
+ AccountExportConfig? service = null;
+ var accountNames = new List();
+ var responseType = ServiceRespType.Singleton;
+ var responseTypeSeen = false;
+ var responseThreshold = TimeSpan.Zero;
+ var thresholdSeen = false;
+ object? latency = null;
+ uint accountTokenPosition = 0;
+ var allowTraceSeen = false;
+ var allowTrace = false;
+
+ foreach (var (rawKey, rawValue) in map)
+ {
+ var key = rawKey.ToLowerInvariant();
+ var entry = NormalizeConfigValue(rawValue);
+ switch (key)
+ {
+ case "stream":
+ {
+ if (service != null)
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Detected stream \"{entry}\" but already saw a service"));
+ break;
+ }
+
+ var subject = entry as string;
+ if (string.IsNullOrEmpty(subject))
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Expected stream name to be string, got {entry?.GetType().Name ?? "null"}"));
+ break;
+ }
+
+ stream = new AccountExportConfig
+ {
+ Subject = subject,
+ AccountNames = [.. accountNames],
+ };
+ break;
+ }
+ case "service":
+ {
+ if (stream != null)
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Detected service \"{entry}\" but already saw a stream"));
+ break;
+ }
+
+ var subject = entry as string;
+ if (string.IsNullOrEmpty(subject))
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Expected service name to be string, got {entry?.GetType().Name ?? "null"}"));
+ break;
+ }
+
+ service = new AccountExportConfig
+ {
+ Subject = subject,
+ AccountNames = [.. accountNames],
+ ResponseType = responseType,
+ Latency = latency,
+ ResponseThreshold = responseThreshold,
+ AllowTrace = allowTraceSeen && allowTrace,
+ };
+ break;
+ }
+ case "response":
+ case "response_type":
+ {
+ if (responseTypeSeen)
+ {
+ errors?.Add(new InvalidOperationException("Duplicate response type definition"));
+ break;
+ }
+
+ responseTypeSeen = true;
+ var responseString = (entry as string ?? string.Empty).ToLowerInvariant();
+ var responseTypeParsed = responseString switch
+ {
+ "single" or "singleton" => ServiceRespType.Singleton,
+ "stream" => ServiceRespType.Streamed,
+ "chunk" or "chunked" => ServiceRespType.Chunked,
+ _ => (ServiceRespType?)null,
+ };
+ if (responseTypeParsed == null)
+ {
+ errors?.Add(new InvalidOperationException($"Unknown response type: \"{entry}\""));
+ break;
+ }
+
+ responseType = responseTypeParsed.Value;
+
+ if (stream != null)
+ errors?.Add(new InvalidOperationException("Detected response directive on non-service"));
+ if (service != null)
+ service.ResponseType = responseType;
+ break;
+ }
+ case "threshold":
+ case "response_threshold":
+ case "response_max_time":
+ case "response_time":
+ {
+ if (thresholdSeen)
+ {
+ errors?.Add(new InvalidOperationException("Duplicate response threshold detected"));
+ break;
+ }
+
+ thresholdSeen = true;
+ responseThreshold = ParseDuration(rawKey, entry, errors, warnings: null);
+ if (stream != null)
+ errors?.Add(new InvalidOperationException("Detected response directive on non-service"));
+ if (service != null)
+ service.ResponseThreshold = responseThreshold;
+ break;
+ }
+ case "accounts":
+ accountNames = ParseStringList(entry);
+ if (stream != null)
+ stream.AccountNames = [.. accountNames];
+ if (service != null)
+ service.AccountNames = [.. accountNames];
+ break;
+ case "latency":
+ {
+ var (parsedLatency, latencyError) = ParseServiceLatency(rawKey, entry);
+ if (latencyError != null)
+ {
+ errors?.Add(latencyError);
+ break;
+ }
+
+ latency = parsedLatency;
+ if (stream != null)
+ {
+ errors?.Add(new InvalidOperationException("Detected latency directive on non-service"));
+ break;
+ }
+
+ if (service != null)
+ service.Latency = latency;
+ break;
+ }
+ case "account_token_position":
+ if (TryConvertToLong(entry, out var tokenPosition))
+ accountTokenPosition = checked((uint)tokenPosition);
+ break;
+ case "allow_trace":
+ allowTraceSeen = true;
+ if (TryConvertToBool(entry, out var allowTraceValue))
+ allowTrace = allowTraceValue;
+ if (stream != null)
+ {
+ errors?.Add(new InvalidOperationException("Detected allow_trace directive on non-service"));
+ break;
+ }
+
+ if (service != null)
+ service.AllowTrace = allowTrace;
+ break;
+ default:
+ if (!ConfigFlags.AllowUnknownTopLevelField)
+ errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
+ break;
+ }
+ }
+
+ if (stream != null)
+ stream.AccountTokenPosition = accountTokenPosition;
+ if (service != null)
+ service.AccountTokenPosition = accountTokenPosition;
+
+ return (stream, service, null);
+ }
+
+ ///
+ /// Parses service-export latency configuration.
+ /// Mirrors parseServiceLatency in opts.go.
+ ///
+ public static (object? Latency, Exception? Error) ParseServiceLatency(string rootField, object? value)
+ {
+ if (NormalizeConfigValue(value) is string subject)
+ {
+ return (new InternalServiceLatency
+ {
+ Subject = subject,
+ Sampling = ServerConstants.DefaultServiceLatencySampling,
+ }, null);
+ }
+
+ if (!TryGetMap(value, out var latencyMap))
+ {
+ return (null, new InvalidOperationException(
+ $"Expected latency entry to be a map/struct or string, got {value?.GetType().Name ?? "null"}"));
+ }
+
+ var sampling = ServerConstants.DefaultServiceLatencySampling;
+ if (latencyMap.TryGetValue("sampling", out var samplingRaw))
+ {
+ var samplingValue = NormalizeConfigValue(samplingRaw);
+ var headerMode = false;
+ switch (samplingValue)
+ {
+ case long longSampling:
+ sampling = checked((int)longSampling);
+ break;
+ case string samplingString:
+ {
+ if (samplingString.Trim().Equals("headers", StringComparison.OrdinalIgnoreCase))
+ {
+ headerMode = true;
+ sampling = 0;
+ break;
+ }
+
+ var normalizedSampling = samplingString.Trim().TrimEnd('%');
+ if (!int.TryParse(normalizedSampling, out sampling))
+ {
+ return (null, new InvalidOperationException(
+ $"Failed to parse latency sample \"{samplingString}\""));
+ }
+
+ break;
+ }
+ default:
+ return (null, new InvalidOperationException(
+ $"Expected latency sample to be a string or integer, got {samplingValue?.GetType().Name ?? "null"}"));
+ }
+
+ if (!headerMode && (sampling < 1 || sampling > 100))
+ return (null, new InvalidOperationException("sampling value should be in range [1..100]"));
+ }
+
+ if (!latencyMap.TryGetValue("subject", out var subjectRaw))
+ {
+ return (null, new InvalidOperationException(
+ $"Latency subject required in \"{rootField}\", but missing"));
+ }
+
+ var latencySubject = NormalizeConfigValue(subjectRaw) as string;
+ if (string.IsNullOrWhiteSpace(latencySubject))
+ {
+ return (null, new InvalidOperationException(
+ $"Expected latency subject to be a string, got {subjectRaw?.GetType().Name ?? "null"}"));
+ }
+
+ return (new InternalServiceLatency
+ {
+ Sampling = sampling,
+ Subject = latencySubject,
+ }, null);
+ }
+
+ ///
+ /// Parses a single import entry (stream or service).
+ /// Mirrors parseImportStreamOrService in opts.go.
+ ///
+ public static (AccountImportStreamConfig? Stream, AccountImportServiceConfig? Service, Exception? Error) ParseImportStreamOrService(
+ object? value,
+ ICollection? errors = null)
+ {
+ if (!TryGetMap(value, out var map))
+ {
+ return (null, null, new InvalidOperationException(
+ $"Import items should be a map with type entry, got {value?.GetType().Name ?? "null"}"));
+ }
+
+ AccountImportStreamConfig? stream = null;
+ AccountImportServiceConfig? service = null;
+ var prefix = string.Empty;
+ var to = string.Empty;
+ var share = false;
+ var allowTraceSeen = false;
+ var allowTrace = false;
+
+ foreach (var (rawKey, rawValue) in map)
+ {
+ var key = rawKey.ToLowerInvariant();
+ var entry = NormalizeConfigValue(rawValue);
+ switch (key)
+ {
+ case "stream":
+ {
+ if (service != null)
+ {
+ errors?.Add(new InvalidOperationException("Detected stream but already saw a service"));
+ break;
+ }
+
+ var (accountName, subject, parseError) = ParseAccount(entry, errors);
+ if (parseError != null)
+ {
+ errors?.Add(parseError);
+ break;
+ }
+
+ if (string.IsNullOrEmpty(accountName) || string.IsNullOrEmpty(subject))
+ {
+ errors?.Add(new InvalidOperationException("Expect an account name and a subject"));
+ break;
+ }
+
+ stream = new AccountImportStreamConfig
+ {
+ AccountName = accountName,
+ Subject = subject,
+ Prefix = prefix,
+ To = to,
+ AllowTrace = allowTraceSeen && allowTrace,
+ };
+ break;
+ }
+ case "service":
+ {
+ if (stream != null)
+ {
+ errors?.Add(new InvalidOperationException("Detected service but already saw a stream"));
+ break;
+ }
+
+ if (allowTraceSeen)
+ {
+ errors?.Add(new InvalidOperationException(
+ "Detected allow_trace directive on a non-stream"));
+ break;
+ }
+
+ var (accountName, subject, parseError) = ParseAccount(entry, errors);
+ if (parseError != null)
+ {
+ errors?.Add(parseError);
+ break;
+ }
+
+ if (string.IsNullOrEmpty(accountName) || string.IsNullOrEmpty(subject))
+ {
+ errors?.Add(new InvalidOperationException("Expect an account name and a subject"));
+ break;
+ }
+
+ service = new AccountImportServiceConfig
+ {
+ AccountName = accountName,
+ Subject = subject,
+ To = string.IsNullOrEmpty(to) ? subject : to,
+ Share = share,
+ };
+ break;
+ }
+ case "prefix":
+ prefix = entry as string ?? string.Empty;
+ if (stream != null)
+ stream.Prefix = prefix;
+ break;
+ case "to":
+ to = entry as string ?? string.Empty;
+ if (service != null)
+ service.To = to;
+ if (stream != null)
+ {
+ stream.To = to;
+ if (!string.IsNullOrEmpty(stream.Prefix))
+ errors?.Add(new InvalidOperationException(
+ "Stream import cannot have both 'prefix' and 'to' properties"));
+ }
+
+ break;
+ case "share":
+ if (TryConvertToBool(entry, out var shareValue))
+ share = shareValue;
+ if (service != null)
+ service.Share = share;
+ break;
+ case "allow_trace":
+ if (service != null)
+ {
+ errors?.Add(new InvalidOperationException(
+ "Detected allow_trace directive on a non-stream"));
+ break;
+ }
+
+ allowTraceSeen = true;
+ if (TryConvertToBool(entry, out var allowTraceValue))
+ allowTrace = allowTraceValue;
+ if (stream != null)
+ stream.AllowTrace = allowTrace;
+ break;
+ default:
+ if (!ConfigFlags.AllowUnknownTopLevelField)
+ errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
+ break;
+ }
+ }
+
+ return (stream, service, null);
+ }
+
+ ///
+ /// Applies default permissions to users and nkeys that do not have explicit permissions.
+ /// Mirrors applyDefaultPermissions in opts.go.
+ ///
+ public static void ApplyDefaultPermissions(
+ IReadOnlyList? users,
+ IReadOnlyList? nkeys,
+ Permissions? defaultPermissions)
+ {
+ if (defaultPermissions == null)
+ return;
+
+ if (users != null)
+ {
+ foreach (var user in users)
+ {
+ user.Permissions ??= defaultPermissions;
+ }
+ }
+
+ if (nkeys != null)
+ {
+ foreach (var user in nkeys)
+ {
+ user.Permissions ??= defaultPermissions;
+ }
+ }
+ }
+
+ ///
+ /// Parses an authorization block.
+ /// Mirrors parseAuthorization in opts.go.
+ ///
+ public static (ParsedAuthorizationBlock? Authorization, Exception? Error) ParseAuthorization(
+ object? value,
+ ICollection? errors = null,
+ ICollection? warnings = null)
+ {
+ if (!TryGetMap(value, out var map))
+ return (null, new InvalidOperationException("authorization should be a map"));
+
+ var auth = new ParsedAuthorizationBlock();
+ foreach (var (rawKey, rawValue) in map)
+ {
+ var key = rawKey.ToLowerInvariant();
+ var entry = NormalizeConfigValue(rawValue);
+ switch (key)
+ {
+ case "user":
+ case "username":
+ auth.User = entry as string ?? string.Empty;
+ break;
+ case "pass":
+ case "password":
+ auth.Pass = entry as string ?? string.Empty;
+ break;
+ case "token":
+ auth.Token = entry as string ?? string.Empty;
+ break;
+ case "timeout":
+ {
+ double timeoutSeconds;
+ switch (entry)
+ {
+ case long longTimeout:
+ timeoutSeconds = longTimeout;
+ break;
+ case double doubleTimeout:
+ timeoutSeconds = doubleTimeout;
+ break;
+ case string duration:
+ timeoutSeconds = ParseDuration("timeout", duration, errors, warnings).TotalSeconds;
+ break;
+ default:
+ return (null, new InvalidOperationException(
+ "error parsing authorization config, 'timeout' wrong type"));
+ }
+
+ auth.TimeoutSeconds = timeoutSeconds;
+ if (timeoutSeconds > TimeSpan.FromSeconds(60).TotalSeconds)
+ {
+ warnings?.Add(new InvalidOperationException(
+ $"timeout of {entry} ({timeoutSeconds} seconds) is high, consider keeping it under 60 seconds"));
+ }
+
+ break;
+ }
+ case "users":
+ {
+ var (nkeys, users, parseError) = ParseUsers(entry, errors);
+ if (parseError != null)
+ {
+ errors?.Add(parseError);
+ break;
+ }
+
+ auth.Users = users;
+ auth.Nkeys = nkeys;
+ break;
+ }
+ case "default_permission":
+ case "default_permissions":
+ case "permissions":
+ auth.DefaultPermissions = ParsePermissionsValue(entry, errors);
+ break;
+ case "auth_callout":
+ case "auth_hook":
+ {
+ var (callout, parseError) = ParseAuthCallout(entry, errors);
+ if (parseError != null)
+ {
+ errors?.Add(parseError);
+ break;
+ }
+
+ auth.Callout = callout;
+ break;
+ }
+ case "proxy_required":
+ if (TryConvertToBool(entry, out var proxyRequired))
+ auth.ProxyRequired = proxyRequired;
+ break;
+ default:
+ if (!ConfigFlags.AllowUnknownTopLevelField)
+ errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
+ break;
+ }
+ }
+
+ ApplyDefaultPermissions(auth.Users, auth.Nkeys, auth.DefaultPermissions);
+ return (auth, null);
+ }
+
+ ///
+ /// Parses users/nkeys block.
+ /// Mirrors parseUsers in opts.go.
+ ///
+ public static (List Nkeys, List Users, Exception? Error) ParseUsers(
+ object? value,
+ ICollection? errors = null)
+ {
+ if (!TryGetArray(value, out var usersArray))
+ {
+ return ([], [], new InvalidOperationException(
+ $"Expected users field to be an array, got {value?.GetType().Name ?? "null"}"));
+ }
+
+ var users = new List();
+ var nkeys = new List();
+ foreach (var userRaw in usersArray)
+ {
+ if (!TryGetMap(userRaw, out var userMap))
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Expected user entry to be a map/struct, got {userRaw?.GetType().Name ?? "null"}"));
+ continue;
+ }
+
+ var user = new User();
+ var nkey = new NkeyUser();
+ Permissions? permissions = null;
+ foreach (var (rawKey, rawValue) in userMap)
+ {
+ var key = rawKey.ToLowerInvariant();
+ var entry = NormalizeConfigValue(rawValue);
+ switch (key)
+ {
+ case "nkey":
+ nkey.Nkey = entry as string ?? string.Empty;
+ break;
+ case "user":
+ case "username":
+ user.Username = entry as string ?? string.Empty;
+ break;
+ case "pass":
+ case "password":
+ user.Password = entry as string ?? string.Empty;
+ break;
+ case "permission":
+ case "permissions":
+ case "authorization":
+ permissions = ParsePermissionsValue(entry, errors);
+ break;
+ case "allowed_connection_types":
+ case "connection_types":
+ case "clients":
+ {
+ var connectionTypes = ParseAllowedConnectionTypes(entry, errors);
+ nkey.AllowedConnectionTypes = connectionTypes;
+ user.AllowedConnectionTypes = connectionTypes;
+ break;
+ }
+ case "proxy_required":
+ if (TryConvertToBool(entry, out var proxyRequired))
+ {
+ nkey.ProxyRequired = proxyRequired;
+ user.ProxyRequired = proxyRequired;
+ }
+
+ break;
+ default:
+ if (!ConfigFlags.AllowUnknownTopLevelField)
+ errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
+ break;
+ }
+ }
+
+ if (permissions != null)
+ {
+ if (!string.IsNullOrEmpty(nkey.Nkey))
+ nkey.Permissions = permissions;
+ else
+ user.Permissions = permissions;
+ }
+
+ if (string.IsNullOrEmpty(nkey.Nkey) && string.IsNullOrEmpty(user.Username))
+ {
+ return ([], [], new InvalidOperationException("User entry requires a user"));
+ }
+
+ if (!string.IsNullOrEmpty(nkey.Nkey))
+ {
+ if (!IsLikelyPublicNkey(nkey.Nkey, 'U'))
+ return ([], [], new InvalidOperationException("Not a valid public nkey for a user"));
+
+ if (!string.IsNullOrEmpty(user.Username) || !string.IsNullOrEmpty(user.Password))
+ {
+ return ([], [], new InvalidOperationException(
+ "Nkey users do not take usernames or passwords"));
+ }
+
+ nkeys.Add(nkey);
+ }
+ else
+ {
+ users.Add(user);
+ }
+ }
+
+ return (nkeys, users, null);
+ }
+
+ ///
+ /// Parses allowed connection types map.
+ /// Mirrors parseAllowedConnectionTypes in opts.go.
+ ///
+ public static HashSet? ParseAllowedConnectionTypes(
+ object? value,
+ ICollection? errors = null)
+ {
+ if (!TryGetArray(value, out var values))
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Expected allowed connection types to be an array, got {value?.GetType().Name ?? "null"}"));
+ return null;
+ }
+
+ var result = new HashSet(StringComparer.Ordinal);
+ foreach (var item in values)
+ {
+ var connectionType = NormalizeConfigValue(item) as string;
+ if (string.IsNullOrEmpty(connectionType))
+ {
+ errors?.Add(new InvalidOperationException("allowed connection type entries must be strings"));
+ continue;
+ }
+
+ result.Add(connectionType);
+ }
+
+ var validateError = AuthHandler.ValidateAllowedConnectionTypes(result);
+ if (validateError != null)
+ errors?.Add(validateError);
+
+ return result;
+ }
+
+ ///
+ /// Parses authorization callout configuration.
+ /// Mirrors parseAuthCallout in opts.go.
+ ///
+ public static (AuthCalloutOpts? Callout, Exception? Error) ParseAuthCallout(
+ object? value,
+ ICollection? errors = null)
+ {
+ if (!TryGetMap(value, out var map))
+ {
+ return (null, new InvalidOperationException(
+ $"Expected authorization callout to be a map/struct, got {value?.GetType().Name ?? "null"}"));
+ }
+
+ var callout = new AuthCalloutOpts();
+ foreach (var (rawKey, rawValue) in map)
+ {
+ var key = rawKey.ToLowerInvariant();
+ var entry = NormalizeConfigValue(rawValue);
+ switch (key)
+ {
+ case "issuer":
+ callout.Issuer = entry as string ?? string.Empty;
+ if (!IsLikelyPublicNkey(callout.Issuer, 'A'))
+ {
+ return (null, new InvalidOperationException(
+ $"Expected callout user to be a valid public account nkey, got \"{callout.Issuer}\""));
+ }
+
+ break;
+ case "account":
+ case "acc":
+ callout.Account = entry as string ?? string.Empty;
+ break;
+ case "auth_users":
+ case "users":
+ if (!TryGetArray(entry, out var authUsersArray))
+ {
+ return (null, new InvalidOperationException(
+ $"Expected auth_users field to be an array, got {entry?.GetType().Name ?? "null"}"));
+ }
+
+ foreach (var userValue in authUsersArray)
+ {
+ var authUser = NormalizeConfigValue(userValue) as string;
+ if (!string.IsNullOrEmpty(authUser))
+ callout.AuthUsers.Add(authUser);
+ }
+
+ break;
+ case "xkey":
+ case "key":
+ callout.XKey = entry as string ?? string.Empty;
+ if (!string.IsNullOrEmpty(callout.XKey) && !IsLikelyPublicNkey(callout.XKey, 'X'))
+ {
+ return (null, new InvalidOperationException(
+ $"Expected callout xkey to be a valid public xkey, got \"{callout.XKey}\""));
+ }
+
+ break;
+ case "allowed_accounts":
+ if (!TryGetArray(entry, out var allowedAccountsArray))
+ {
+ return (null, new InvalidOperationException(
+ $"Expected allowed accounts field to be an array, got {entry?.GetType().Name ?? "null"}"));
+ }
+
+ foreach (var accountValue in allowedAccountsArray)
+ {
+ var accountName = NormalizeConfigValue(accountValue) as string;
+ if (!string.IsNullOrEmpty(accountName))
+ callout.AllowedAccounts.Add(accountName);
+ }
+
+ break;
+ default:
+ if (!ConfigFlags.AllowUnknownTopLevelField)
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Unknown field \"{rawKey}\" parsing authorization callout"));
+ }
+
+ break;
+ }
+ }
+
+ if (string.IsNullOrEmpty(callout.Account))
+ callout.Account = ServerConstants.DefaultGlobalAccount;
+ if (string.IsNullOrEmpty(callout.Issuer))
+ return (null, new InvalidOperationException("Authorization callouts require an issuer to be specified"));
+ if (callout.AuthUsers.Count == 0)
+ return (null, new InvalidOperationException("Authorization callouts require authorized users to be specified"));
+
+ return (callout, null);
+ }
+
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
@@ -2140,6 +3846,179 @@ public sealed partial class ServerOptions
public bool ProxyRequired { get; set; }
}
+ private static Permissions? ParsePermissionsValue(object? value, ICollection? errors)
+ {
+ if (!TryGetMap(value, out var map))
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Expected permissions to be a map/struct, got {value?.GetType().Name ?? "null"}"));
+ return null;
+ }
+
+ var permissions = new Permissions();
+ foreach (var (rawKey, rawValue) in map)
+ {
+ var key = rawKey.ToLowerInvariant();
+ var entry = NormalizeConfigValue(rawValue);
+ switch (key)
+ {
+ case "publish":
+ case "import":
+ permissions.Publish = ParseSubjectPermissionValue(entry, errors);
+ break;
+ case "subscribe":
+ case "export":
+ permissions.Subscribe = ParseSubjectPermissionValue(entry, errors);
+ break;
+ case "responses":
+ case "allow_responses":
+ permissions.Response = ParseAllowResponsesValue(entry, errors);
+ break;
+ default:
+ if (!ConfigFlags.AllowUnknownTopLevelField)
+ errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
+ break;
+ }
+ }
+
+ AuthHandler.ValidateResponsePermissions(permissions);
+ return permissions;
+ }
+
+ private static SubjectPermission? ParseSubjectPermissionValue(object? value, ICollection? errors)
+ {
+ var normalized = NormalizeConfigValue(value);
+ if (TryGetMap(normalized, out var map))
+ {
+ var permission = new SubjectPermission();
+ foreach (var (rawKey, rawValue) in map)
+ {
+ var key = rawKey.ToLowerInvariant();
+ var entry = NormalizeConfigValue(rawValue);
+ switch (key)
+ {
+ case "allow":
+ permission.Allow = ParsePermissionSubjects(entry, errors);
+ break;
+ case "deny":
+ permission.Deny = ParsePermissionSubjects(entry, errors);
+ break;
+ default:
+ if (!ConfigFlags.AllowUnknownTopLevelField)
+ errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
+ break;
+ }
+ }
+
+ return permission;
+ }
+
+ return new SubjectPermission
+ {
+ Allow = ParsePermissionSubjects(normalized, errors),
+ };
+ }
+
+ private static List ParsePermissionSubjects(object? value, ICollection? errors)
+ {
+ if (TryGetArray(value, out var array))
+ {
+ var subjects = new List(array.Count);
+ foreach (var entry in array)
+ {
+ var subject = NormalizeConfigValue(entry) as string;
+ if (string.IsNullOrWhiteSpace(subject))
+ {
+ errors?.Add(new InvalidOperationException("permission subjects must be non-empty strings"));
+ continue;
+ }
+
+ if (!SubscriptionIndex.IsValidSubject(subject))
+ {
+ errors?.Add(new InvalidOperationException($"invalid subject \"{subject}\" in permissions"));
+ continue;
+ }
+
+ subjects.Add(subject);
+ }
+
+ return subjects;
+ }
+
+ if (NormalizeConfigValue(value) is string singleSubject)
+ {
+ if (!SubscriptionIndex.IsValidSubject(singleSubject))
+ {
+ errors?.Add(new InvalidOperationException($"invalid subject \"{singleSubject}\" in permissions"));
+ return [];
+ }
+
+ return [singleSubject];
+ }
+
+ errors?.Add(new InvalidOperationException(
+ $"Expected permission subject array/string, got {value?.GetType().Name ?? "null"}"));
+ return [];
+ }
+
+ private static ResponsePermission? ParseAllowResponsesValue(object? value, ICollection? errors)
+ {
+ var normalized = NormalizeConfigValue(value);
+ if (TryConvertToBool(normalized, out var enabled))
+ {
+ return enabled
+ ? new ResponsePermission
+ {
+ MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs,
+ Expires = ServerConstants.DefaultAllowResponseExpiration,
+ }
+ : null;
+ }
+
+ if (!TryGetMap(normalized, out var map))
+ {
+ errors?.Add(new InvalidOperationException(
+ $"Expected allow_responses to be a boolean or map, got {normalized?.GetType().Name ?? "null"}"));
+ return null;
+ }
+
+ var response = new ResponsePermission
+ {
+ MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs,
+ Expires = ServerConstants.DefaultAllowResponseExpiration,
+ };
+
+ foreach (var (rawKey, rawValue) in map)
+ {
+ var key = rawKey.ToLowerInvariant();
+ var entry = NormalizeConfigValue(rawValue);
+ switch (key)
+ {
+ case "max":
+ case "max_msgs":
+ if (TryConvertToLong(entry, out var maxMessages))
+ response.MaxMsgs = checked((int)maxMessages);
+ else
+ errors?.Add(new InvalidOperationException("allow_responses.max should be an integer"));
+ break;
+ case "expires":
+ response.Expires = ParseDuration("allow_responses.expires", entry, errors, warnings: null);
+ break;
+ default:
+ if (!ConfigFlags.AllowUnknownTopLevelField)
+ errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
+ break;
+ }
+ }
+
+ return response;
+ }
+
+ private static bool IsLikelyPublicNkey(string value, char prefix) =>
+ !string.IsNullOrWhiteSpace(value) &&
+ value.Length >= 10 &&
+ value[0] == prefix;
+
private static WriteTimeoutPolicy ParseWriteDeadlinePolicyFallback(string value, ICollection? errors)
{
errors?.Add(new InvalidOperationException(
diff --git a/porting.db b/porting.db
index 26d0c0e..d326e7a 100644
Binary files a/porting.db and b/porting.db differ
diff --git a/reports/current.md b/reports/current.md
index 9b5c14c..fadedc9 100644
--- a/reports/current.md
+++ b/reports/current.md
@@ -1,6 +1,6 @@
# NATS .NET Porting Status Report
-Generated: 2026-02-28 14:37:42 UTC
+Generated: 2026-02-28 14:47:33 UTC
## Modules (12 total)
@@ -12,10 +12,10 @@ Generated: 2026-02-28 14:37:42 UTC
| Status | Count |
|--------|-------|
-| deferred | 2094 |
+| deferred | 2077 |
| n_a | 24 |
| stub | 1 |
-| verified | 1554 |
+| verified | 1571 |
## Unit Tests (3257 total)
@@ -34,4 +34,4 @@ Generated: 2026-02-28 14:37:42 UTC
## Overall Progress
-**2799/6942 items complete (40.3%)**
+**2816/6942 items complete (40.6%)**