feat: wire remaining E2E gaps — account imports, subject transforms, JWT auth, service latency

Close all 5 server-side wiring gaps so E2E tests pass without skips:
- System events: bridge user-defined system_account to internal $SYS
- Account imports/exports: config parsing + reverse response import for cross-account request-reply
- Subject transforms: parse mappings config block, apply in ProcessMessage
- JWT auth: parse trusted_keys, resolver MEMORY, resolver_preload in config
- Service latency: timestamp on request, publish ServiceLatencyMsg on response
This commit is contained in:
Joseph Doherty
2026-03-12 23:03:12 -04:00
parent 246fc7ad87
commit 95e9f0a92e
5 changed files with 571 additions and 16 deletions

View File

@@ -291,7 +291,55 @@ public static class ConfigProcessor
ParseAccounts(accountsDict, opts, errors);
break;
// Unknown keys silently ignored (resolver, operator, etc.)
// Server-level subject mappings: mappings { src: dest }
// Go reference: server/opts.go — "mappings" case
case "mappings" or "maps":
if (value is Dictionary<string, object?> mappingsDict)
{
opts.SubjectMappings ??= new Dictionary<string, string>();
foreach (var (src, dest) in mappingsDict)
{
if (dest is string destStr)
opts.SubjectMappings[src] = destStr;
}
}
break;
// JWT operator mode — trusted operator public NKeys
// Go reference: server/opts.go — "trusted_keys" / "trusted" case
case "trusted_keys" or "trusted":
opts.TrustedKeys = ParseStringArray(value);
break;
// JWT resolver type and preload
// Go reference: server/opts.go — "resolver" case
case "resolver" or "account_resolver" or "accounts_resolver":
if (value is string resolverStr && resolverStr.Equals("MEMORY", StringComparison.OrdinalIgnoreCase))
opts.AccountResolver = new Auth.Jwt.MemAccountResolver();
break;
// Pre-load account JWTs into the resolver
// Go reference: server/opts.go — "resolver_preload" case
case "resolver_preload":
if (value is Dictionary<string, object?> preloadDict && opts.AccountResolver != null)
{
foreach (var (accNkey, jwtObj) in preloadDict)
{
if (jwtObj is string jwt)
opts.AccountResolver.StoreAsync(accNkey, jwt).GetAwaiter().GetResult();
}
}
break;
// Operator key (can derive trusted_keys from operator JWT — for now just accept NKeys directly)
case "operator" or "operators" or "root" or "roots" or "root_operators" or "root_operator":
// For simple mode: treat as trusted_keys alias if string array
opts.TrustedKeys ??= ParseStringArray(value);
break;
// Unknown keys silently ignored
default:
warnings.Add(new UnknownConfigFieldWarning(key).Message);
break;
@@ -975,6 +1023,8 @@ public static class ConfigProcessor
int maxConnections = 0;
int maxSubscriptions = 0;
List<object?>? userList = null;
List<ExportDefinition>? exports = null;
List<ImportDefinition>? imports = null;
foreach (var (key, value) in acctDict)
{
@@ -989,6 +1039,21 @@ public static class ConfigProcessor
break;
case "max_subscriptions" or "max_subs":
maxSubscriptions = ToInt(value);
break;
case "exports":
if (value is List<object?> exportList)
exports = ParseExports(exportList);
break;
case "imports":
if (value is List<object?> importList)
imports = ParseImports(importList);
break;
case "mappings" or "maps":
if (value is Dictionary<string, object?> mappingsDict)
{
// Account-level subject mappings not yet supported
}
break;
}
}
@@ -997,6 +1062,8 @@ public static class ConfigProcessor
{
MaxConnections = maxConnections,
MaxSubscriptions = maxSubscriptions,
Exports = exports,
Imports = imports,
};
if (userList is not null)
@@ -1020,6 +1087,140 @@ public static class ConfigProcessor
}
}
/// <summary>
/// Parses an exports array: [{ service: "sub" }, { stream: "sub" }].
/// Go reference: server/opts.go — parseExportStreamMap / parseExportServiceMap.
/// </summary>
private static List<ExportDefinition> ParseExports(List<object?> exportList)
{
var result = new List<ExportDefinition>();
foreach (var item in exportList)
{
if (item is not Dictionary<string, object?> dict)
continue;
string? service = null, stream = null;
string? latencySubject = null;
int latencySampling = 100;
foreach (var (k, v) in dict)
{
switch (k.ToLowerInvariant())
{
case "service":
service = ToString(v);
break;
case "stream":
stream = ToString(v);
break;
case "latency":
// latency can be a string (subject only) or a map { subject, sampling }
// Go reference: server/opts.go — parseServiceLatency
if (v is string latStr)
{
latencySubject = latStr;
}
else if (v is Dictionary<string, object?> latDict)
{
foreach (var (lk, lv) in latDict)
{
switch (lk.ToLowerInvariant())
{
case "subject":
latencySubject = ToString(lv);
break;
case "sampling":
latencySampling = ToInt(lv);
break;
}
}
}
break;
}
}
result.Add(new ExportDefinition
{
Service = service,
Stream = stream,
LatencySubject = latencySubject,
LatencySampling = latencySampling,
});
}
return result;
}
/// <summary>
/// Parses an imports array: [{ service: { account: X, subject: "sub" }, to: "local" }].
/// Go reference: server/opts.go — parseImportStreamMap / parseImportServiceMap.
/// </summary>
private static List<ImportDefinition> ParseImports(List<object?> importList)
{
var result = new List<ImportDefinition>();
foreach (var item in importList)
{
if (item is not Dictionary<string, object?> dict)
continue;
string? serviceAccount = null, serviceSubject = null;
string? streamAccount = null, streamSubject = null;
string? to = null;
foreach (var (k, v) in dict)
{
switch (k.ToLowerInvariant())
{
case "service" when v is Dictionary<string, object?> svcDict:
foreach (var (sk, sv) in svcDict)
{
switch (sk.ToLowerInvariant())
{
case "account":
serviceAccount = ToString(sv);
break;
case "subject":
serviceSubject = ToString(sv);
break;
}
}
break;
case "stream" when v is Dictionary<string, object?> strmDict:
foreach (var (sk, sv) in strmDict)
{
switch (sk.ToLowerInvariant())
{
case "account":
streamAccount = ToString(sv);
break;
case "subject":
streamSubject = ToString(sv);
break;
}
}
break;
case "to":
to = ToString(v);
break;
}
}
result.Add(new ImportDefinition
{
ServiceAccount = serviceAccount,
ServiceSubject = serviceSubject,
StreamAccount = streamAccount,
StreamSubject = streamSubject,
To = to,
});
}
return result;
}
/// <summary>
/// Splits a users array into plain users and NKey users.
/// An entry with an "nkey" field is an NKey user; entries with "user" are plain users.
@@ -1623,6 +1824,30 @@ public static class ConfigProcessor
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to double"),
};
/// <summary>
/// Parses a config value that can be a single string or a list of strings into a string[].
/// Go reference: server/opts.go — parseTrustedKeys accepts string, []string, []interface{}.
/// </summary>
private static string[]? ParseStringArray(object? value)
{
if (value is List<object?> list)
{
var result = new List<string>(list.Count);
foreach (var item in list)
{
if (item is string s)
result.Add(s);
}
return result.Count > 0 ? result.ToArray() : null;
}
if (value is string str)
return [str];
return null;
}
private static IReadOnlyList<string> ToStringList(object? value)
{
if (value is List<object?> list)