diff --git a/src/NATS.Server/Auth/Account.cs b/src/NATS.Server/Auth/Account.cs
index e48537d..00bd613 100644
--- a/src/NATS.Server/Auth/Account.cs
+++ b/src/NATS.Server/Auth/Account.cs
@@ -212,6 +212,65 @@ public sealed class Account : IDisposable
/// Records a service request latency sample on this account's tracker.
public void RecordServiceLatency(double latencyMs) => LatencyTracker.RecordLatency(latencyMs);
+ ///
+ /// Returns all service exports registered on this account.
+ /// Go reference: accounts.go exports.services iteration.
+ ///
+ public IReadOnlyList GetAllServiceExports()
+ {
+ var result = new List(Exports.Services.Count);
+ foreach (var (subject, se) in Exports.Services)
+ result.Add(ToServiceExportInfo(subject, se));
+ return result;
+ }
+
+ ///
+ /// Returns the service export for an exact subject match, or null if not found.
+ /// Does not apply wildcard matching.
+ /// Go reference: accounts.go getServiceExport (direct map lookup only).
+ ///
+ public ServiceExportInfo? GetExactServiceExport(string subject)
+ {
+ if (Exports.Services.TryGetValue(subject, out var se))
+ return ToServiceExportInfo(subject, se);
+ return null;
+ }
+
+ ///
+ /// Finds a service export whose subject pattern matches the given subject using
+ /// wildcard matching. Returns null when no export pattern matches.
+ /// Go reference: accounts.go getWildcardServiceExport (line 2849).
+ ///
+ public ServiceExportInfo? GetWildcardServiceExport(string subject)
+ {
+ // First try exact match
+ if (Exports.Services.TryGetValue(subject, out var exact))
+ return ToServiceExportInfo(subject, exact);
+
+ // Then scan for a wildcard pattern that matches
+ foreach (var (pattern, se) in Exports.Services)
+ {
+ if (SubjectMatch.MatchLiteral(subject, pattern))
+ return ToServiceExportInfo(pattern, se);
+ }
+ return null;
+ }
+
+ ///
+ /// Returns true when any service export (exact or wildcard) matches the given subject.
+ /// Go reference: accounts.go getServiceExport.
+ ///
+ public bool HasServiceExport(string subject) => GetWildcardServiceExport(subject) != null;
+
+ private static ServiceExportInfo ToServiceExportInfo(string subject, ServiceExport se)
+ {
+ IReadOnlyList approved = se.Auth.ApprovedAccounts != null
+ ? [.. se.Auth.ApprovedAccounts]
+ : [];
+ bool isWildcard = subject.Contains('*') || subject.Contains('>');
+ return new ServiceExportInfo(subject, se.ResponseType, approved, isWildcard);
+ }
+
public void AddServiceExport(string subject, ServiceResponseType responseType, IEnumerable? approved)
{
var auth = new ExportAuth
diff --git a/src/NATS.Server/Imports/ImportMap.cs b/src/NATS.Server/Imports/ImportMap.cs
index a136c54..2d553ed 100644
--- a/src/NATS.Server/Imports/ImportMap.cs
+++ b/src/NATS.Server/Imports/ImportMap.cs
@@ -1,3 +1,5 @@
+using NATS.Server.Auth;
+
namespace NATS.Server.Imports;
public sealed class ImportMap
@@ -15,4 +17,23 @@ public sealed class ImportMap
list.Add(si);
}
+
+ ///
+ /// Returns the distinct set of source accounts referenced by stream imports.
+ /// Go reference: accounts.go imports.streams — each streamImport has an acc field.
+ ///
+ public IReadOnlyList GetStreamImportSourceAccounts()
+ {
+ if (Streams.Count == 0)
+ return [];
+
+ var seen = new HashSet(StringComparer.Ordinal);
+ var result = new List(Streams.Count);
+ foreach (var si in Streams)
+ {
+ if (seen.Add(si.SourceAccount.Name))
+ result.Add(si.SourceAccount);
+ }
+ return result;
+ }
}
diff --git a/src/NATS.Server/Imports/ServiceExportInfo.cs b/src/NATS.Server/Imports/ServiceExportInfo.cs
new file mode 100644
index 0000000..7b62b65
--- /dev/null
+++ b/src/NATS.Server/Imports/ServiceExportInfo.cs
@@ -0,0 +1,12 @@
+namespace NATS.Server.Imports;
+
+///
+/// Immutable view of a service export, returned by Account query methods.
+/// IsWildcard is true when the subject contains '*' or '>'.
+/// Go reference: accounts.go serviceExport struct.
+///
+public sealed record ServiceExportInfo(
+ string Subject,
+ ServiceResponseType ResponseType,
+ IReadOnlyList ApprovedAccounts,
+ bool IsWildcard);
diff --git a/tests/NATS.Server.Tests/Auth/StreamImportCycleTests.cs b/tests/NATS.Server.Tests/Auth/StreamImportCycleTests.cs
new file mode 100644
index 0000000..a090073
--- /dev/null
+++ b/tests/NATS.Server.Tests/Auth/StreamImportCycleTests.cs
@@ -0,0 +1,175 @@
+// Tests for stream import cycle detection via DFS on Account.
+// Go reference: accounts_test.go — TestAccountStreamImportCycles (accounts.go:1627 streamImportFormsCycle).
+
+using NATS.Server.Auth;
+
+namespace NATS.Server.Tests.Auth;
+
+public class StreamImportCycleTests
+{
+ private static Account CreateAccount(string name) => new(name);
+
+ ///
+ /// Sets up a public stream export on for
+ /// and then adds a stream import on from .
+ ///
+ private static void SetupStreamImport(Account importer, Account exporter, string subject)
+ {
+ exporter.AddStreamExport(subject, approved: null); // public export
+ importer.AddStreamImport(exporter, subject, subject);
+ }
+
+ // 1. No cycle when the proposed source has no imports leading back to this account.
+ // A imports from B; checking whether B can import from C — no path C→A exists.
+ [Fact]
+ public void StreamImportFormsCycle_NoCycle_ReturnsFalse()
+ {
+ // Go ref: accounts.go streamImportFormsCycle
+ var a = CreateAccount("A");
+ var b = CreateAccount("B");
+ var c = CreateAccount("C");
+
+ SetupStreamImport(a, b, "events.>"); // A imports from B
+ c.AddStreamExport("other.>", approved: null);
+
+ // B importing from C: does C→...→A exist? No.
+ a.StreamImportFormsCycle(c).ShouldBeFalse();
+ }
+
+ // 2. Direct cycle: A already imports from B; proposing B imports from A = cycle.
+ [Fact]
+ public void StreamImportFormsCycle_DirectCycle_ReturnsTrue()
+ {
+ // Go ref: accounts.go streamImportFormsCycle
+ var a = CreateAccount("A");
+ var b = CreateAccount("B");
+
+ SetupStreamImport(a, b, "stream.>"); // A imports from B
+
+ // Now check: would A importing from B (again, or B's perspective) form a cycle?
+ // We ask account B: does proposing A as source form a cycle?
+ // i.e. b.StreamImportFormsCycle(a) — does a chain from A lead back to B?
+ // A imports from B, so A→B, meaning following A's imports we reach B. Cycle confirmed.
+ b.StreamImportFormsCycle(a).ShouldBeTrue();
+ }
+
+ // 3. Indirect cycle: A→B→C; proposing C import from A would create C→A→B→C.
+ [Fact]
+ public void StreamImportFormsCycle_IndirectCycle_ReturnsTrue()
+ {
+ // Go ref: accounts.go checkStreamImportsForCycles
+ var a = CreateAccount("A");
+ var b = CreateAccount("B");
+ var c = CreateAccount("C");
+
+ SetupStreamImport(a, b, "s.>"); // A imports from B
+ SetupStreamImport(b, c, "t.>"); // B imports from C
+
+ // Would C importing from A form a cycle? Path: A imports from B, B imports from C → cycle.
+ c.StreamImportFormsCycle(a).ShouldBeTrue();
+ }
+
+ // 4. Self-import: A importing from A is always a cycle.
+ [Fact]
+ public void StreamImportFormsCycle_SelfImport_ReturnsTrue()
+ {
+ // Go ref: accounts.go streamImportFormsCycle — proposedSource == this
+ var a = CreateAccount("A");
+
+ a.StreamImportFormsCycle(a).ShouldBeTrue();
+ }
+
+ // 5. Account with no imports at all — no cycle possible.
+ [Fact]
+ public void StreamImportFormsCycle_NoImports_ReturnsFalse()
+ {
+ // Go ref: accounts.go streamImportFormsCycle — empty imports.streams
+ var a = CreateAccount("A");
+ var b = CreateAccount("B");
+
+ // Neither account has any stream imports; proposing B as source for A is safe.
+ a.StreamImportFormsCycle(b).ShouldBeFalse();
+ }
+
+ // 6. Diamond topology: A→B, A→C, B→D, C→D — no cycle, just shared descendant.
+ [Fact]
+ public void StreamImportFormsCycle_DiamondNoCycle_ReturnsFalse()
+ {
+ // Go ref: accounts.go checkStreamImportsForCycles — visited set prevents false positives
+ var a = CreateAccount("A");
+ var b = CreateAccount("B");
+ var c = CreateAccount("C");
+ var d = CreateAccount("D");
+
+ SetupStreamImport(a, b, "b.>"); // A imports from B
+ SetupStreamImport(a, c, "c.>"); // A imports from C
+ SetupStreamImport(b, d, "d1.>"); // B imports from D
+ SetupStreamImport(c, d, "d2.>"); // C imports from D
+
+ // Proposing D import from A: does A→...→D path exist? Yes (via B and C).
+ d.StreamImportFormsCycle(a).ShouldBeTrue();
+
+ // Proposing E (new account) import from D: D has no imports, so no cycle.
+ var e = CreateAccount("E");
+ e.StreamImportFormsCycle(d).ShouldBeFalse();
+ }
+
+ // 7. GetStreamImportSources returns names of source accounts.
+ [Fact]
+ public void GetStreamImportSources_ReturnsSourceNames()
+ {
+ // Go ref: accounts.go imports.streams acc field
+ var a = CreateAccount("A");
+ var b = CreateAccount("B");
+ var c = CreateAccount("C");
+
+ SetupStreamImport(a, b, "x.>");
+ SetupStreamImport(a, c, "y.>");
+
+ var sources = a.GetStreamImportSources();
+
+ sources.Count.ShouldBe(2);
+ sources.ShouldContain("B");
+ sources.ShouldContain("C");
+ }
+
+ // 8. GetStreamImportSources returns empty list when no imports exist.
+ [Fact]
+ public void GetStreamImportSources_Empty_ReturnsEmpty()
+ {
+ // Go ref: accounts.go imports.streams — empty slice
+ var a = CreateAccount("A");
+
+ var sources = a.GetStreamImportSources();
+
+ sources.ShouldBeEmpty();
+ }
+
+ // 9. HasStreamImportFrom returns true when a matching import exists.
+ [Fact]
+ public void HasStreamImportFrom_True()
+ {
+ // Go ref: accounts.go imports.streams — acc.Name lookup
+ var a = CreateAccount("A");
+ var b = CreateAccount("B");
+
+ SetupStreamImport(a, b, "events.>");
+
+ a.HasStreamImportFrom("B").ShouldBeTrue();
+ }
+
+ // 10. HasStreamImportFrom returns false when no import from that account exists.
+ [Fact]
+ public void HasStreamImportFrom_False()
+ {
+ // Go ref: accounts.go imports.streams — acc.Name lookup miss
+ var a = CreateAccount("A");
+ var b = CreateAccount("B");
+ var c = CreateAccount("C");
+
+ SetupStreamImport(a, b, "events.>");
+
+ a.HasStreamImportFrom("C").ShouldBeFalse();
+ a.HasStreamImportFrom(c.Name).ShouldBeFalse();
+ }
+}
diff --git a/tests/NATS.Server.Tests/Auth/WildcardExportTests.cs b/tests/NATS.Server.Tests/Auth/WildcardExportTests.cs
new file mode 100644
index 0000000..0f7cdea
--- /dev/null
+++ b/tests/NATS.Server.Tests/Auth/WildcardExportTests.cs
@@ -0,0 +1,169 @@
+// Tests for wildcard service export matching on Account.
+// Go reference: accounts_test.go — getWildcardServiceExport, getServiceExport (accounts.go line 2849).
+
+using NATS.Server.Auth;
+using NATS.Server.Imports;
+
+namespace NATS.Server.Tests.Auth;
+
+public class WildcardExportTests
+{
+ private static Account CreateAccount(string name = "TestAccount") => new(name);
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // GetWildcardServiceExport
+ // ──────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void GetWildcardServiceExport_ExactMatch_ReturnsExport()
+ {
+ // Go ref: accounts.go getWildcardServiceExport — exact key in exports.services map
+ var acct = CreateAccount();
+ acct.AddServiceExport("orders.create", ServiceResponseType.Singleton, null);
+
+ var result = acct.GetWildcardServiceExport("orders.create");
+
+ result.ShouldNotBeNull();
+ result.Subject.ShouldBe("orders.create");
+ result.ResponseType.ShouldBe(ServiceResponseType.Singleton);
+ }
+
+ [Fact]
+ public void GetWildcardServiceExport_StarWildcard_ReturnsExport()
+ {
+ // Go ref: accounts.go getWildcardServiceExport — isSubsetMatch with '*' wildcard
+ var acct = CreateAccount();
+ acct.AddServiceExport("orders.*", ServiceResponseType.Streamed, null);
+
+ var result = acct.GetWildcardServiceExport("orders.create");
+
+ result.ShouldNotBeNull();
+ result.Subject.ShouldBe("orders.*");
+ result.ResponseType.ShouldBe(ServiceResponseType.Streamed);
+ result.IsWildcard.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void GetWildcardServiceExport_GtWildcard_ReturnsExport()
+ {
+ // Go ref: accounts.go getWildcardServiceExport — isSubsetMatch with '>' wildcard
+ var acct = CreateAccount();
+ acct.AddServiceExport("orders.>", ServiceResponseType.Chunked, null);
+
+ var result = acct.GetWildcardServiceExport("orders.create.new");
+
+ result.ShouldNotBeNull();
+ result.Subject.ShouldBe("orders.>");
+ result.ResponseType.ShouldBe(ServiceResponseType.Chunked);
+ result.IsWildcard.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void GetWildcardServiceExport_NoMatch_ReturnsNull()
+ {
+ // Go ref: accounts.go getWildcardServiceExport — returns nil when no pattern matches
+ var acct = CreateAccount();
+ acct.AddServiceExport("payments.*", ServiceResponseType.Singleton, null);
+
+ var result = acct.GetWildcardServiceExport("orders.create");
+
+ result.ShouldBeNull();
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // GetAllServiceExports
+ // ──────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void GetAllServiceExports_ReturnsAll()
+ {
+ // Go ref: accounts.go — exports.services map contains all registered exports
+ var acct = CreateAccount();
+ acct.AddServiceExport("svc.a", ServiceResponseType.Singleton, null);
+ acct.AddServiceExport("svc.b.*", ServiceResponseType.Streamed, null);
+ acct.AddServiceExport("svc.>", ServiceResponseType.Chunked, null);
+
+ var all = acct.GetAllServiceExports();
+
+ all.Count.ShouldBe(3);
+ all.Select(e => e.Subject).ShouldContain("svc.a");
+ all.Select(e => e.Subject).ShouldContain("svc.b.*");
+ all.Select(e => e.Subject).ShouldContain("svc.>");
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // GetExactServiceExport
+ // ──────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void GetExactServiceExport_Found()
+ {
+ // Go ref: accounts.go getServiceExport — direct map lookup, no wildcard scan
+ var acct = CreateAccount();
+ acct.AddServiceExport("orders.create", ServiceResponseType.Singleton, null);
+
+ var result = acct.GetExactServiceExport("orders.create");
+
+ result.ShouldNotBeNull();
+ result.Subject.ShouldBe("orders.create");
+ }
+
+ [Fact]
+ public void GetExactServiceExport_NotFound_ReturnsNull()
+ {
+ // Go ref: accounts.go getServiceExport — map lookup misses wildcard patterns
+ var acct = CreateAccount();
+ acct.AddServiceExport("orders.*", ServiceResponseType.Singleton, null);
+
+ // "orders.create" is not an exact key in the map — only "orders.*" is
+ var result = acct.GetExactServiceExport("orders.create");
+
+ result.ShouldBeNull();
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // HasServiceExport
+ // ──────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void HasServiceExport_ExactMatch_ReturnsTrue()
+ {
+ // Go ref: accounts.go — exact subject registered as an export
+ var acct = CreateAccount();
+ acct.AddServiceExport("orders.create", ServiceResponseType.Singleton, null);
+
+ acct.HasServiceExport("orders.create").ShouldBeTrue();
+ }
+
+ [Fact]
+ public void HasServiceExport_WildcardMatch_ReturnsTrue()
+ {
+ // Go ref: accounts.go — wildcard pattern covers the queried literal subject
+ var acct = CreateAccount();
+ acct.AddServiceExport("orders.>", ServiceResponseType.Singleton, null);
+
+ acct.HasServiceExport("orders.create.urgent").ShouldBeTrue();
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // IsWildcard flag
+ // ──────────────────────────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("orders.*", true)]
+ [InlineData("orders.>", true)]
+ [InlineData("orders.*.create", true)]
+ [InlineData("orders.create", false)]
+ [InlineData("svc", false)]
+ public void IsWildcard_DetectsWildcardSubjects(string subject, bool expectedWildcard)
+ {
+ // Go ref: accounts.go — wildcard subjects contain '*' or '>'
+ var acct = CreateAccount();
+ acct.AddServiceExport(subject, ServiceResponseType.Singleton, null);
+
+ var result = acct.GetExactServiceExport(subject);
+
+ result.ShouldNotBeNull();
+ result.IsWildcard.ShouldBe(expectedWildcard);
+ }
+}