// Copyright 2018-2026 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 server/accounts.go in the NATS server Go source.
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// IAccountResolver
// Mirrors Go AccountResolver interface (accounts.go ~line 4035).
// ============================================================================
///
/// Resolves and stores account JWTs by account public key name.
/// Mirrors Go AccountResolver interface.
///
public interface IAccountResolver
{
///
/// Fetches the JWT for the named account.
/// Throws when the account is not found.
/// Mirrors Go AccountResolver.Fetch.
///
Task FetchAsync(string name, CancellationToken ct = default);
///
/// Stores the JWT for the named account.
/// Read-only implementations throw .
/// Mirrors Go AccountResolver.Store.
///
Task StoreAsync(string name, string jwt, CancellationToken ct = default);
/// Returns true when no writes are permitted. Mirrors Go IsReadOnly.
bool IsReadOnly();
///
/// Starts any background processing needed by the resolver (system subscriptions, timers, etc.).
/// The parameter accepts an object to avoid a circular assembly
/// reference; implementations should cast it to the concrete server type as needed.
/// Mirrors Go AccountResolver.Start.
///
void Start(object server);
/// Returns true when the resolver reacts to JWT update events. Mirrors Go IsTrackingUpdate.
bool IsTrackingUpdate();
/// Reloads state from the backing store. Mirrors Go AccountResolver.Reload.
void Reload();
/// Releases resources held by the resolver. Mirrors Go AccountResolver.Close.
void Close();
}
// ============================================================================
// ResolverDefaultsOps
// Mirrors Go resolverDefaultsOpsImpl (accounts.go ~line 4046).
// ============================================================================
///
/// Abstract base that provides sensible no-op / read-only defaults for
/// so concrete implementations only need to override what they change.
/// Mirrors Go resolverDefaultsOpsImpl.
///
public abstract class ResolverDefaultsOps : IAccountResolver
{
///
public abstract Task FetchAsync(string name, CancellationToken ct = default);
///
/// Default store implementation — always throws because the base defaults to read-only.
/// Mirrors Go resolverDefaultsOpsImpl.Store.
///
public virtual Task StoreAsync(string name, string jwt, CancellationToken ct = default)
=> throw new NotSupportedException("store operation not supported");
/// Default: the resolver is read-only. Mirrors Go resolverDefaultsOpsImpl.IsReadOnly.
public virtual bool IsReadOnly() => true;
/// Default: no-op start. Mirrors Go resolverDefaultsOpsImpl.Start.
public virtual void Start(object server) { }
/// Default: does not track updates. Mirrors Go resolverDefaultsOpsImpl.IsTrackingUpdate.
public virtual bool IsTrackingUpdate() => false;
/// Default: no-op reload. Mirrors Go resolverDefaultsOpsImpl.Reload.
public virtual void Reload() { }
/// Default: no-op close. Mirrors Go resolverDefaultsOpsImpl.Close.
public virtual void Close() { }
}
// ============================================================================
// MemoryAccountResolver
// Mirrors Go MemAccResolver (accounts.go ~line 4072).
// ============================================================================
///
/// An in-memory account resolver backed by a .
/// Primarily intended for testing.
/// Mirrors Go MemAccResolver.
///
public sealed class MemoryAccountResolver : ResolverDefaultsOps
{
private readonly ConcurrentDictionary _store = new(StringComparer.Ordinal);
/// In-memory resolver is not read-only.
public override bool IsReadOnly() => false;
///
/// Returns the stored JWT for , or throws
/// when the account is unknown.
/// Mirrors Go MemAccResolver.Fetch.
///
public override Task FetchAsync(string name, CancellationToken ct = default)
{
if (_store.TryGetValue(name, out var jwt))
{
return Task.FromResult(jwt);
}
throw new InvalidOperationException($"Account not found: {name}");
}
///
/// Stores for .
/// Mirrors Go MemAccResolver.Store.
///
public override Task StoreAsync(string name, string jwt, CancellationToken ct = default)
{
_store[name] = jwt;
return Task.CompletedTask;
}
}
// ============================================================================
// UrlAccountResolver
// Mirrors Go URLAccResolver (accounts.go ~line 4097).
// ============================================================================
///
/// An HTTP-based account resolver that fetches JWTs by appending the account public key
/// to a configured base URL.
/// Mirrors Go URLAccResolver.
///
public sealed class UrlAccountResolver : ResolverDefaultsOps
{
// Mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT.
private static readonly TimeSpan DefaultAccountFetchTimeout = TimeSpan.FromSeconds(2);
private readonly string _url;
private readonly HttpClient _httpClient;
///
/// Creates a new URL resolver for the given .
/// A trailing slash is appended when absent so that account names can be concatenated
/// directly. An is configured with connection-pooling
/// settings that amortise TLS handshakes across requests, mirroring Go's custom
/// http.Transport.
/// Mirrors Go NewURLAccResolver.
///
public UrlAccountResolver(string url)
{
if (!url.EndsWith('/'))
{
url += "/";
}
_url = url;
// Mirror Go: MaxIdleConns=10, IdleConnTimeout=30s on a custom transport.
var handler = new SocketsHttpHandler
{
MaxConnectionsPerServer = 10,
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
};
_httpClient = new HttpClient(handler)
{
Timeout = DefaultAccountFetchTimeout,
};
}
///
/// Issues an HTTP GET to the base URL with the account name appended, and returns
/// the response body as the JWT string.
/// Throws on a non-200 response.
/// Mirrors Go URLAccResolver.Fetch.
///
public override async Task FetchAsync(string name, CancellationToken ct = default)
{
var requestUrl = _url + name;
HttpResponseMessage response;
try
{
response = await _httpClient.GetAsync(requestUrl, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
throw new InvalidOperationException($"could not fetch <\"{requestUrl}\">: {ex.Message}", ex);
}
using (response)
{
if (response.StatusCode != HttpStatusCode.OK)
{
throw new InvalidOperationException(
$"could not fetch <\"{requestUrl}\">: {(int)response.StatusCode} {response.ReasonPhrase}");
}
return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
}
}
}
// ============================================================================
// DirResOption — functional option for DirAccountResolver
// Mirrors Go DirResOption func type (accounts.go ~line 4552).
// ============================================================================
///
/// A functional option that configures a instance.
/// Mirrors Go DirResOption function type.
///
public delegate void DirResOption(DirAccountResolver resolver);
///
/// Factory methods for commonly used values.
///
public static class DirResOptions
{
///
/// Returns an option that overrides the default fetch timeout.
/// must be positive.
/// Mirrors Go FetchTimeout option constructor.
///
///
/// Thrown at application time when is not positive.
///
public static DirResOption FetchTimeout(TimeSpan timeout)
{
if (timeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(timeout),
$"Fetch timeout {timeout} is too small");
}
return resolver => resolver.FetchTimeout = timeout;
}
}
// ============================================================================
// DirAccountResolver (stub)
// Mirrors Go DirAccResolver (accounts.go ~line 4143).
// Full system-subscription wiring is deferred to session 12.
// ============================================================================
///
/// A directory-backed account resolver that stores JWTs in a
/// and synchronises with peers via NATS system subjects.
///
/// The Start override that wires up system subscriptions and the periodic sync goroutine
/// is a stub in this session; full implementation requires JetStream and system
/// subscription support (session 12+).
///
/// Mirrors Go DirAccResolver.
///
public class DirAccountResolver : ResolverDefaultsOps, IDisposable
{
// Default fetch timeout — mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT (2 s).
private static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(2);
// Default sync interval — mirrors Go's fallback of 1 minute.
private static readonly TimeSpan DefaultSyncInterval = TimeSpan.FromMinutes(1);
/// The underlying directory JWT store. Mirrors Go DirAccResolver.DirJWTStore.
public DirJwtStore Store { get; }
/// Reference to the running server, set during . Mirrors Go DirAccResolver.Server.
public object? Server { get; protected set; }
/// How often the resolver sends a sync (pack) request to peers. Mirrors Go DirAccResolver.syncInterval.
public TimeSpan SyncInterval { get; protected set; }
/// Maximum time to wait for a remote JWT fetch. Mirrors Go DirAccResolver.fetchTimeout.
public TimeSpan FetchTimeout { get; set; }
///
/// Creates a new directory account resolver.
///
/// When is zero it is promoted to (unlimited).
/// When is non-positive it defaults to one minute.
///
/// Mirrors Go NewDirAccResolver.
///
/// Directory path for the JWT store.
/// Maximum number of JWTs the store may hold (0 = unlimited).
/// How often to broadcast a sync/pack request to peers.
/// Controls whether deletes are soft- or hard-deleted.
/// Zero or more functional options to further configure this instance.
public DirAccountResolver(
string path,
long limit,
TimeSpan syncInterval,
JwtDeleteType deleteType,
params DirResOption[] opts)
{
if (limit == 0)
{
limit = long.MaxValue;
}
if (syncInterval <= TimeSpan.Zero)
{
syncInterval = DefaultSyncInterval;
}
Store = DirJwtStore.NewExpiringDirJwtStore(
path,
shard: false,
create: true,
deleteType,
expireCheck: TimeSpan.Zero,
limit,
evictOnLimit: false,
ttl: TimeSpan.Zero,
changeNotification: null);
SyncInterval = syncInterval;
FetchTimeout = DefaultFetchTimeout;
Apply(opts);
}
// Internal constructor used by CacheDirAccountResolver which supplies its own store.
internal DirAccountResolver(
DirJwtStore store,
TimeSpan syncInterval,
TimeSpan fetchTimeout)
{
Store = store;
SyncInterval = syncInterval;
FetchTimeout = fetchTimeout;
}
///
/// Applies a sequence of functional options to this resolver.
/// Mirrors Go DirAccResolver.apply.
///
protected void Apply(IEnumerable opts)
{
foreach (var opt in opts)
{
opt(this);
}
}
// -------------------------------------------------------------------------
// IAccountResolver overrides
// -------------------------------------------------------------------------
///
/// DirAccountResolver is not read-only.
/// Mirrors Go: DirAccResolver does not override IsReadOnly, so it inherits false
/// from the concrete behaviour (store is writable).
///
public override bool IsReadOnly() => false;
///
/// Tracks updates (reacts to JWT change events).
/// Mirrors Go DirAccResolver.IsTrackingUpdate.
///
public override bool IsTrackingUpdate() => true;
///
/// Reloads state from the backing .
/// Mirrors Go DirAccResolver.Reload.
///
public override void Reload() => Store.Reload();
///
/// Fetches the JWT for from the local .
/// Throws when the account is not found locally.
///
/// Note: the Go implementation falls back to srv.fetch (a cluster-wide lookup) when
/// the local store misses. That fallback requires system subscriptions and is deferred to
/// session 12. For now this method only consults the local store.
///
/// Mirrors Go DirAccResolver.Fetch (local path only).
///
public override Task FetchAsync(string name, CancellationToken ct = default)
{
var theJwt = Store.LoadAcc(name);
if (!string.IsNullOrEmpty(theJwt))
{
return Task.FromResult(theJwt);
}
throw new InvalidOperationException($"Account not found: {name}");
}
///
/// Stores under , keeping the newer JWT
/// when a conflicting entry already exists.
/// Mirrors Go DirAccResolver.Store (delegates to saveIfNewer).
///
public override Task StoreAsync(string name, string jwt, CancellationToken ct = default)
{
// SaveAcc is equivalent to saveIfNewer in the DirJwtStore implementation.
Store.SaveAcc(name, jwt);
return Task.CompletedTask;
}
///
/// Starts background system subscriptions and the periodic sync timer.
///
/// TODO (session 12): wire up system subscriptions for account JWT update/lookup/pack
/// requests, cluster synchronisation, and the periodic pack broadcast goroutine.
///
/// Mirrors Go DirAccResolver.Start.
///
public override void Start(object server)
{
Server = server;
// TODO (session 12): set up system subscriptions and periodic sync timer.
}
///
/// Stops background processing and closes the .
/// Mirrors Go AccountResolver.Close (no explicit Go override; store is closed
/// by the server shutdown path).
///
public override void Close() => Store.Close();
///
public void Dispose() => Store.Dispose();
}
// ============================================================================
// CacheDirAccountResolver (stub)
// Mirrors Go CacheDirAccResolver (accounts.go ~line 4594).
// ============================================================================
///
/// A caching variant of that uses a TTL-based expiring
/// store so that fetched JWTs are automatically evicted after .
///
/// The Start override that wires up system subscriptions is a stub in this session;
/// full implementation requires system subscription support (session 12+).
///
/// Mirrors Go CacheDirAccResolver.
///
public sealed class CacheDirAccountResolver : DirAccountResolver
{
// Default cache limit — mirrors Go's fallback of 1 000 entries.
private const long DefaultCacheLimit = 1_000;
// Default fetch timeout — mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT (2 s).
private static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(2);
/// The TTL applied to each cached JWT entry. Mirrors Go CacheDirAccResolver.ttl.
public TimeSpan Ttl { get; }
///
/// Creates a new caching directory account resolver.
///
/// When is zero or negative it defaults to 1 000.
///
/// Mirrors Go NewCacheDirAccResolver.
///
/// Directory path for the JWT store.
/// Maximum number of JWTs to cache (0 = 1 000).
/// Time-to-live for each cached JWT.
/// Zero or more functional options to further configure this instance.
public CacheDirAccountResolver(
string path,
long limit,
TimeSpan ttl,
params DirResOption[] opts)
: base(
store: DirJwtStore.NewExpiringDirJwtStore(
path,
shard: false,
create: true,
JwtDeleteType.HardDelete,
expireCheck: TimeSpan.Zero,
limit: limit <= 0 ? DefaultCacheLimit : limit,
evictOnLimit: true,
ttl: ttl,
changeNotification: null),
syncInterval: TimeSpan.Zero,
fetchTimeout: DefaultFetchTimeout)
{
Ttl = ttl;
Apply(opts);
}
///
/// Starts background system subscriptions for cached JWT update notifications.
///
/// TODO (session 12): wire up system subscriptions for account JWT update events
/// (cache variant — does not include pack/list/delete handling).
///
/// Mirrors Go CacheDirAccResolver.Start.
///
public override void Start(object server)
{
Server = server;
// TODO (session 12): set up system subscriptions for cache-update notifications.
}
}