diff --git a/Directory.Build.props b/Directory.Build.props
index 38588533..edb1c55d 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -3,9 +3,13 @@
latest
true
false
-
- 1
- CS8602;CS8604;CS8618;CA2000;CA2012;CA2201;IDE0005;IDE0058
+
+ 2
+
+ CS8602;CS8604;CS8618;CA2000;CA2012;CA2201;IDE0058
$(WarningsAsErrors);ORLEANS0012;ORLEANS0013
+
+
+
+
+
diff --git a/Vortex.Authentication.Tests/AuthenticationServiceTests.cs b/Vortex.Authentication.Tests/AuthenticationServiceTests.cs
new file mode 100644
index 00000000..b01c0a3b
--- /dev/null
+++ b/Vortex.Authentication.Tests/AuthenticationServiceTests.cs
@@ -0,0 +1,446 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using Vortex.Authentication.Configuration;
+using Vortex.Database.Context;
+using Vortex.Database.Entities.Players;
+using Vortex.Database.Entities.Security;
+using Vortex.Primitives.Events;
+using Vortex.Primitives.Players.Enums;
+using Vortex.Primitives.Rooms.Enums;
+using Xunit;
+
+namespace Vortex.Authentication.Tests;
+
+///
+/// TEST-01 safety net for : ticket
+/// lookup, the ExpiresAt ?? CreatedAt + TicketTtlSeconds expiry computation, the
+/// locked-ticket exemption from deletion-on-expiry, the sliding-expiry refresh on a successful
+/// login, and which of /
+/// gets published for each outcome.
+///
+public sealed class AuthenticationServiceTests
+{
+ private const int TICKET_TTL_SECONDS = 30;
+
+ private sealed class RecordingEventPublisher : IEventPublisher
+ {
+ public List Published { get; } = [];
+
+ public Task PublishAsync(IEvent @event, CancellationToken ct = default)
+ {
+ Published.Add(@event);
+
+ return Task.CompletedTask;
+ }
+ }
+
+ private sealed class TestDbContextFactory(DbContextOptions options)
+ : IDbContextFactory
+ {
+ public VortexDbContext CreateDbContext() => new(options);
+ }
+
+ private static DbContextOptions NewOptions() =>
+ new DbContextOptionsBuilder()
+ .UseInMemoryDatabase($"auth-{Guid.NewGuid():N}")
+ .Options;
+
+ private static AuthenticationService NewService(
+ DbContextOptions options,
+ RecordingEventPublisher events,
+ int ticketTtlSeconds = TICKET_TTL_SECONDS
+ )
+ {
+ AuthenticationConfig config = new()
+ {
+ IpHashSecret = "test-secret",
+ TicketTtlSeconds = ticketTtlSeconds,
+ };
+
+ return new AuthenticationService(
+ new TestDbContextFactory(options),
+ events,
+ Options.Create(config)
+ );
+ }
+
+ private static PlayerEntity NewPlayer(string name) =>
+ new()
+ {
+ Name = name,
+ Figure = "hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64",
+ Gender = AvatarGenderType.Male,
+ PlayerStatus = PlayerStatusType.Offline,
+ PlayerPerks = PlayerPerkFlags.None,
+ };
+
+ private static async Task SeedPlayerAsync(
+ DbContextOptions options,
+ string name
+ )
+ {
+ await using VortexDbContext ctx = new(options);
+ PlayerEntity player = NewPlayer(name);
+ ctx.Players.Add(player);
+ await ctx.SaveChangesAsync().ConfigureAwait(true);
+
+ return player;
+ }
+
+ private static async Task SeedTicketAsync(
+ DbContextOptions options,
+ PlayerEntity player,
+ string ticket,
+ DateTime createdAt,
+ DateTime? expiresAt,
+ bool isLocked
+ )
+ {
+ await using VortexDbContext ctx = new(options);
+ // `player` was loaded/created against a different DbContext instance -- attach it as
+ // Unchanged here first so SaveChanges does not try to re-INSERT an already-persisted row
+ // via the required PlayerEntity navigation.
+ ctx.Players.Attach(player);
+ ctx.SecurityTickets.Add(
+ new SecurityTicketEntity
+ {
+ PlayerEntityId = player.Id,
+ PlayerEntity = player,
+ Ticket = ticket,
+ IpAddress = "127.0.0.1",
+ IsLocked = isLocked,
+ ExpiresAt = expiresAt,
+ CreatedAt = createdAt,
+ }
+ );
+ await ctx.SaveChangesAsync().ConfigureAwait(true);
+ }
+
+ [Fact]
+ public async Task ValidNonExpiredTicket_ResolvesToTheCorrectPlayerId()
+ {
+ DbContextOptions options = NewOptions();
+ PlayerEntity player = await SeedPlayerAsync(options, "alice").ConfigureAwait(true);
+ await SeedTicketAsync(
+ options,
+ player,
+ "valid-ticket",
+ createdAt: DateTime.UtcNow,
+ expiresAt: DateTime.UtcNow.AddSeconds(60),
+ isLocked: false
+ )
+ .ConfigureAwait(true);
+
+ RecordingEventPublisher events = new();
+ AuthenticationService service = NewService(options, events);
+
+ int playerId = await service
+ .GetPlayerIdFromTicketAsync("valid-ticket")
+ .ConfigureAwait(true);
+
+ playerId.Should().Be(player.Id);
+ }
+
+ [Fact]
+ public async Task ValidTicket_PublishesPlayerLoggedInEvent()
+ {
+ DbContextOptions options = NewOptions();
+ PlayerEntity player = await SeedPlayerAsync(options, "bob").ConfigureAwait(true);
+ await SeedTicketAsync(
+ options,
+ player,
+ "valid-ticket",
+ createdAt: DateTime.UtcNow,
+ expiresAt: DateTime.UtcNow.AddSeconds(60),
+ isLocked: false
+ )
+ .ConfigureAwait(true);
+
+ RecordingEventPublisher events = new();
+ AuthenticationService service = NewService(options, events);
+
+ await service.GetPlayerIdFromTicketAsync("valid-ticket").ConfigureAwait(true);
+
+ events.Published.Should().ContainSingle().Which.Should().BeOfType();
+ ((PlayerLoggedInEvent)events.Published[0]).PlayerId.Should().Be(player.Id);
+ }
+
+ [Fact]
+ public async Task MissingTicket_ReturnsZero_AndPublishesLoginFailedEvent()
+ {
+ DbContextOptions options = NewOptions();
+ RecordingEventPublisher events = new();
+ AuthenticationService service = NewService(options, events);
+
+ int playerId = await service
+ .GetPlayerIdFromTicketAsync("does-not-exist")
+ .ConfigureAwait(true);
+
+ playerId.Should().Be(0);
+ events.Published.Should().ContainSingle().Which.Should().BeOfType();
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public async Task NullOrEmptyTicket_ReturnsZero_WithoutTouchingTheDatabaseOrPublishing(
+ string? ticket
+ )
+ {
+ DbContextOptions options = NewOptions();
+ RecordingEventPublisher events = new();
+ AuthenticationService service = NewService(options, events);
+
+ int playerId = await service
+ .GetPlayerIdFromTicketAsync(ticket!)
+ .ConfigureAwait(true);
+
+ playerId.Should().Be(0);
+ events.Published.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task ExpiredNonLockedTicket_IsRejected_AndDeletedFromTheDatabase()
+ {
+ DbContextOptions options = NewOptions();
+ PlayerEntity player = await SeedPlayerAsync(options, "carol").ConfigureAwait(true);
+ await SeedTicketAsync(
+ options,
+ player,
+ "expired-ticket",
+ createdAt: DateTime.UtcNow.AddMinutes(-10),
+ expiresAt: DateTime.UtcNow.AddMinutes(-1),
+ isLocked: false
+ )
+ .ConfigureAwait(true);
+
+ RecordingEventPublisher events = new();
+ AuthenticationService service = NewService(options, events);
+
+ int playerId = await service
+ .GetPlayerIdFromTicketAsync("expired-ticket")
+ .ConfigureAwait(true);
+
+ playerId.Should().Be(0);
+ events.Published.Should().ContainSingle().Which.Should().BeOfType();
+
+ await using VortexDbContext verifyCtx = new(options);
+ bool stillExists = await verifyCtx
+ .SecurityTickets.AnyAsync(t => t.Ticket == "expired-ticket")
+ .ConfigureAwait(true);
+ stillExists.Should().BeFalse("an expired, non-locked ticket must be deleted");
+ }
+
+ [Fact]
+ public async Task ExpiredButLockedTicket_IsRejected_ButNotDeleted()
+ {
+ DbContextOptions options = NewOptions();
+ PlayerEntity player = await SeedPlayerAsync(options, "dave").ConfigureAwait(true);
+ await SeedTicketAsync(
+ options,
+ player,
+ "locked-expired-ticket",
+ createdAt: DateTime.UtcNow.AddMinutes(-10),
+ expiresAt: DateTime.UtcNow.AddMinutes(-1),
+ isLocked: true
+ )
+ .ConfigureAwait(true);
+
+ RecordingEventPublisher events = new();
+ AuthenticationService service = NewService(options, events);
+
+ int playerId = await service
+ .GetPlayerIdFromTicketAsync("locked-expired-ticket")
+ .ConfigureAwait(true);
+
+ // Current behaviour under test (not an endorsement): a locked ticket is rejected as
+ // expired just like any other, but the IsLocked branch skips the delete.
+ playerId.Should().Be(0);
+ events.Published.Should().ContainSingle().Which.Should().BeOfType();
+
+ await using VortexDbContext verifyCtx = new(options);
+ bool stillExists = await verifyCtx
+ .SecurityTickets.AnyAsync(t => t.Ticket == "locked-expired-ticket")
+ .ConfigureAwait(true);
+ stillExists.Should().BeTrue("locked tickets are exempt from delete-on-expiry");
+ }
+
+ [Fact]
+ public async Task ValidTicket_WithNullExpiresAt_FallsBackToCreatedAtPlusTtl_AndIsRejectedOnceStale()
+ {
+ DbContextOptions options = NewOptions();
+ PlayerEntity player = await SeedPlayerAsync(options, "erin").ConfigureAwait(true);
+ // CreatedAt is far enough in the past that CreatedAt + TicketTtlSeconds has already
+ // elapsed, with ExpiresAt left null so the TTL fallback is what's actually exercised.
+ await SeedTicketAsync(
+ options,
+ player,
+ "null-expiry-stale",
+ createdAt: DateTime.UtcNow.AddSeconds(-(TICKET_TTL_SECONDS + 5)),
+ expiresAt: null,
+ isLocked: false
+ )
+ .ConfigureAwait(true);
+
+ RecordingEventPublisher events = new();
+ AuthenticationService service = NewService(options, events);
+
+ int playerId = await service
+ .GetPlayerIdFromTicketAsync("null-expiry-stale")
+ .ConfigureAwait(true);
+
+ playerId.Should().Be(0);
+ events.Published.Should().ContainSingle().Which.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task ValidTicket_WithNullExpiresAt_WithinTtl_Succeeds()
+ {
+ DbContextOptions options = NewOptions();
+ PlayerEntity player = await SeedPlayerAsync(options, "frank").ConfigureAwait(true);
+ await SeedTicketAsync(
+ options,
+ player,
+ "null-expiry-fresh",
+ createdAt: DateTime.UtcNow,
+ expiresAt: null,
+ isLocked: false
+ )
+ .ConfigureAwait(true);
+
+ RecordingEventPublisher events = new();
+ AuthenticationService service = NewService(options, events);
+
+ int playerId = await service
+ .GetPlayerIdFromTicketAsync("null-expiry-fresh")
+ .ConfigureAwait(true);
+
+ playerId.Should().Be(player.Id);
+ }
+
+ [Fact]
+ public async Task ZeroTicketTtlSeconds_DisablesTheNullExpiryFallback_SoNullExpiryNeverExpires()
+ {
+ DbContextOptions options = NewOptions();
+ PlayerEntity player = await SeedPlayerAsync(options, "gina").ConfigureAwait(true);
+ await SeedTicketAsync(
+ options,
+ player,
+ "no-ttl-ticket",
+ createdAt: DateTime.UtcNow.AddYears(-1),
+ expiresAt: null,
+ isLocked: false
+ )
+ .ConfigureAwait(true);
+
+ RecordingEventPublisher events = new();
+ AuthenticationService service = NewService(options, events, ticketTtlSeconds: 0);
+
+ int playerId = await service
+ .GetPlayerIdFromTicketAsync("no-ttl-ticket")
+ .ConfigureAwait(true);
+
+ playerId.Should().Be(player.Id);
+ }
+
+ [Fact]
+ public async Task SuccessfulLogin_RefreshesExpiresAt_ForSlidingExpiry()
+ {
+ DbContextOptions options = NewOptions();
+ PlayerEntity player = await SeedPlayerAsync(options, "holly").ConfigureAwait(true);
+ DateTime originalExpiry = DateTime.UtcNow.AddSeconds(5);
+ await SeedTicketAsync(
+ options,
+ player,
+ "sliding-ticket",
+ createdAt: DateTime.UtcNow,
+ expiresAt: originalExpiry,
+ isLocked: false
+ )
+ .ConfigureAwait(true);
+
+ RecordingEventPublisher events = new();
+ AuthenticationService service = NewService(options, events);
+
+ await service.GetPlayerIdFromTicketAsync("sliding-ticket").ConfigureAwait(true);
+
+ await using VortexDbContext verifyCtx = new(options);
+ SecurityTicketEntity refreshed = await verifyCtx
+ .SecurityTickets.SingleAsync(t => t.Ticket == "sliding-ticket")
+ .ConfigureAwait(true);
+
+ refreshed
+ .ExpiresAt.Should()
+ .NotBe(originalExpiry, "a successful, unlocked login refreshes ExpiresAt");
+ refreshed.ExpiresAt.Should().BeAfter(originalExpiry);
+ }
+
+ [Fact]
+ public async Task SuccessfulLogin_OnALockedTicket_DoesNotRefreshExpiresAt()
+ {
+ DbContextOptions options = NewOptions();
+ PlayerEntity player = await SeedPlayerAsync(options, "ian").ConfigureAwait(true);
+ DateTime originalExpiry = DateTime.UtcNow.AddSeconds(5);
+ await SeedTicketAsync(
+ options,
+ player,
+ "locked-active-ticket",
+ createdAt: DateTime.UtcNow,
+ expiresAt: originalExpiry,
+ isLocked: true
+ )
+ .ConfigureAwait(true);
+
+ RecordingEventPublisher events = new();
+ AuthenticationService service = NewService(options, events);
+
+ int playerId = await service
+ .GetPlayerIdFromTicketAsync("locked-active-ticket")
+ .ConfigureAwait(true);
+
+ // A locked but not-yet-expired ticket still resolves to the player...
+ playerId.Should().Be(player.Id);
+
+ // ...but the IsLocked branch means the sliding-expiry refresh is skipped.
+ await using VortexDbContext verifyCtx = new(options);
+ SecurityTicketEntity unchanged = await verifyCtx
+ .SecurityTickets.SingleAsync(t => t.Ticket == "locked-active-ticket")
+ .ConfigureAwait(true);
+ unchanged.ExpiresAt.Should().Be(originalExpiry);
+ }
+
+ [Fact]
+ public async Task RemoteIp_IsHashed_NotStoredOrPublishedInTheClear()
+ {
+ DbContextOptions options = NewOptions();
+ PlayerEntity player = await SeedPlayerAsync(options, "jack").ConfigureAwait(true);
+ await SeedTicketAsync(
+ options,
+ player,
+ "ip-ticket",
+ createdAt: DateTime.UtcNow,
+ expiresAt: DateTime.UtcNow.AddSeconds(60),
+ isLocked: false
+ )
+ .ConfigureAwait(true);
+
+ RecordingEventPublisher events = new();
+ AuthenticationService service = NewService(options, events);
+
+ await service
+ .GetPlayerIdFromTicketAsync("ip-ticket", remoteIp: "203.0.113.42")
+ .ConfigureAwait(true);
+
+ PlayerLoggedInEvent published = events.Published.Should().ContainSingle().Which
+ as PlayerLoggedInEvent
+ ?? throw new InvalidOperationException("expected a PlayerLoggedInEvent");
+
+ published.IpHash.Should().NotBeNullOrEmpty();
+ published.IpHash.Should().NotBe("203.0.113.42");
+ }
+}
diff --git a/Vortex.Authentication.Tests/Vortex.Authentication.Tests.csproj b/Vortex.Authentication.Tests/Vortex.Authentication.Tests.csproj
new file mode 100644
index 00000000..9ffe12a3
--- /dev/null
+++ b/Vortex.Authentication.Tests/Vortex.Authentication.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+ net10.0
+ disable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Vortex.Authentication/AuthenticationService.cs b/Vortex.Authentication/AuthenticationService.cs
index 1e2677ad..fbafcf2b 100644
--- a/Vortex.Authentication/AuthenticationService.cs
+++ b/Vortex.Authentication/AuthenticationService.cs
@@ -23,6 +23,8 @@ IOptions options
private readonly IEventPublisher _events = events;
private readonly string _ipHashSecret = options.Value.IpHashSecret;
private readonly int _ticketTtlSeconds = options.Value.TicketTtlSeconds;
+ private readonly bool _ticketSingleUse = options.Value.TicketSingleUse;
+ private readonly int? _ticketAbsoluteLifetimeSeconds = options.Value.TicketAbsoluteLifetimeSeconds;
public async Task GetPlayerIdFromTicketAsync(
string ticket,
@@ -78,11 +80,40 @@ await _events
return 0;
}
+ // `IsLocked` tickets are persistent/CMS-managed and intentionally never touched here
+ // (no slide, no single-use deletion, no expiry) — they are a separate mechanism from the
+ // one-ticket-per-login-attempt flow this method otherwise implements.
if (!entity.IsLocked)
{
- // Refresh the expiry so the client can reconnect after a disconnect without a
- // new ticket, while keeping the replay window bounded.
- entity.ExpiresAt = _ticketTtlSeconds > 0 ? now.AddSeconds(_ticketTtlSeconds) : null;
+ if (_ticketSingleUse)
+ {
+ // Consumed on first successful use: an observed ticket (proxy logs, browser
+ // history, unencrypted transport, Referer) can no longer be replayed at all,
+ // rather than being able to extend its own validity indefinitely.
+ dbCtx.SecurityTickets.Remove(entity);
+ }
+ else
+ {
+ // Refresh the expiry so the client can reconnect after a disconnect without a
+ // new ticket, while keeping the replay window bounded per use. Left default
+ // (TicketSingleUse = false) for compatibility with CMS integrations that reuse
+ // one ticket across reconnects.
+ DateTime slidExpiry = _ticketTtlSeconds > 0
+ ? now.AddSeconds(_ticketTtlSeconds)
+ : DateTime.MaxValue;
+
+ if (_ticketAbsoluteLifetimeSeconds is int absoluteSeconds)
+ {
+ DateTime absoluteCap = entity.CreatedAt.AddSeconds(absoluteSeconds);
+
+ if (slidExpiry > absoluteCap)
+ {
+ slidExpiry = absoluteCap;
+ }
+ }
+
+ entity.ExpiresAt = slidExpiry == DateTime.MaxValue ? null : slidExpiry;
+ }
await dbCtx.SaveChangesAsync(ct).ConfigureAwait(false);
}
diff --git a/Vortex.Authentication/Configuration/AuthenticationConfig.cs b/Vortex.Authentication/Configuration/AuthenticationConfig.cs
index eecd4fe8..b130547d 100644
--- a/Vortex.Authentication/Configuration/AuthenticationConfig.cs
+++ b/Vortex.Authentication/Configuration/AuthenticationConfig.cs
@@ -24,4 +24,20 @@ public sealed class AuthenticationConfig
/// Default: 30 seconds — tight enough to prevent replay, long enough for slow connections.
///
public int TicketTtlSeconds { get; init; } = 30;
+
+ ///
+ /// When true, a non-locked ticket is deleted on its first successful use instead of having its
+ /// expiry pushed forward. Default false so behavior is unchanged out of the box: some CMS
+ /// integrations rely on reusing the same ticket across reconnects, and turning this on would
+ /// break them until they're updated to mint a fresh ticket per connection attempt.
+ ///
+ public bool TicketSingleUse { get; init; }
+
+ ///
+ /// Optional absolute cap (seconds since a ticket's CreatedAt) on how far the sliding
+ /// expiry refresh may push a ticket's validity. Unset (default) preserves today's behavior,
+ /// where each successful use extends the ticket indefinitely into the future. Ignored when
+ /// is true.
+ ///
+ public int? TicketAbsoluteLifetimeSeconds { get; init; }
}
diff --git a/Vortex.Authentication/Configuration/AuthenticationConfigValidator.cs b/Vortex.Authentication/Configuration/AuthenticationConfigValidator.cs
index 202c2c19..3bb6d677 100644
--- a/Vortex.Authentication/Configuration/AuthenticationConfigValidator.cs
+++ b/Vortex.Authentication/Configuration/AuthenticationConfigValidator.cs
@@ -56,6 +56,15 @@ public ValidateOptionsResult Validate(string? name, AuthenticationConfig options
);
}
+ if (options.TicketAbsoluteLifetimeSeconds is < 1)
+ {
+ failures.Add(
+ $"'{AuthenticationConfig.SECTION_NAME}:{nameof(AuthenticationConfig.TicketAbsoluteLifetimeSeconds)}' "
+ + $"must be a positive number of seconds when set (got {options.TicketAbsoluteLifetimeSeconds}). "
+ + "Leave it unset to disable the cap."
+ );
+ }
+
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
diff --git a/Vortex.Catalog/CatalogModule.cs b/Vortex.Catalog/CatalogModule.cs
index 756265bc..738968b2 100644
--- a/Vortex.Catalog/CatalogModule.cs
+++ b/Vortex.Catalog/CatalogModule.cs
@@ -9,6 +9,7 @@
using Vortex.Primitives.Catalog.Providers;
using Vortex.Primitives.Catalog.Tags;
using Vortex.Primitives.Furniture.Providers;
+using Vortex.Primitives.Hosting;
using Vortex.Primitives.Plugins;
namespace Vortex.Catalog;
@@ -24,7 +25,13 @@ public void ConfigureServices(IServiceCollection services, HostApplicationBuilde
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton(sp =>
+ (IReferenceDataProvider)sp.GetRequiredService()
+ );
services.AddSingleton();
+ services.AddSingleton(sp =>
+ (IReferenceDataProvider)sp.GetRequiredService()
+ );
services.AddSingleton>(
sp => new CatalogSnapshotProvider(
sp.GetRequiredService>(),
@@ -33,6 +40,9 @@ public void ConfigureServices(IServiceCollection services, HostApplicationBuilde
CatalogType.Normal
)
);
+ services.AddSingleton(sp =>
+ (IReferenceDataProvider)sp.GetRequiredService>()
+ );
services.AddSingleton>(
sp => new CatalogSnapshotProvider(
sp.GetRequiredService>(),
@@ -41,5 +51,9 @@ public void ConfigureServices(IServiceCollection services, HostApplicationBuilde
CatalogType.BuildersClub
)
);
+ services.AddSingleton(sp =>
+ (IReferenceDataProvider)
+ sp.GetRequiredService>()
+ );
}
}
diff --git a/Vortex.Catalog/Grains/CatalogPurchaseGrain.Gift.cs b/Vortex.Catalog/Grains/CatalogPurchaseGrain.Gift.cs
index 7832f592..e399762c 100644
--- a/Vortex.Catalog/Grains/CatalogPurchaseGrain.Gift.cs
+++ b/Vortex.Catalog/Grains/CatalogPurchaseGrain.Gift.cs
@@ -63,15 +63,10 @@ CancellationToken ct
debitRequests,
async innerCt =>
{
- // The single line that differs from a normal purchase. Everything around it --
- // the debit, the automatic refund when this delegate throws, the spend tracking,
- // the audit trail -- stays identical on purpose: each one of those was a hole
- // while this path hand-rolled its own debit.
- await _grainFactory
- .GetInventoryGrain(receiverId.Value)
- .GrantCatalogOfferAsync(offer, extraParam, 1, innerCt)
- .ConfigureAwait(true);
-
+ // Ordered least-to-most reversible, and the notification events moved outside
+ // this compensated scope entirely (see SEC-06): a failure tracking a stat is
+ // harmless to compensate, but a failure *after* the inventory grant would leave
+ // the recipient's gift delivered for free once the wallet refunds the buyer.
if (creditCost > 0)
{
await _grainFactory
@@ -80,32 +75,9 @@ await _grainFactory
.ConfigureAwait(true);
}
- await _events
- .PublishAsync(
- new CatalogPurchasedEvent(
- buyerId,
- catalogType.ToString(),
- offerId,
- 1,
- creditCost
- ),
- innerCt
- )
- .ConfigureAwait(true);
-
- // Emitted on top of the purchase event rather than instead of it: the buyer's
- // spend belongs in the same series as every other purchase, and a gift also has
- // to answer "to whom".
- await _events
- .PublishAsync(
- new CatalogGiftPurchasedEvent(
- buyerId,
- receiverId.Value,
- offerId,
- creditCost
- ),
- innerCt
- )
+ await _grainFactory
+ .GetInventoryGrain(receiverId.Value)
+ .GrantCatalogOfferAsync(offer, extraParam, 1, innerCt)
.ConfigureAwait(true);
return offer;
@@ -120,6 +92,22 @@ await _events
throw CreateInsufficientBalanceException(result.Failure);
}
+ await _events
+ .PublishAsync(
+ new CatalogPurchasedEvent(buyerId, catalogType.ToString(), offerId, 1, creditCost),
+ ct
+ )
+ .ConfigureAwait(true);
+
+ // Emitted on top of the purchase event rather than instead of it: the buyer's spend belongs
+ // in the same series as every other purchase, and a gift also has to answer "to whom".
+ await _events
+ .PublishAsync(
+ new CatalogGiftPurchasedEvent(buyerId, receiverId.Value, offerId, creditCost),
+ ct
+ )
+ .ConfigureAwait(true);
+
return result.Reward!;
}
}
diff --git a/Vortex.Catalog/Grains/CatalogPurchaseGrain.cs b/Vortex.Catalog/Grains/CatalogPurchaseGrain.cs
index 5602b48e..0b4add6f 100644
--- a/Vortex.Catalog/Grains/CatalogPurchaseGrain.cs
+++ b/Vortex.Catalog/Grains/CatalogPurchaseGrain.cs
@@ -74,11 +74,9 @@ out List debitRequests
debitRequests,
async innerCt =>
{
- await _grainFactory
- .GetInventoryGrain((int)this.GetPrimaryKeyLong())
- .GrantCatalogOfferAsync(offer, extraParam, quantity, innerCt)
- .ConfigureAwait(true);
-
+ // Ordered least-to-most reversible: a failure tracking a stat is harmless to
+ // compensate, but a failure *after* the inventory grant would leave the item
+ // granted for free once the wallet refunds. See SEC-06.
if (creditCost > 0)
{
await _grainFactory
@@ -87,17 +85,9 @@ await _grainFactory
.ConfigureAwait(true);
}
- await _events
- .PublishAsync(
- new CatalogPurchasedEvent(
- (int)this.GetPrimaryKeyLong(),
- catalogType.ToString(),
- offerId,
- quantity,
- creditCost
- ),
- innerCt
- )
+ await _grainFactory
+ .GetInventoryGrain((int)this.GetPrimaryKeyLong())
+ .GrantCatalogOfferAsync(offer, extraParam, quantity, innerCt)
.ConfigureAwait(true);
return offer;
@@ -112,6 +102,22 @@ await _events
throw CreateInsufficientBalanceException(result.Failure);
}
+ // Published after the purchase has fully succeeded and is out of the compensated scope: it
+ // is a notification, not a purchase step, so a failing subscriber must never be able to
+ // trigger a refund of an already-completed sale (SEC-06).
+ await _events
+ .PublishAsync(
+ new CatalogPurchasedEvent(
+ (int)this.GetPrimaryKeyLong(),
+ catalogType.ToString(),
+ offerId,
+ quantity,
+ creditCost
+ ),
+ ct
+ )
+ .ConfigureAwait(true);
+
return result.Reward!;
}
diff --git a/Vortex.Catalog/Providers/CatalogClubGiftProvider.cs b/Vortex.Catalog/Providers/CatalogClubGiftProvider.cs
index adf5df85..555dc6b1 100644
--- a/Vortex.Catalog/Providers/CatalogClubGiftProvider.cs
+++ b/Vortex.Catalog/Providers/CatalogClubGiftProvider.cs
@@ -11,6 +11,7 @@
using Vortex.Primitives.Catalog.Snapshots;
using Vortex.Primitives.Furniture.Enums;
using Vortex.Primitives.Furniture.Providers;
+using Vortex.Primitives.Hosting;
namespace Vortex.Catalog.Providers;
@@ -18,12 +19,15 @@ public sealed class CatalogClubGiftProvider(
IDbContextFactory dbCtxFactory,
ILogger logger,
IFurnitureDefinitionProvider furnitureProvider
-) : ICatalogClubGiftProvider
+) : ICatalogClubGiftProvider, IReferenceDataProvider
{
private readonly IDbContextFactory _dbCtxFactory = dbCtxFactory;
private readonly ILogger _logger = logger;
private readonly IFurnitureDefinitionProvider _furnitureProvider = furnitureProvider;
+ // Stage 1: reads furniture definitions, must reload after IFurnitureDefinitionProvider.
+ public int LoadStage => 1;
+
private IReadOnlyList _offers = [];
public IReadOnlyList GetAll() => _offers;
diff --git a/Vortex.Catalog/Providers/CatalogClubOfferProvider.cs b/Vortex.Catalog/Providers/CatalogClubOfferProvider.cs
index 2d937566..0f010f74 100644
--- a/Vortex.Catalog/Providers/CatalogClubOfferProvider.cs
+++ b/Vortex.Catalog/Providers/CatalogClubOfferProvider.cs
@@ -8,17 +8,20 @@
using Vortex.Database.Entities.Catalog;
using Vortex.Primitives.Catalog;
using Vortex.Primitives.Catalog.Providers;
+using Vortex.Primitives.Hosting;
namespace Vortex.Catalog.Providers;
public sealed class CatalogClubOfferProvider(
IDbContextFactory dbCtxFactory,
ILogger logger
-) : ICatalogClubOfferProvider
+) : ICatalogClubOfferProvider, IReferenceDataProvider
{
private readonly IDbContextFactory _dbCtxFactory = dbCtxFactory;
private readonly ILogger _logger = logger;
+ public int LoadStage => 0;
+
private IReadOnlyList _offers = [];
public IReadOnlyList GetAll()
diff --git a/Vortex.Catalog/Providers/CatalogSnapshotProvider.cs b/Vortex.Catalog/Providers/CatalogSnapshotProvider.cs
index 4ad1de11..bb9c827a 100644
--- a/Vortex.Catalog/Providers/CatalogSnapshotProvider.cs
+++ b/Vortex.Catalog/Providers/CatalogSnapshotProvider.cs
@@ -12,6 +12,7 @@
using Vortex.Primitives.Catalog.Providers;
using Vortex.Primitives.Catalog.Snapshots;
using Vortex.Primitives.Furniture.Providers;
+using Vortex.Primitives.Hosting;
namespace Vortex.Catalog.Providers;
@@ -20,7 +21,7 @@ public sealed class CatalogSnapshotProvider(
ILogger> logger,
IFurnitureDefinitionProvider furnitureProvider,
CatalogType catalogType
-) : ICatalogSnapshotProvider
+) : ICatalogSnapshotProvider, IReferenceDataProvider
where TTag : ICatalogTag
{
private readonly IDbContextFactory _dbCtxFactory = dbCtxFactory;
@@ -28,6 +29,10 @@ CatalogType catalogType
private readonly IFurnitureDefinitionProvider _furnitureProvider = furnitureProvider;
private CatalogSnapshot _current = CatalogSnapshot.Empty;
+ // Stage 1: reads furniture definitions while building snapshots, so it must reload after
+ // IFurnitureDefinitionProvider (stage 0).
+ public int LoadStage => 1;
+
public CatalogType CatalogType => catalogType;
public CatalogSnapshot Current => _current;
diff --git a/Vortex.Cloud.sln b/Vortex.Cloud.sln
index ae687bfd..dcce6baa 100644
--- a/Vortex.Cloud.sln
+++ b/Vortex.Cloud.sln
@@ -69,6 +69,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vortex.Plugins.TestPlugin",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vortex.Hosting.Tests", "Vortex.Hosting.Tests\Vortex.Hosting.Tests.csproj", "{79069E02-7C80-4930-97DF-82D320F42FC9}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vortex.Crypto.Tests", "Vortex.Crypto.Tests\Vortex.Crypto.Tests.csproj", "{25FB0731-6CCB-4796-A93F-1DD7D476F30F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vortex.Authentication.Tests", "Vortex.Authentication.Tests\Vortex.Authentication.Tests.csproj", "{AE069C3E-DE2D-4737-8CA0-E47B2710C135}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vortex.Pipeline.Tests", "Vortex.Pipeline.Tests\Vortex.Pipeline.Tests.csproj", "{8789349C-266A-4FE3-B5E2-EC8FBCFA1362}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -475,6 +481,42 @@ Global
{79069E02-7C80-4930-97DF-82D320F42FC9}.Release|x64.Build.0 = Release|Any CPU
{79069E02-7C80-4930-97DF-82D320F42FC9}.Release|x86.ActiveCfg = Release|Any CPU
{79069E02-7C80-4930-97DF-82D320F42FC9}.Release|x86.Build.0 = Release|Any CPU
+ {25FB0731-6CCB-4796-A93F-1DD7D476F30F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {25FB0731-6CCB-4796-A93F-1DD7D476F30F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {25FB0731-6CCB-4796-A93F-1DD7D476F30F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {25FB0731-6CCB-4796-A93F-1DD7D476F30F}.Debug|x64.Build.0 = Debug|Any CPU
+ {25FB0731-6CCB-4796-A93F-1DD7D476F30F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {25FB0731-6CCB-4796-A93F-1DD7D476F30F}.Debug|x86.Build.0 = Debug|Any CPU
+ {25FB0731-6CCB-4796-A93F-1DD7D476F30F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {25FB0731-6CCB-4796-A93F-1DD7D476F30F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {25FB0731-6CCB-4796-A93F-1DD7D476F30F}.Release|x64.ActiveCfg = Release|Any CPU
+ {25FB0731-6CCB-4796-A93F-1DD7D476F30F}.Release|x64.Build.0 = Release|Any CPU
+ {25FB0731-6CCB-4796-A93F-1DD7D476F30F}.Release|x86.ActiveCfg = Release|Any CPU
+ {25FB0731-6CCB-4796-A93F-1DD7D476F30F}.Release|x86.Build.0 = Release|Any CPU
+ {AE069C3E-DE2D-4737-8CA0-E47B2710C135}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AE069C3E-DE2D-4737-8CA0-E47B2710C135}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AE069C3E-DE2D-4737-8CA0-E47B2710C135}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {AE069C3E-DE2D-4737-8CA0-E47B2710C135}.Debug|x64.Build.0 = Debug|Any CPU
+ {AE069C3E-DE2D-4737-8CA0-E47B2710C135}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {AE069C3E-DE2D-4737-8CA0-E47B2710C135}.Debug|x86.Build.0 = Debug|Any CPU
+ {AE069C3E-DE2D-4737-8CA0-E47B2710C135}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AE069C3E-DE2D-4737-8CA0-E47B2710C135}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AE069C3E-DE2D-4737-8CA0-E47B2710C135}.Release|x64.ActiveCfg = Release|Any CPU
+ {AE069C3E-DE2D-4737-8CA0-E47B2710C135}.Release|x64.Build.0 = Release|Any CPU
+ {AE069C3E-DE2D-4737-8CA0-E47B2710C135}.Release|x86.ActiveCfg = Release|Any CPU
+ {AE069C3E-DE2D-4737-8CA0-E47B2710C135}.Release|x86.Build.0 = Release|Any CPU
+ {8789349C-266A-4FE3-B5E2-EC8FBCFA1362}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8789349C-266A-4FE3-B5E2-EC8FBCFA1362}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8789349C-266A-4FE3-B5E2-EC8FBCFA1362}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {8789349C-266A-4FE3-B5E2-EC8FBCFA1362}.Debug|x64.Build.0 = Debug|Any CPU
+ {8789349C-266A-4FE3-B5E2-EC8FBCFA1362}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {8789349C-266A-4FE3-B5E2-EC8FBCFA1362}.Debug|x86.Build.0 = Debug|Any CPU
+ {8789349C-266A-4FE3-B5E2-EC8FBCFA1362}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8789349C-266A-4FE3-B5E2-EC8FBCFA1362}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8789349C-266A-4FE3-B5E2-EC8FBCFA1362}.Release|x64.ActiveCfg = Release|Any CPU
+ {8789349C-266A-4FE3-B5E2-EC8FBCFA1362}.Release|x64.Build.0 = Release|Any CPU
+ {8789349C-266A-4FE3-B5E2-EC8FBCFA1362}.Release|x86.ActiveCfg = Release|Any CPU
+ {8789349C-266A-4FE3-B5E2-EC8FBCFA1362}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Vortex.Crypto.Tests/DiffieServiceTests.cs b/Vortex.Crypto.Tests/DiffieServiceTests.cs
new file mode 100644
index 00000000..7ce17328
--- /dev/null
+++ b/Vortex.Crypto.Tests/DiffieServiceTests.cs
@@ -0,0 +1,195 @@
+using System;
+using System.Text;
+using FluentAssertions;
+using Microsoft.Extensions.Options;
+using Org.BouncyCastle.Crypto.Parameters;
+using Org.BouncyCastle.Math;
+using Org.BouncyCastle.Security;
+using Org.BouncyCastle.Utilities.Encoders;
+using Vortex.Crypto.Configuration;
+using Xunit;
+
+namespace Vortex.Crypto.Tests;
+
+///
+/// TEST-01 safety net for . Per the audit, the DH private/public key
+/// pair is generated once in the constructor (one per service instance), not per call, so:
+///
+/// - the SAME instance must answer identically every
+/// time;
+/// - two DIFFERENT instances must land on different public values (fresh randomness happens at
+/// construction, not at call time).
+///
+/// The safe-prime hex constant below is copied verbatim from DiffieService.DH_SAFE_PRIME_HEX
+/// (it is private, so it cannot be read off the class under test) purely so tests can independently
+/// recompute the expected generator/prime and cross-check a real Diffie-Hellman exchange by manual
+/// modpow.
+///
+public sealed class DiffieServiceTests
+{
+ private const string DH_SAFE_PRIME_HEX =
+ "BA772B7ED412CCEAFB345933614CEC83FD8D5F4DC5EC1DC08382BAD6B0CA97224EF920BD50CCFBE709993C09A766AE87";
+
+ private static readonly BigInteger s_prime = new(DH_SAFE_PRIME_HEX, 16);
+ private static readonly BigInteger s_generator = BigInteger.Two;
+
+ private readonly struct Fixture(DiffieService diffie, RsaService rsa, RsaKeyParameters publicKey)
+ {
+ public DiffieService Diffie { get; } = diffie;
+ public RsaService Rsa { get; } = rsa;
+ public RsaKeyParameters PublicKey { get; } = publicKey;
+ }
+
+ private static Fixture NewService()
+ {
+ (RsaKeyParameters pub, RsaPrivateCrtKeyParameters priv) = TestRsaKeyFactory.Generate();
+ IOptions options = TestRsaKeyFactory.ToOptions(pub, priv);
+ RsaService rsa = new(options);
+
+ return new Fixture(new DiffieService(rsa), rsa, pub);
+ }
+
+ /// Mirrors DiffieService.EncryptBigInteger's (private) decode side, using only
+ /// the public key -- exactly what a real client does to read the server's signed values.
+ private static BigInteger DecodeSigned(string signedHex, RsaKeyParameters publicKey)
+ {
+ byte[] signedBytes = Hex.Decode(signedHex);
+ byte[] plain = TestRsaKeyFactory.VerifyWithPublicKey(publicKey, signedBytes);
+
+ return new BigInteger(Encoding.UTF8.GetString(plain));
+ }
+
+ /// Mirrors what a real client does before calling GenerateSharedKey: RSA-encrypt
+ /// (public key) the decimal string of its own DH public value.
+ private static string EncryptClientPublicKey(BigInteger clientPublic, RsaService rsa)
+ {
+ byte[] payload = Encoding.UTF8.GetBytes(clientPublic.ToString(10));
+ byte[] encrypted = rsa.Encrypt(payload);
+
+ return Hex.ToHexString(encrypted);
+ }
+
+ [Fact]
+ public void GetPublicKey_SameInstance_IsDeterministicAcrossCalls()
+ {
+ Fixture fixture = NewService();
+
+ BigInteger first = DecodeSigned(fixture.Diffie.GetPublicKey(), fixture.PublicKey);
+ BigInteger second = DecodeSigned(fixture.Diffie.GetPublicKey(), fixture.PublicKey);
+
+ second.Should().Be(first, "the DH keypair is generated once per instance, not per call");
+ }
+
+ [Fact]
+ public void GetPublicKey_TwoDifferentInstances_ProduceDifferentValues()
+ {
+ Fixture fixture = NewService();
+ DiffieService second = new(fixture.Rsa);
+
+ BigInteger firstValue = DecodeSigned(fixture.Diffie.GetPublicKey(), fixture.PublicKey);
+ BigInteger secondValue = DecodeSigned(second.GetPublicKey(), fixture.PublicKey);
+
+ secondValue
+ .Should()
+ .NotBe(firstValue, "each instance generates its own random DH private key");
+ }
+
+ [Fact]
+ public void GetSignedPrime_MatchesTheConfiguredSafePrime()
+ {
+ Fixture fixture = NewService();
+
+ BigInteger decoded = DecodeSigned(fixture.Diffie.GetSignedPrime(), fixture.PublicKey);
+
+ decoded.Should().Be(s_prime);
+ }
+
+ [Fact]
+ public void GetSignedGenerator_IsTwo()
+ {
+ Fixture fixture = NewService();
+
+ BigInteger decoded = DecodeSigned(fixture.Diffie.GetSignedGenerator(), fixture.PublicKey);
+
+ decoded.Should().Be(s_generator);
+ }
+
+ [Fact]
+ public void GenerateSharedKey_MatchesManualModPowComputation()
+ {
+ Fixture fixture = NewService();
+
+ BigInteger serverPublic = DecodeSigned(fixture.Diffie.GetPublicKey(), fixture.PublicKey);
+
+ // Act as the "client": generate our own DH keypair against the same public group.
+ SecureRandom random = new();
+ BigInteger clientPrivate = new BigInteger(256, random).SetBit(255);
+ BigInteger clientPublic = s_generator.ModPow(clientPrivate, s_prime);
+
+ string encryptedClientPublic = EncryptClientPublicKey(clientPublic, fixture.Rsa);
+
+ byte[] serverComputedShared = fixture.Diffie.GenerateSharedKey(encryptedClientPublic);
+
+ // The client-side computation of the very same shared secret: serverPublic ^ clientPrivate.
+ byte[] expectedShared = serverPublic.ModPow(clientPrivate, s_prime).ToByteArrayUnsigned();
+
+ serverComputedShared.Should().Equal(expectedShared);
+ }
+
+ [Fact]
+ public void GenerateSharedKey_RejectsZero()
+ {
+ Fixture fixture = NewService();
+ string encrypted = EncryptClientPublicKey(BigInteger.Zero, fixture.Rsa);
+
+ Action act = () => fixture.Diffie.GenerateSharedKey(encrypted);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void GenerateSharedKey_RejectsOne()
+ {
+ Fixture fixture = NewService();
+ string encrypted = EncryptClientPublicKey(BigInteger.One, fixture.Rsa);
+
+ Action act = () => fixture.Diffie.GenerateSharedKey(encrypted);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void GenerateSharedKey_RejectsPrimeMinusOne()
+ {
+ Fixture fixture = NewService();
+ string encrypted = EncryptClientPublicKey(s_prime.Subtract(BigInteger.One), fixture.Rsa);
+
+ Action act = () => fixture.Diffie.GenerateSharedKey(encrypted);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void GenerateSharedKey_RejectsThePrimeItself()
+ {
+ Fixture fixture = NewService();
+ string encrypted = EncryptClientPublicKey(s_prime, fixture.Rsa);
+
+ Action act = () => fixture.Diffie.GenerateSharedKey(encrypted);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void GenerateSharedKey_AcceptsValueJustInsideTheValidRange()
+ {
+ Fixture fixture = NewService();
+ // Valid range is documented as [2, prime - 1) -- prime - 2 is the top boundary that must
+ // still be accepted.
+ string encrypted = EncryptClientPublicKey(s_prime.Subtract(BigInteger.Two), fixture.Rsa);
+
+ Action act = () => fixture.Diffie.GenerateSharedKey(encrypted);
+
+ act.Should().NotThrow();
+ }
+}
diff --git a/Vortex.Crypto.Tests/Rc4EngineTests.cs b/Vortex.Crypto.Tests/Rc4EngineTests.cs
new file mode 100644
index 00000000..f2bf54ae
--- /dev/null
+++ b/Vortex.Crypto.Tests/Rc4EngineTests.cs
@@ -0,0 +1,214 @@
+using System;
+using System.Text;
+using FluentAssertions;
+using Xunit;
+
+namespace Vortex.Crypto.Tests;
+
+///
+/// TEST-01 safety net for : round-trip correctness, one well-known RC4
+/// test vector, the constructor's key-length guards, and the CODE-02 argument validation added to
+/// .
+///
+public sealed class Rc4EngineTests
+{
+ private static byte[] Key(string s) => Encoding.UTF8.GetBytes(s);
+
+ [Fact]
+ public void RoundTrip_EncryptThenDecrypt_ReturnsOriginalPlaintext()
+ {
+ byte[] key = Key("a reasonably long RC4 session key, not just a couple of bytes");
+ byte[] plaintext = new byte[4096];
+ new Random(1234).NextBytes(plaintext);
+
+ Rc4Engine encryptor = new(key);
+ byte[] ciphertext = encryptor.Process(plaintext);
+
+ ciphertext.Should().NotEqual(plaintext, "RC4 must actually transform the data");
+
+ // A fresh engine re-keyed identically must reproduce the same keystream so it can decrypt.
+ Rc4Engine decryptor = new(key);
+ byte[] decrypted = decryptor.Process(ciphertext);
+
+ decrypted.Should().Equal(plaintext);
+ }
+
+ [Fact]
+ public void KnownVector_KeyPlaintext_MatchesStandardRc4Ciphertext()
+ {
+ // Classical RC4 test vector (Key="Key", Plaintext="Plaintext"), widely reproduced across
+ // reference implementations. dropN=0 / no offset matches the plain, undropped RC4 stream.
+ Rc4Engine engine = new(Key("Key"));
+ byte[] ciphertext = engine.Process(Key("Plaintext"));
+
+ Convert.ToHexString(ciphertext).Should().Be("BBF316E8D940AF0AD3");
+ }
+
+ [Fact]
+ public void Constructor_NullKey_ThrowsArgumentNullException()
+ {
+ Action act = () => _ = new Rc4Engine(null!);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Constructor_EmptyKey_ThrowsArgumentException()
+ {
+ Action act = () => _ = new Rc4Engine(Array.Empty());
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Constructor_KeyLongerThan256Bytes_ThrowsArgumentException()
+ {
+ Action act = () => _ = new Rc4Engine(new byte[257]);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Constructor_KeyExactly256Bytes_Succeeds()
+ {
+ Action act = () => _ = new Rc4Engine(new byte[256]);
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Constructor_NegativeDropN_ThrowsArgumentOutOfRangeException()
+ {
+ Action act = () => _ = new Rc4Engine(Key("some-key"), dropN: -1);
+
+ act.Should().Throw();
+ }
+
+ [Theory]
+ [InlineData(-1, 0)]
+ [InlineData(0, -1)]
+ public void ProcessBytes_NegativeOffset_ThrowsArgumentOutOfRangeException(
+ int inputOffset,
+ int outputOffset
+ )
+ {
+ Rc4Engine engine = new(Key("k"));
+ byte[] buffer = new byte[8];
+
+ Action act = () =>
+ engine.ProcessBytes(buffer, inputOffset, 4, buffer, outputOffset);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void ProcessBytes_NegativeLength_ThrowsArgumentOutOfRangeException()
+ {
+ Rc4Engine engine = new(Key("k"));
+ byte[] buffer = new byte[8];
+
+ Action act = () => engine.ProcessBytes(buffer, 0, -1, buffer, 0);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void ProcessBytes_NullInputData_ThrowsArgumentNullException()
+ {
+ Rc4Engine engine = new(Key("k"));
+ byte[] buffer = new byte[8];
+
+ Action act = () => engine.ProcessBytes(null!, 0, 4, buffer, 0);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void ProcessBytes_NullOutputData_ThrowsArgumentNullException()
+ {
+ Rc4Engine engine = new(Key("k"));
+ byte[] buffer = new byte[8];
+
+ Action act = () => engine.ProcessBytes(buffer, 0, 4, null!, 0);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void ProcessBytes_InputBufferShorterThanRequestedLength_ThrowsArgumentException()
+ {
+ Rc4Engine engine = new(Key("k"));
+ byte[] input = new byte[4];
+ byte[] output = new byte[8];
+
+ Action act = () => engine.ProcessBytes(input, 0, 8, output, 0);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void ProcessBytes_OutputBufferShorterThanRequestedLength_ThrowsArgumentException()
+ {
+ Rc4Engine engine = new(Key("k"));
+ byte[] input = new byte[8];
+ byte[] output = new byte[4];
+
+ Action act = () => engine.ProcessBytes(input, 0, 8, output, 0);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Peek_DoesNotAdvanceKeystream_AndMatchesSubsequentProcessBytes()
+ {
+ byte[] key = Key("peek-does-not-consume");
+ byte[] plaintext = Key("some plaintext that is peeked at before being really processed");
+
+ Rc4Engine engine = new(key);
+
+ byte[] peeked = engine.Peek(plaintext);
+ byte[] peekedAgain = engine.Peek(plaintext);
+
+ // Peeking twice must return identical output -- proof it never mutates (_i, _j).
+ peekedAgain.Should().Equal(peeked);
+
+ byte[] processed = engine.ProcessBytes(
+ plaintext,
+ 0,
+ plaintext.Length,
+ new byte[plaintext.Length],
+ 0
+ );
+
+ // The real (state-advancing) call must produce the exact same bytes Peek predicted.
+ processed.Should().Equal(peeked);
+ }
+
+ [Fact]
+ public void DropN_ProducesDifferentKeystreamThanNoDrop()
+ {
+ byte[] key = Key("drop-n-changes-the-stream");
+ byte[] plaintext = new byte[64];
+ new Random(99).NextBytes(plaintext);
+
+ Rc4Engine noDrop = new(key);
+ Rc4Engine withDrop = new(key, dropN: 256);
+
+ byte[] noDropCipher = noDrop.Process(plaintext);
+ byte[] withDropCipher = withDrop.Process(plaintext);
+
+ withDropCipher.Should().NotEqual(noDropCipher);
+ }
+
+ [Fact]
+ public void SameKey_TwoIndependentEngines_ProduceIdenticalCiphertext()
+ {
+ byte[] key = Key("deterministic-keystream");
+ byte[] plaintext = Key("identical input must yield identical output for the same key");
+
+ Rc4Engine first = new(key);
+ Rc4Engine second = new(key);
+
+ first.Process(plaintext).Should().Equal(second.Process(plaintext));
+ }
+}
diff --git a/Vortex.Crypto.Tests/RsaServiceTests.cs b/Vortex.Crypto.Tests/RsaServiceTests.cs
new file mode 100644
index 00000000..75635c28
--- /dev/null
+++ b/Vortex.Crypto.Tests/RsaServiceTests.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Text;
+using FluentAssertions;
+using Microsoft.Extensions.Options;
+using Org.BouncyCastle.Crypto.Parameters;
+using Vortex.Crypto.Configuration;
+using Xunit;
+
+namespace Vortex.Crypto.Tests;
+
+///
+/// TEST-01 safety net for : encrypt/decrypt round-trips at the configured
+/// key size, sign/verify round-trips, and that corrupted ciphertext throws instead of silently
+/// returning wrong bytes (PKCS#1 padding failure).
+///
+public sealed class RsaServiceTests
+{
+ private static (RsaService Service, RsaKeyParameters PublicKey) NewService()
+ {
+ (RsaKeyParameters pub, RsaPrivateCrtKeyParameters priv) = TestRsaKeyFactory.Generate();
+ IOptions options = TestRsaKeyFactory.ToOptions(pub, priv);
+
+ return (new RsaService(options), pub);
+ }
+
+ [Theory]
+ [InlineData("short")]
+ [InlineData("a somewhat longer payload that still fits in a single 1024-bit RSA/PKCS#1 block")]
+ public void EncryptThenDecrypt_RoundTrips_ToOriginalBytes(string plaintext)
+ {
+ (RsaService service, _) = NewService();
+ byte[] original = Encoding.UTF8.GetBytes(plaintext);
+
+ byte[] encrypted = service.Encrypt(original);
+ byte[] decrypted = service.Decrypt(encrypted);
+
+ decrypted.Should().Equal(original);
+ }
+
+ [Fact]
+ public void Encrypt_ProducesCiphertextDifferentFromPlaintext()
+ {
+ (RsaService service, _) = NewService();
+ byte[] original = Encoding.UTF8.GetBytes("do not leak me in the clear");
+
+ byte[] encrypted = service.Encrypt(original);
+
+ encrypted.Should().NotEqual(original);
+ }
+
+ [Fact]
+ public void Encrypt_IsNonDeterministic_AcrossCalls()
+ {
+ // PKCS#1 v1.5 encryption pads with random bytes, so the same plaintext must not encrypt to
+ // the same ciphertext twice -- a regression here would mean padding got dropped.
+ (RsaService service, _) = NewService();
+ byte[] original = Encoding.UTF8.GetBytes("same plaintext, twice");
+
+ byte[] first = service.Encrypt(original);
+ byte[] second = service.Encrypt(original);
+
+ first.Should().NotEqual(second);
+ service.Decrypt(first).Should().Equal(original);
+ service.Decrypt(second).Should().Equal(original);
+ }
+
+ [Fact]
+ public void SignThenVerifyWithPublicKey_RecoversOriginalBytes()
+ {
+ (RsaService service, RsaKeyParameters publicKey) = NewService();
+ byte[] original = Encoding.UTF8.GetBytes("398471209384712093847120938471209");
+
+ byte[] signed = service.Sign(original);
+ byte[] recovered = TestRsaKeyFactory.VerifyWithPublicKey(publicKey, signed);
+
+ recovered.Should().Equal(original);
+ }
+
+ [Fact]
+ public void Decrypt_CorruptedCiphertext_ThrowsInsteadOfReturningWrongData()
+ {
+ (RsaService service, _) = NewService();
+ byte[] encrypted = service.Encrypt(Encoding.UTF8.GetBytes("legit payload"));
+
+ // Flip a byte in the middle of the ciphertext block -- PKCS#1 padding must reject this.
+ encrypted[encrypted.Length / 2] ^= 0xFF;
+
+ Action act = () => service.Decrypt(encrypted);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Decrypt_RandomGarbageOfCorrectBlockSize_Throws()
+ {
+ (RsaService service, RsaKeyParameters publicKey) = NewService();
+ byte[] garbage = new byte[(publicKey.Modulus.BitLength + 7) / 8];
+ new Random(42).NextBytes(garbage);
+ // PKCS#1 v1.5 requires the first byte to be 0x00; force it invalid so this is not a flaky,
+ // astronomically-unlikely-to-pass-by-chance assertion.
+ garbage[0] = 0xFF;
+
+ Action act = () => service.Decrypt(garbage);
+
+ act.Should().Throw();
+ }
+}
diff --git a/Vortex.Crypto.Tests/TestRsaKeyFactory.cs b/Vortex.Crypto.Tests/TestRsaKeyFactory.cs
new file mode 100644
index 00000000..b448dad8
--- /dev/null
+++ b/Vortex.Crypto.Tests/TestRsaKeyFactory.cs
@@ -0,0 +1,69 @@
+using Microsoft.Extensions.Options;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Encodings;
+using Org.BouncyCastle.Crypto.Engines;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Crypto.Parameters;
+using Org.BouncyCastle.Math;
+using Org.BouncyCastle.Security;
+using Vortex.Crypto.Configuration;
+
+namespace Vortex.Crypto.Tests;
+
+///
+/// Generates a fresh, real (never hard-coded/fabricated) RSA key pair for each test run and wraps
+/// it as the shape expects, plus a bare
+/// "verify with the public key" helper -- only exposes
+/// Encrypt/Decrypt/Sign (both Decrypt and Sign always use the private key), so recovering the
+/// plaintext behind a value produced requires driving BouncyCastle's
+/// PKCS#1 engine directly with the public key, exactly like a real client would.
+///
+internal static class TestRsaKeyFactory
+{
+ public static (RsaKeyParameters PublicKey, RsaPrivateCrtKeyParameters PrivateKey) Generate(
+ int strengthBits = 1024
+ )
+ {
+ RsaKeyPairGenerator generator = new();
+ generator.Init(
+ new RsaKeyGenerationParameters(
+ BigInteger.ValueOf(0x10001),
+ new SecureRandom(),
+ strengthBits,
+ 80
+ )
+ );
+
+ AsymmetricCipherKeyPair pair = generator.GenerateKeyPair();
+
+ return ((RsaKeyParameters)pair.Public, (RsaPrivateCrtKeyParameters)pair.Private);
+ }
+
+ public static IOptions ToOptions(
+ RsaKeyParameters publicKey,
+ RsaPrivateCrtKeyParameters privateKey
+ )
+ {
+ CryptoConfig config = new()
+ {
+ KeySize = publicKey.Exponent.ToString(16),
+ PublicKey = publicKey.Modulus.ToString(16),
+ PrivateKey = privateKey.Exponent.ToString(16),
+ EnableServerToClientEncryption = true,
+ };
+
+ return Options.Create(config);
+ }
+
+ ///
+ /// The public-key-side inverse of : recovers the bytes that were
+ /// signed, using only the public modulus/exponent (never the private key).
+ ///
+ public static byte[] VerifyWithPublicKey(RsaKeyParameters publicKey, byte[] signed)
+ {
+ Pkcs1Encoding cipher = new(new RsaEngine());
+ cipher.Init(false, publicKey);
+
+ return cipher.ProcessBlock(signed, 0, signed.Length);
+ }
+}
diff --git a/Vortex.Crypto.Tests/Vortex.Crypto.Tests.csproj b/Vortex.Crypto.Tests/Vortex.Crypto.Tests.csproj
new file mode 100644
index 00000000..677655ed
--- /dev/null
+++ b/Vortex.Crypto.Tests/Vortex.Crypto.Tests.csproj
@@ -0,0 +1,19 @@
+
+
+ net10.0
+ disable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Vortex.Crypto/Rc4Engine.cs b/Vortex.Crypto/Rc4Engine.cs
index 416ec858..481f1c57 100644
--- a/Vortex.Crypto/Rc4Engine.cs
+++ b/Vortex.Crypto/Rc4Engine.cs
@@ -47,9 +47,14 @@ int outputOffset
ArgumentNullException.ThrowIfNull(outputData);
ArgumentOutOfRangeException.ThrowIfNegative(length);
- if (inputOffset < 0 || outputOffset < 0)
+ if (inputOffset < 0)
{
- throw new ArgumentOutOfRangeException("Offsets must be non-negative.");
+ throw new ArgumentOutOfRangeException(nameof(inputOffset), "Offset must be non-negative.");
+ }
+
+ if (outputOffset < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(outputOffset), "Offset must be non-negative.");
}
if (inputData.Length - inputOffset < length)
diff --git a/Vortex.Dashboard.API/Hosting/DashboardEndpoints.cs b/Vortex.Dashboard.API/Hosting/DashboardEndpoints.cs
index 92ef389f..20c7ef19 100644
--- a/Vortex.Dashboard.API/Hosting/DashboardEndpoints.cs
+++ b/Vortex.Dashboard.API/Hosting/DashboardEndpoints.cs
@@ -200,9 +200,12 @@ public static void MapMeta(WebApplication app)
methods = ["GET"];
}
- string?[] capabilities = endpoint
+ // OfType() drops null policies *and* yields a non-nullable element
+ // type, matching the ApiRouteDescriptor.Capabilities: string[] parameter.
+ string[] capabilities = endpoint
.Metadata.OfType()
.Select(auth => auth.Policy)
+ .OfType()
.Where(policy => !string.IsNullOrWhiteSpace(policy))
.Distinct()
.OrderBy(policy => policy)
diff --git a/Vortex.Database/Configuration/DatabaseConfig.cs b/Vortex.Database/Configuration/DatabaseConfig.cs
index b14d70e6..194e135e 100644
--- a/Vortex.Database/Configuration/DatabaseConfig.cs
+++ b/Vortex.Database/Configuration/DatabaseConfig.cs
@@ -5,5 +5,11 @@ public class DatabaseConfig
public const string SECTION_NAME = "Vortex:Database";
public string ConnectionString { get; init; } = string.Empty;
- public bool LoggingEnabled { get; init; } = false;
+
+ ///
+ /// Explicit MySQL server version (e.g. "8.0.36-mysql"), so ServerVersion.Parse can be used
+ /// instead of ServerVersion.AutoDetect, which opens a connection during DI configuration
+ /// itself. Left unset, the driver falls back to auto-detection.
+ ///
+ public string? MySqlServerVersion { get; init; }
}
diff --git a/Vortex.Database/Extensions/ServiceCollectionExtensions.cs b/Vortex.Database/Extensions/ServiceCollectionExtensions.cs
index 596210b8..1eed67bc 100644
--- a/Vortex.Database/Extensions/ServiceCollectionExtensions.cs
+++ b/Vortex.Database/Extensions/ServiceCollectionExtensions.cs
@@ -31,14 +31,14 @@ HostApplicationBuilder builder
{
DatabaseConfig dbConfig = sp.GetRequiredService>().Value;
string connectionString = dbConfig.ConnectionString;
- bool loggingEnabled = dbConfig.LoggingEnabled;
options.UseMySql(
connectionString,
- ServerVersion.AutoDetect(connectionString),
+ ResolveServerVersion(dbConfig, connectionString),
options =>
{
options.MigrationsAssembly("Vortex.Database");
+ options.EnableRetryOnFailure(maxRetryCount: 3);
}
);
}
@@ -47,6 +47,14 @@ HostApplicationBuilder builder
return services;
}
+ private static ServerVersion ResolveServerVersion(
+ DatabaseConfig dbConfig,
+ string connectionString
+ ) =>
+ string.IsNullOrWhiteSpace(dbConfig.MySqlServerVersion)
+ ? ServerVersion.AutoDetect(connectionString)
+ : ServerVersion.Parse(dbConfig.MySqlServerVersion);
+
public static IServiceCollection AddPluginTablePrefix(
this IServiceCollection services
)
@@ -100,16 +108,16 @@ this IServiceCollection services
IHostServices host = sp.GetRequiredService();
DatabaseConfig dbConfig = host.GetRequiredService>().Value;
string connectionString = dbConfig.ConnectionString;
- bool loggingEnabled = dbConfig.LoggingEnabled;
options.UseMySql(
connectionString,
- ServerVersion.AutoDetect(connectionString),
+ ResolveServerVersion(dbConfig, connectionString),
builder =>
{
builder.MigrationsHistoryTable(
$"__EFMigrationsHistory_{prefix().TrimEnd('_')}"
);
+ builder.EnableRetryOnFailure(maxRetryCount: 3);
}
);
}
diff --git a/Vortex.Furniture/FurnitureModule.cs b/Vortex.Furniture/FurnitureModule.cs
index d26d8eb7..b57ac8fa 100644
--- a/Vortex.Furniture/FurnitureModule.cs
+++ b/Vortex.Furniture/FurnitureModule.cs
@@ -4,6 +4,7 @@
using Vortex.Furniture.Providers;
using Vortex.Primitives.Furniture;
using Vortex.Primitives.Furniture.Providers;
+using Vortex.Primitives.Hosting;
using Vortex.Primitives.Plugins;
namespace Vortex.Furniture;
@@ -19,6 +20,9 @@ public void ConfigureServices(IServiceCollection services, HostApplicationBuilde
);
services.AddSingleton();
+ services.AddSingleton(sp =>
+ (IReferenceDataProvider)sp.GetRequiredService()
+ );
services.AddSingleton();
services.AddSingleton();
}
diff --git a/Vortex.Furniture/Providers/FurnitureDefinitionProvider.cs b/Vortex.Furniture/Providers/FurnitureDefinitionProvider.cs
index 8ce57fbd..8a8ffc10 100644
--- a/Vortex.Furniture/Providers/FurnitureDefinitionProvider.cs
+++ b/Vortex.Furniture/Providers/FurnitureDefinitionProvider.cs
@@ -12,6 +12,7 @@
using Vortex.Furniture.Configuration;
using Vortex.Primitives.Furniture.Providers;
using Vortex.Primitives.Furniture.Snapshots;
+using Vortex.Primitives.Hosting;
namespace Vortex.Furniture.Providers;
@@ -19,12 +20,16 @@ public sealed class FurnitureDefinitionProvider(
IOptions config,
IDbContextFactory dbCtxFactory,
ILogger logger
-) : IFurnitureDefinitionProvider
+) : IFurnitureDefinitionProvider, IReferenceDataProvider
{
private readonly FurnitureConfig _config = config.Value;
private readonly IDbContextFactory _dbCtxFactory = dbCtxFactory;
private readonly ILogger _logger = logger;
+ // Stage 0: CatalogSnapshotProvider and CatalogClubGiftProvider read furniture definitions
+ // during their own reload (see stage 1), so this must finish first.
+ public int LoadStage => 0;
+
private ImmutableDictionary _definitionsById =
ImmutableDictionary.Empty;
diff --git a/Vortex.Hosting.Tests/ConfigValidationTests.cs b/Vortex.Hosting.Tests/ConfigValidationTests.cs
index b847902c..ec279758 100644
--- a/Vortex.Hosting.Tests/ConfigValidationTests.cs
+++ b/Vortex.Hosting.Tests/ConfigValidationTests.cs
@@ -411,27 +411,48 @@ public void ATooShortDashboardSession_IsRefused()
// ── Orleans ──────────────────────────────────────────────────────────────
+ private static OrleansHostConfigValidator OrleansValidator(string connectionString = "server=x") =>
+ new(Options.Create(new DatabaseConfig { ConnectionString = connectionString }));
+
[Fact]
public void TheDefaultOrleansEndpoint_Passes() =>
- new OrleansHostConfigValidator()
- .Validate(null, new OrleansHostConfig())
- .Succeeded.Should()
- .BeTrue();
+ OrleansValidator().Validate(null, new OrleansHostConfig()).Succeeded.Should().BeTrue();
[Fact]
public void ANonLiteralAdvertisedIp_IsRefused() =>
- new OrleansHostConfigValidator()
+ OrleansValidator()
.Validate(null, new OrleansHostConfig { AdvertisedIp = "silo.internal" })
.Failed.Should()
.BeTrue();
[Fact]
public void ACollidingSiloAndGatewayPort_IsRefused() =>
- new OrleansHostConfigValidator()
+ OrleansValidator()
.Validate(null, new OrleansHostConfig { SiloPort = 11111, GatewayPort = 11111 })
.Failed.Should()
.BeTrue();
+ [Fact]
+ public void AnUnknownClusteringProvider_IsRefused() =>
+ OrleansValidator()
+ .Validate(null, new OrleansHostConfig { ClusteringProvider = "zookeeper" })
+ .Failed.Should()
+ .BeTrue();
+
+ [Fact]
+ public void AdoNetClusteringWithoutAConnectionString_IsRefused() =>
+ OrleansValidator(connectionString: string.Empty)
+ .Validate(null, new OrleansHostConfig { ClusteringProvider = "adonet" })
+ .Failed.Should()
+ .BeTrue();
+
+ [Fact]
+ public void AdoNetClusteringWithAConnectionString_Passes() =>
+ OrleansValidator()
+ .Validate(null, new OrleansHostConfig { ClusteringProvider = "adonet" })
+ .Succeeded.Should()
+ .BeTrue();
+
// ── Plugins ──────────────────────────────────────────────────────────────
[Fact]
diff --git a/Vortex.Main/Configuration/OrleansHostConfig.cs b/Vortex.Main/Configuration/OrleansHostConfig.cs
index efeb5c76..77397132 100644
--- a/Vortex.Main/Configuration/OrleansHostConfig.cs
+++ b/Vortex.Main/Configuration/OrleansHostConfig.cs
@@ -20,4 +20,27 @@ public sealed class OrleansHostConfig
/// deployment, and configure a persistent clustering/storage provider otherwise.
///
public bool AllowUnclusteredOutsideDevelopment { get; init; }
+
+ ///
+ /// "localhost" (default, unchanged) or "adonet" for multi-silo clustering backed by the same
+ /// MySQL database as Vortex:Database:ConnectionString. Selecting "adonet" requires
+ /// Orleans's official clustering SQL scripts (https://aka.ms/orleans-sql-scripts) to already
+ /// be applied to that database — this is a deployment prerequisite this process cannot apply
+ /// for you, the same way EF migrations must already be applied before startup.
+ ///
+ public string ClusteringProvider { get; init; } = "localhost";
+
+ ///
+ /// "memory" (default, unchanged) or "adonet" to persist PubSubStore (the only grain storage
+ /// actually used — see ORL-01) so in-flight stream messages survive a silo restart. Same SQL
+ /// script prerequisite as .
+ ///
+ public string GrainStorageProvider { get; init; } = "memory";
+
+ ///
+ /// ADO.NET provider invariant name for "adonet" mode. Defaults to the driver this project
+ /// already ships (MySqlConnector, via Pomelo.EntityFrameworkCore.MySql); override only if
+ /// pointing Orleans at a different ADO.NET provider/database engine.
+ ///
+ public string Invariant { get; init; } = "MySqlConnector";
}
diff --git a/Vortex.Main/Configuration/OrleansHostConfigValidator.cs b/Vortex.Main/Configuration/OrleansHostConfigValidator.cs
index 5bdf8889..998f7577 100644
--- a/Vortex.Main/Configuration/OrleansHostConfigValidator.cs
+++ b/Vortex.Main/Configuration/OrleansHostConfigValidator.cs
@@ -1,6 +1,8 @@
+using System;
using System.Collections.Generic;
using System.Net;
using Microsoft.Extensions.Options;
+using Vortex.Database.Configuration;
namespace Vortex.Main.Configuration;
@@ -9,12 +11,61 @@ namespace Vortex.Main.Configuration;
/// collision here surfaces as an opaque clustering failure well after the host has started
/// announcing itself.
///
-public sealed class OrleansHostConfigValidator : IValidateOptions
+public sealed class OrleansHostConfigValidator(IOptions databaseConfig)
+ : IValidateOptions
{
+ private static readonly string[] KnownClusteringProviders = ["localhost", "adonet"];
+ private static readonly string[] KnownGrainStorageProviders = ["memory", "adonet"];
+
public ValidateOptionsResult Validate(string? name, OrleansHostConfig options)
{
List failures = [];
+ if (
+ Array.IndexOf(KnownClusteringProviders, options.ClusteringProvider) < 0
+ )
+ {
+ failures.Add(
+ $"'{OrleansHostConfig.SECTION_NAME}:{nameof(OrleansHostConfig.ClusteringProvider)}' "
+ + $"must be one of: {string.Join(", ", KnownClusteringProviders)} "
+ + $"(got '{options.ClusteringProvider}')."
+ );
+ }
+
+ if (
+ Array.IndexOf(KnownGrainStorageProviders, options.GrainStorageProvider) < 0
+ )
+ {
+ failures.Add(
+ $"'{OrleansHostConfig.SECTION_NAME}:{nameof(OrleansHostConfig.GrainStorageProvider)}' "
+ + $"must be one of: {string.Join(", ", KnownGrainStorageProviders)} "
+ + $"(got '{options.GrainStorageProvider}')."
+ );
+ }
+
+ if (
+ (options.ClusteringProvider == "adonet" || options.GrainStorageProvider == "adonet")
+ && string.IsNullOrWhiteSpace(options.Invariant)
+ )
+ {
+ failures.Add(
+ $"'{OrleansHostConfig.SECTION_NAME}:{nameof(OrleansHostConfig.Invariant)}' must be "
+ + "set when clustering or grain storage uses the 'adonet' provider."
+ );
+ }
+
+ if (
+ (options.ClusteringProvider == "adonet" || options.GrainStorageProvider == "adonet")
+ && string.IsNullOrWhiteSpace(databaseConfig.Value.ConnectionString)
+ )
+ {
+ failures.Add(
+ $"'{OrleansHostConfig.SECTION_NAME}' selects the 'adonet' provider, but "
+ + $"'{DatabaseConfig.SECTION_NAME}:{nameof(DatabaseConfig.ConnectionString)}' is "
+ + "not set. Orleans reuses that connection string rather than a separate one."
+ );
+ }
+
if (!IPAddress.TryParse(options.AdvertisedIp, out _))
{
failures.Add(
diff --git a/Vortex.Main/Extensions/HostApplicationBuilderExtensions.cs b/Vortex.Main/Extensions/HostApplicationBuilderExtensions.cs
index a574913c..9e2abc94 100644
--- a/Vortex.Main/Extensions/HostApplicationBuilderExtensions.cs
+++ b/Vortex.Main/Extensions/HostApplicationBuilderExtensions.cs
@@ -1,10 +1,13 @@
using System;
+using System.Data.Common;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
+using MySqlConnector;
using Orleans.Configuration;
using Orleans.Hosting;
+using Vortex.Database.Configuration;
using Vortex.Main.Configuration;
using Vortex.Primitives.Orleans;
@@ -33,7 +36,14 @@ public static HostApplicationBuilder AddOrleans(this HostApplicationBuilder buil
.Get()
?? new OrleansHostConfig();
- if (!builder.Environment.IsDevelopment())
+ bool usesAdoNet =
+ hostConfig.ClusteringProvider == "adonet" || hostConfig.GrainStorageProvider == "adonet";
+
+ bool unclustered =
+ hostConfig.ClusteringProvider == "localhost"
+ && hostConfig.GrainStorageProvider == "memory";
+
+ if (!builder.Environment.IsDevelopment() && unclustered)
{
if (!hostConfig.AllowUnclusteredOutsideDevelopment)
{
@@ -41,7 +51,9 @@ public static HostApplicationBuilder AddOrleans(this HostApplicationBuilder buil
"Refusing to start: Orleans would run with single-node localhost clustering and "
+ "in-memory grain storage/streams outside Development. State does not survive "
+ "a restart and this cannot scale beyond one silo. Configure a persistent "
- + "clustering/storage provider, or set "
+ + $"clustering/storage provider via '{OrleansHostConfig.SECTION_NAME}:"
+ + $"{nameof(OrleansHostConfig.ClusteringProvider)}'/"
+ + $"'{nameof(OrleansHostConfig.GrainStorageProvider)}' (= \"adonet\"), or set "
+ $"'{OrleansHostConfig.SECTION_NAME}:{nameof(OrleansHostConfig.AllowUnclusteredOutsideDevelopment)}' "
+ "to true to explicitly accept this for a single-node deployment."
);
@@ -55,6 +67,20 @@ public static HostApplicationBuilder AddOrleans(this HostApplicationBuilder buil
);
}
+ if (usesAdoNet)
+ {
+ // MySqlConnector isn't registered as a DbProviderFactory by default (unlike the EF Core
+ // path, which references it directly) - Orleans's ADO.NET providers resolve their driver
+ // by invariant name through DbProviderFactories.
+ DbProviderFactories.RegisterFactory(hostConfig.Invariant, MySqlConnectorFactory.Instance);
+ }
+
+ string databaseConnectionString =
+ builder
+ .Configuration.GetSection(DatabaseConfig.SECTION_NAME)
+ .Get()
+ ?.ConnectionString ?? string.Empty;
+
builder.UseOrleans(
(System.Action)(
silo =>
@@ -70,11 +96,39 @@ public static HostApplicationBuilder AddOrleans(this HostApplicationBuilder buil
listenOnAnyHostAddress: true
);
- silo.UseLocalhostClustering()
- .AddMemoryGrainStorage(OrleansStorageNames.PUB_SUB_STORE)
- .AddMemoryGrainStorage(OrleansStorageNames.PLAYER_STORE)
- .AddMemoryGrainStorage(OrleansStorageNames.ROOM_STORE)
- .AddMemoryStreams(OrleansStreamProviders.DEFAULT_STREAM_PROVIDER)
+ if (hostConfig.ClusteringProvider == "adonet")
+ {
+ silo.UseAdoNetClustering(options =>
+ {
+ options.Invariant = hostConfig.Invariant;
+ options.ConnectionString = databaseConnectionString;
+ });
+ }
+ else
+ {
+ silo.UseLocalhostClustering();
+ }
+
+ // PLAYER_STORE/ROOM_STORE were never wired to a [PersistentState] grain — every
+ // grain in this codebase persists through EF Core instead, so only PubSubStore
+ // (required by the stream providers below) is actually used.
+ if (hostConfig.GrainStorageProvider == "adonet")
+ {
+ silo.AddAdoNetGrainStorage(
+ OrleansStorageNames.PUB_SUB_STORE,
+ options =>
+ {
+ options.Invariant = hostConfig.Invariant;
+ options.ConnectionString = databaseConnectionString;
+ }
+ );
+ }
+ else
+ {
+ silo.AddMemoryGrainStorage(OrleansStorageNames.PUB_SUB_STORE);
+ }
+
+ silo.AddMemoryStreams(OrleansStreamProviders.DEFAULT_STREAM_PROVIDER)
.AddMemoryStreams(OrleansStreamProviders.ROOM_STREAM_PROVIDER);
}
)
diff --git a/Vortex.Main/Program.cs b/Vortex.Main/Program.cs
index 5d8b7cf4..b3e3865a 100644
--- a/Vortex.Main/Program.cs
+++ b/Vortex.Main/Program.cs
@@ -88,7 +88,7 @@ IConfigurationProvider p in ((IConfigurationRoot)builder.Configuration).Provider
IFileInfo? fi = fileProvider?.GetFileInfo(path);
string physical = fi?.PhysicalPath ?? "";
- bootstrapLogger.LogInformation($"Json: '{path}' -> {physical}");
+ bootstrapLogger.LogInformation("Json: '{Path}' -> {Physical}", path, physical);
}
}
}
@@ -102,7 +102,7 @@ IConfigurationProvider p in ((IConfigurationRoot)builder.Configuration).Provider
builder.Services.AddVortexPlugins(builder);
builder.Services.AddVortexDatabaseContext(builder);
builder.Services.AddVortexEventSystem();
- builder.Services.AddVortexMessageSystem();
+ builder.Services.AddVortexMessageSystem(builder);
builder.Services.AddVortexCrypto(builder);
builder.Services.AddVortexRevisions(builder);
diff --git a/Vortex.Main/Vortex.Main.csproj b/Vortex.Main/Vortex.Main.csproj
index e5bab0e2..2c967fdf 100644
--- a/Vortex.Main/Vortex.Main.csproj
+++ b/Vortex.Main/Vortex.Main.csproj
@@ -15,6 +15,9 @@
+
+
+
diff --git a/Vortex.Main/VortexEmulator.cs b/Vortex.Main/VortexEmulator.cs
index 18ea72a4..b42e8e89 100644
--- a/Vortex.Main/VortexEmulator.cs
+++ b/Vortex.Main/VortexEmulator.cs
@@ -1,85 +1,39 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
-using Vortex.Primitives.Catalog.Providers;
-using Vortex.Primitives.Catalog.Tags;
-using Vortex.Primitives.Furniture.Providers;
-using Vortex.Primitives.Groups.Providers;
-using Vortex.Primitives.Marketplace.Providers;
-using Vortex.Primitives.Navigator;
+using Vortex.Primitives.Hosting;
using Vortex.Primitives.Networking;
-using Vortex.Primitives.Networking.Revisions;
-using Vortex.Primitives.Pets.Providers;
-using Vortex.Primitives.Players.Providers;
-using Vortex.Primitives.Rooms.Providers;
-using Vortex.Revisions.Revision20260701;
namespace Vortex.Main;
public class VortexEmulator(
ILogger logger,
- IFurnitureDefinitionProvider furnitureProvider,
- ICatalogSnapshotProvider catalogProvider,
- ICatalogSnapshotProvider buildersClubCatalogProvider,
- ICatalogClubOfferProvider clubOfferProvider,
- ICatalogClubGiftProvider clubGiftProvider,
- ICurrencyTypeProvider currencyTypeProvider,
- IGroupBadgePartProvider guildBadgePartProvider,
- IMarketplaceSettingsProvider marketplaceSettingsProvider,
- IPetPaletteProvider petPaletteProvider,
- IPetCommandProvider petCommandProvider,
- IPetLevelProvider petLevelProvider,
- IPetVocalProvider petVocalProvider,
- INavigatorProvider topLevelContextProvider,
- IRoomModelProvider roomModelProvider,
- INetworkManager networkManager,
- IRevisionManager revisionManager,
- Revision20260701 defaultRevision
+ IEnumerable referenceDataProviders,
+ INetworkManager networkManager
) : IHostedService
{
- private readonly ICatalogSnapshotProvider _buildersClubCatalogProvider =
- buildersClubCatalogProvider;
-
- private readonly ICatalogSnapshotProvider _catalogProvider = catalogProvider;
- private readonly ICatalogClubGiftProvider _clubGiftProvider = clubGiftProvider;
- private readonly ICatalogClubOfferProvider _clubOfferProvider = clubOfferProvider;
- private readonly ICurrencyTypeProvider _currencyTypeProvider = currencyTypeProvider;
- private readonly Revision20260701 _defaultRevision = defaultRevision;
- private readonly IFurnitureDefinitionProvider _furnitureProvider = furnitureProvider;
- private readonly IGroupBadgePartProvider _guildBadgePartProvider = guildBadgePartProvider;
private readonly ILogger _logger = logger;
- private readonly IMarketplaceSettingsProvider _marketplaceSettingsProvider =
- marketplaceSettingsProvider;
+ private readonly IEnumerable _referenceDataProviders =
+ referenceDataProviders;
private readonly INetworkManager _networkManager = networkManager;
- private readonly IPetCommandProvider _petCommandProvider = petCommandProvider;
- private readonly IPetLevelProvider _petLevelProvider = petLevelProvider;
- private readonly IPetVocalProvider _petVocalProvider = petVocalProvider;
- private readonly IPetPaletteProvider _petPaletteProvider = petPaletteProvider;
- private readonly IRevisionManager _revisionManager = revisionManager;
- private readonly IRoomModelProvider _roomModelProvider = roomModelProvider;
- private readonly INavigatorProvider _topLevelContextProvider = topLevelContextProvider;
public async Task StartAsync(CancellationToken ct)
{
try
{
- _revisionManager.RegisterRevision(_defaultRevision);
- await _furnitureProvider.ReloadAsync(ct).ConfigureAwait(false);
- await _catalogProvider.ReloadAsync(ct).ConfigureAwait(false);
- await _buildersClubCatalogProvider.ReloadAsync(ct).ConfigureAwait(false);
- await _clubOfferProvider.ReloadAsync(ct).ConfigureAwait(false);
- await _clubGiftProvider.ReloadAsync(ct).ConfigureAwait(false);
- await _currencyTypeProvider.ReloadAsync(ct).ConfigureAwait(false);
- await _marketplaceSettingsProvider.ReloadAsync(ct).ConfigureAwait(false);
- await _guildBadgePartProvider.ReloadAsync(ct).ConfigureAwait(false);
- await _petPaletteProvider.ReloadAsync(ct).ConfigureAwait(false);
- await _petCommandProvider.ReloadAsync(ct).ConfigureAwait(false);
- await _petLevelProvider.ReloadAsync(ct).ConfigureAwait(false);
- await _petVocalProvider.ReloadAsync(ct).ConfigureAwait(false);
- await _topLevelContextProvider.ReloadAsync(ct).ConfigureAwait(false);
- await _roomModelProvider.ReloadAsync(ct).ConfigureAwait(false);
+ IEnumerable> stages = _referenceDataProviders
+ .GroupBy(p => p.LoadStage)
+ .OrderBy(stage => stage.Key);
+
+ foreach (IGrouping stage in stages)
+ {
+ await Task.WhenAll(stage.Select(p => p.ReloadAsync(ct))).ConfigureAwait(false);
+ }
+
await _networkManager.StartAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
diff --git a/Vortex.Marketplace/MarketplaceModule.cs b/Vortex.Marketplace/MarketplaceModule.cs
index 9c9f1c36..42d2df2e 100644
--- a/Vortex.Marketplace/MarketplaceModule.cs
+++ b/Vortex.Marketplace/MarketplaceModule.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Vortex.Marketplace.Providers;
+using Vortex.Primitives.Hosting;
using Vortex.Primitives.Marketplace.Providers;
using Vortex.Primitives.Plugins;
@@ -13,5 +14,8 @@ public sealed class MarketplaceModule : IHostPluginModule
public void ConfigureServices(IServiceCollection services, HostApplicationBuilder builder)
{
services.AddSingleton();
+ services.AddSingleton(sp =>
+ (IReferenceDataProvider)sp.GetRequiredService()
+ );
}
}
diff --git a/Vortex.Marketplace/Providers/MarketplaceSettingsProvider.cs b/Vortex.Marketplace/Providers/MarketplaceSettingsProvider.cs
index 6ab3f82c..19f886da 100644
--- a/Vortex.Marketplace/Providers/MarketplaceSettingsProvider.cs
+++ b/Vortex.Marketplace/Providers/MarketplaceSettingsProvider.cs
@@ -5,6 +5,7 @@
using Microsoft.Extensions.Logging;
using Vortex.Database.Context;
using Vortex.Database.Entities.Marketplace;
+using Vortex.Primitives.Hosting;
using Vortex.Primitives.Marketplace.Providers;
using Vortex.Primitives.Marketplace.Snapshots;
@@ -13,7 +14,7 @@ namespace Vortex.Marketplace.Providers;
public sealed class MarketplaceSettingsProvider(
IDbContextFactory dbCtxFactory,
ILogger logger
-) : IMarketplaceSettingsProvider
+) : IMarketplaceSettingsProvider, IReferenceDataProvider
{
private static readonly MarketplaceSettingsSnapshot Defaults = new()
{
@@ -21,6 +22,8 @@ ILogger logger
OfferDurationSeconds = 259200,
};
+ public int LoadStage => 0;
+
private readonly IDbContextFactory _dbCtxFactory = dbCtxFactory;
private readonly ILogger _logger = logger;
diff --git a/Vortex.Messages/Behaviors/RateLimitBehavior.cs b/Vortex.Messages/Behaviors/RateLimitBehavior.cs
new file mode 100644
index 00000000..478ea6fb
--- /dev/null
+++ b/Vortex.Messages/Behaviors/RateLimitBehavior.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Vortex.Messages.RateLimit;
+using Vortex.Messages.Registry;
+using Vortex.Pipeline.Attributes;
+using Vortex.Primitives.Networking;
+using Vortex.Primitives.Observability;
+
+namespace Vortex.Messages.Behaviors;
+
+///
+/// Global per-session token-bucket gate on every inbound message (SEC-03). Registered for
+/// itself, so EnableInheritanceDispatch applies it to every
+/// concrete message type without listing them; ordered to run before any other behavior or handler
+/// so a rate-limited packet never reaches business logic (or the extra grain call that resolving
+/// its context would otherwise cost).
+///
+[Order(int.MinValue)]
+public sealed class RateLimitBehavior(
+ IRateLimiter limiter,
+ IVortexMetrics metrics,
+ ILogger logger
+) : IMessageBehavior
+{
+ public ValueTask InvokeAsync(
+ IMessageEvent env,
+ MessageContext ctx,
+ Func next,
+ CancellationToken ct
+ )
+ {
+ if (limiter.TryConsume(ctx.SessionKey))
+ {
+ return next();
+ }
+
+ metrics.PacketDropped("rate_limited");
+
+ logger.LogDebug(
+ "Rate limit exceeded for session {SessionKey}; dropping {MessageType}.",
+ ctx.SessionKey,
+ env.GetType().Name
+ );
+
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/Vortex.Messages/Configuration/RateLimitConfig.cs b/Vortex.Messages/Configuration/RateLimitConfig.cs
new file mode 100644
index 00000000..604c4f1d
--- /dev/null
+++ b/Vortex.Messages/Configuration/RateLimitConfig.cs
@@ -0,0 +1,21 @@
+namespace Vortex.Messages.Configuration;
+
+///
+/// Per-session token-bucket limit on inbound packets, enforced by RateLimitBehavior before
+/// any handler runs. Generous by default: this exists to close the cheapest DoS vector (a client
+/// looping packets at wire speed), not to police normal play.
+///
+public sealed class RateLimitConfig
+{
+ public const string SECTION_NAME = "Vortex:Networking:RateLimit";
+
+ /// Sustained packets/second a session may send once its burst allowance is spent.
+ public int MaxPacketsPerSecond { get; init; } = 50;
+
+ ///
+ /// Bucket capacity - how many packets a session may send in a single instant before the
+ /// per-second refill rate starts gating it. Defaults to twice the sustained rate so a normal
+ /// burst (e.g. bootstrap composers, a quick flurry of moves) is never affected.
+ ///
+ public int BurstSize { get; init; } = 100;
+}
diff --git a/Vortex.Messages/Extensions/ServiceCollectionExtensions.cs b/Vortex.Messages/Extensions/ServiceCollectionExtensions.cs
index 428fc37f..b6faea18 100644
--- a/Vortex.Messages/Extensions/ServiceCollectionExtensions.cs
+++ b/Vortex.Messages/Extensions/ServiceCollectionExtensions.cs
@@ -1,4 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Vortex.Messages.Configuration;
+using Vortex.Messages.RateLimit;
using Vortex.Messages.Registry;
using Vortex.Pipeline;
using Vortex.Runtime.AssemblyProcessing;
@@ -7,13 +10,22 @@ namespace Vortex.Messages.Extensions;
public static class ServiceCollectionExtensions
{
- public static IServiceCollection AddVortexMessageSystem(this IServiceCollection services)
+ public static IServiceCollection AddVortexMessageSystem(
+ this IServiceCollection services,
+ HostApplicationBuilder builder
+ )
{
services.AddSingleton();
services.AddSingleton>();
services.AddSingleton();
services.AddSingleton();
+ services.Configure(
+ builder.Configuration.GetSection(RateLimitConfig.SECTION_NAME)
+ );
+ services.AddSingleton();
+ services.AddHostedService();
+
return services;
}
}
diff --git a/Vortex.Messages/MessageBehaviorRegistrationService.cs b/Vortex.Messages/MessageBehaviorRegistrationService.cs
new file mode 100644
index 00000000..3329f8d0
--- /dev/null
+++ b/Vortex.Messages/MessageBehaviorRegistrationService.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Hosting;
+using Vortex.Runtime.AssemblyProcessing;
+
+namespace Vortex.Messages;
+
+///
+/// Scans this assembly's own IMessageBehavior<>/IMessageHandler<>
+/// implementations (currently just ). Every other handler
+/// lives in a plugin module assembly, which PluginBootstrapper scans automatically -
+/// Vortex.Messages itself isn't a plugin module, so it needs this one small nudge. Registered
+/// alongside PluginBootstrapper, before VortexEmulator starts the network listeners.
+///
+public sealed class MessageBehaviorRegistrationService(
+ AssemblyProcessor processor,
+ IServiceProvider serviceProvider
+) : IHostedService
+{
+ private IDisposable? _registration;
+
+ public async Task StartAsync(CancellationToken ct)
+ {
+ _registration = await processor
+ .ProcessAsync(Assembly.GetExecutingAssembly(), serviceProvider, ct)
+ .ConfigureAwait(false);
+ }
+
+ public Task StopAsync(CancellationToken ct)
+ {
+ _registration?.Dispose();
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/Vortex.Messages/RateLimit/IRateLimiter.cs b/Vortex.Messages/RateLimit/IRateLimiter.cs
new file mode 100644
index 00000000..794cc720
--- /dev/null
+++ b/Vortex.Messages/RateLimit/IRateLimiter.cs
@@ -0,0 +1,10 @@
+using Vortex.Primitives.Networking;
+
+namespace Vortex.Messages.RateLimit;
+
+public interface IRateLimiter
+{
+ /// Attempts to consume one token for . Returns false when the
+ /// session's bucket is empty and the packet should be dropped instead of dispatched.
+ bool TryConsume(SessionKey session);
+}
diff --git a/Vortex.Messages/RateLimit/TokenBucketRateLimiter.cs b/Vortex.Messages/RateLimit/TokenBucketRateLimiter.cs
new file mode 100644
index 00000000..7379e9a8
--- /dev/null
+++ b/Vortex.Messages/RateLimit/TokenBucketRateLimiter.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Threading;
+using Microsoft.Extensions.Options;
+using Vortex.Messages.Configuration;
+using Vortex.Primitives.Networking;
+
+namespace Vortex.Messages.RateLimit;
+
+///
+/// One token bucket per session, refilled continuously at
+/// up to . Registered as a singleton: the buckets must
+/// outlive any single packet's dispatch, since RateLimitBehavior itself is constructed fresh
+/// per invocation by the pipeline.
+///
+public sealed class TokenBucketRateLimiter : IRateLimiter
+{
+ // Swept every this-many calls rather than on a timer, so an idle process spends nothing.
+ private const int SweepEveryNCalls = 4096;
+ private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(5);
+
+ private readonly ConcurrentDictionary _buckets = new();
+ private readonly double _ratePerSecond;
+ private readonly double _capacity;
+ private long _callCounter;
+
+ public TokenBucketRateLimiter(IOptions options)
+ {
+ RateLimitConfig config = options.Value;
+ _ratePerSecond = Math.Max(1, config.MaxPacketsPerSecond);
+ _capacity = Math.Max(_ratePerSecond, config.BurstSize);
+ }
+
+ public bool TryConsume(SessionKey session)
+ {
+ Bucket bucket = _buckets.GetOrAdd(session, _ => new Bucket(_capacity));
+
+ bool allowed;
+
+ lock (bucket)
+ {
+ long now = Stopwatch.GetTimestamp();
+ double elapsedSeconds = Stopwatch.GetElapsedTime(bucket.LastRefillTimestamp, now)
+ .TotalSeconds;
+
+ if (elapsedSeconds > 0)
+ {
+ bucket.Tokens = Math.Min(_capacity, bucket.Tokens + (elapsedSeconds * _ratePerSecond));
+ bucket.LastRefillTimestamp = now;
+ }
+
+ bucket.LastTouchedUtc = DateTime.UtcNow;
+
+ if (bucket.Tokens >= 1)
+ {
+ bucket.Tokens -= 1;
+ allowed = true;
+ }
+ else
+ {
+ allowed = false;
+ }
+ }
+
+ if (Interlocked.Increment(ref _callCounter) % SweepEveryNCalls == 0)
+ {
+ SweepStaleBuckets();
+ }
+
+ return allowed;
+ }
+
+ private void SweepStaleBuckets()
+ {
+ DateTime cutoff = DateTime.UtcNow - StaleAfter;
+
+ foreach ((SessionKey key, Bucket bucket) in _buckets)
+ {
+ bool stale;
+
+ lock (bucket)
+ {
+ stale = bucket.LastTouchedUtc < cutoff;
+ }
+
+ if (stale)
+ {
+ _buckets.TryRemove(key, out _);
+ }
+ }
+ }
+
+ private sealed class Bucket(double initialTokens)
+ {
+ public double Tokens = initialTokens;
+ public long LastRefillTimestamp = Stopwatch.GetTimestamp();
+ public DateTime LastTouchedUtc = DateTime.UtcNow;
+ }
+}
diff --git a/Vortex.Messages/Registry/MessageRegistry.cs b/Vortex.Messages/Registry/MessageRegistry.cs
index 73b77cb1..a592a3e3 100644
--- a/Vortex.Messages/Registry/MessageRegistry.cs
+++ b/Vortex.Messages/Registry/MessageRegistry.cs
@@ -1,16 +1,14 @@
using System;
+using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
-using Orleans;
using Vortex.Logging;
using Vortex.Pipeline;
using Vortex.Primitives;
using Vortex.Primitives.Networking;
using Vortex.Primitives.Observability;
using Vortex.Primitives.Orleans;
-using Vortex.Primitives.Orleans.Snapshots.Room;
using Vortex.Primitives.Players;
-using Vortex.Primitives.Players.Grains;
namespace Vortex.Messages.Registry;
@@ -38,32 +36,24 @@ ILogger logger
{
return new EnvelopeHostOptions
{
- CreateContextAsync = async (env, data) =>
+ CreateContextAsync = (env, data) =>
{
if (data is null)
{
throw new VortexException(VortexErrorCodeEnum.InvalidSession);
}
- IGrainFactory grainFactory = serviceProvider.GetRequiredService();
ISessionGateway sessionGateway =
serviceProvider.GetRequiredService();
PlayerId playerId = sessionGateway.GetPlayerId(data.SessionKey);
- int roomId = -1;
- if (playerId > 0)
- {
- IPlayerPresenceGrain playerPresence = grainFactory.GetPlayerPresenceGrain(
- playerId
- );
- RoomPointerSnapshot activeRoom = await playerPresence
- .GetActiveRoomAsync()
- .ConfigureAwait(false);
-
- roomId = activeRoom.RoomId;
- }
+ // MessageSystem.PublishAsync already resolved the active room for this exact packet
+ // (for tracing/metrics) and opened the ambient scope carrying it before invoking this
+ // pipeline, so this reuses that value instead of a second GetActiveRoomAsync grain
+ // round trip per packet (PERF-01).
+ int roomId = playerId > 0 ? (contextAccessor.Current?.RoomId ?? -1) : -1;
- return new(data, playerId, roomId);
+ return Task.FromResult(new MessageContext(data, playerId, roomId));
},
EnableInheritanceDispatch = true,
HandlerMode = HandlerExecutionMode.Parallel,
diff --git a/Vortex.Navigator/NavigatorModule.cs b/Vortex.Navigator/NavigatorModule.cs
index 70982c93..ec75a258 100644
--- a/Vortex.Navigator/NavigatorModule.cs
+++ b/Vortex.Navigator/NavigatorModule.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+using Vortex.Primitives.Hosting;
using Vortex.Primitives.Navigator;
using Vortex.Primitives.Plugins;
@@ -13,5 +14,8 @@ public void ConfigureServices(IServiceCollection services, HostApplicationBuilde
{
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton(sp =>
+ (IReferenceDataProvider)sp.GetRequiredService()
+ );
}
}
diff --git a/Vortex.Navigator/NavigatorProvider.cs b/Vortex.Navigator/NavigatorProvider.cs
index 089e01b7..b085a56c 100644
--- a/Vortex.Navigator/NavigatorProvider.cs
+++ b/Vortex.Navigator/NavigatorProvider.cs
@@ -11,6 +11,7 @@
using Vortex.Database.Entities.Room;
using Vortex.Primitives.Navigator;
using Vortex.Primitives.Navigator.Enums;
+using Vortex.Primitives.Hosting;
using Vortex.Primitives.Orleans.Snapshots.Navigator;
using Vortex.Primitives.Orleans.Snapshots.Room;
using Vortex.Primitives.Players;
@@ -20,8 +21,10 @@ namespace Vortex.Navigator;
public sealed class NavigatorProvider(
IDbContextFactory dbCtxFactory,
ILogger logger
-) : INavigatorProvider
+) : INavigatorProvider, IReferenceDataProvider
{
+ public int LoadStage => 0;
+
private readonly IDbContextFactory _dbCtxFactory = dbCtxFactory;
private readonly ILogger _logger = logger;
diff --git a/Vortex.Networking/NetworkManager.cs b/Vortex.Networking/NetworkManager.cs
index db49da55..3aede1ad 100644
--- a/Vortex.Networking/NetworkManager.cs
+++ b/Vortex.Networking/NetworkManager.cs
@@ -22,6 +22,7 @@
using Vortex.Primitives.Messages.Outgoing.Handshake;
using Vortex.Primitives.Networking;
using Vortex.Primitives.Networking.Revisions;
+using Vortex.Primitives.Observability;
using Vortex.Primitives.Packets;
namespace Vortex.Networking;
@@ -32,7 +33,10 @@ public sealed class NetworkManager(
IRevisionManager revisionManager,
MessageSystem messageSystem,
ILoggerFactory loggerFactory,
- IGrainFactory grainFactory
+ IGrainFactory grainFactory,
+ IVortexContextAccessor contextAccessor,
+ IErrorGroupingSink errorSink,
+ IVortexMetrics metrics
) : INetworkManager
{
private readonly NetworkingConfig _config = config.Value;
@@ -40,9 +44,13 @@ IGrainFactory grainFactory
private readonly ILogger _logger = loggerFactory.CreateLogger();
private readonly ILoggerFactory _loggerFactory = loggerFactory;
private readonly MessageSystem _messageSystem = messageSystem;
+ private readonly IVortexContextAccessor _contextAccessor = contextAccessor;
+ private readonly IErrorGroupingSink _errorSink = errorSink;
+ private readonly IVortexMetrics _metrics = metrics;
private readonly IPackageEncoder _packageEncoder = new PackageEncoder(
revisionManager,
- loggerFactory.CreateLogger()
+ loggerFactory.CreateLogger(),
+ metrics
);
private readonly IRevisionManager _revisionManager = revisionManager;
private readonly ISessionGateway _sessionGateway = sessionGateway;
@@ -123,7 +131,6 @@ private void CreateTcpSocket()
builder.UseSession();
builder.UsePipelineFilter();
builder.UseSessionGateway();
- //builder.UsePingPong();
_tcpHost = builder.Build();
}
@@ -140,7 +147,9 @@ private void CreateWsSocket()
PackageHandler packageHandler = new(
_revisionManager,
_messageSystem,
- _loggerFactory.CreateLogger()
+ _loggerFactory.CreateLogger(),
+ _contextAccessor,
+ _errorSink
);
WsPackageHandler wsPackageHandler = new(
decoder,
@@ -302,6 +311,13 @@ private void ConfigureCommonServices(IServiceCollection services)
services.AddSingleton(_messageSystem);
services.AddSingleton(_loggerFactory);
services.AddSingleton(_grainFactory);
+ // Forwarded from the parent container so PackageHandler/PackageEncoder resolve these as
+ // required dependencies in the TCP host's own child container too, instead of silently
+ // getting null (see NET-01): a missing observability sink must be a startup error, not an
+ // invisible gap in the dashboard's error grouping.
+ services.AddSingleton(_contextAccessor);
+ services.AddSingleton(_errorSink);
+ services.AddSingleton(_metrics);
services.AddSingleton, PackageEncoder>();
services.AddSingleton, PackageHandler>();
services.AddSingleton(_ => new ClientPacketDecoder(
diff --git a/Vortex.Networking/Package/PackageEncoder.cs b/Vortex.Networking/Package/PackageEncoder.cs
index 1727a7c2..64b2cc71 100644
--- a/Vortex.Networking/Package/PackageEncoder.cs
+++ b/Vortex.Networking/Package/PackageEncoder.cs
@@ -4,15 +4,20 @@
using SuperSocket.ProtoBase;
using Vortex.Primitives.Networking;
using Vortex.Primitives.Networking.Revisions;
+using Vortex.Primitives.Observability;
using Vortex.Primitives.Packets;
namespace Vortex.Networking.Package;
-public sealed class PackageEncoder(IRevisionManager revisionManager, ILogger logger)
- : IPackageEncoder
+public sealed class PackageEncoder(
+ IRevisionManager revisionManager,
+ ILogger logger,
+ IVortexMetrics metrics
+) : IPackageEncoder
{
private readonly IRevisionManager _revisionManager = revisionManager;
private readonly ILogger _logger = logger;
+ private readonly IVortexMetrics _metrics = metrics;
public int Encode(IBufferWriter writer, OutgoingPackage pack)
{
@@ -46,8 +51,16 @@ public int Encode(IBufferWriter writer, OutgoingPackage pack)
composerType.Name,
pack.Session.SessionKey
);
+
+ // The packet silently vanishes here: SuperSocket writes nothing when Encode
+ // returns 0, so this counter is the only trace that it ever happened.
+ _metrics.PacketDropped("serializer_not_found");
}
}
+ else
+ {
+ _metrics.PacketDropped("revision_not_found");
+ }
}
catch (Exception ex)
{
@@ -57,6 +70,8 @@ public int Encode(IBufferWriter writer, OutgoingPackage pack)
pack.Composer.GetType().Name,
pack.Session.SessionKey
);
+
+ _metrics.PacketDropped("serialize_exception");
}
return 0;
diff --git a/Vortex.Networking/Package/PackageHandler.cs b/Vortex.Networking/Package/PackageHandler.cs
index 98d1c1f1..d12c342e 100644
--- a/Vortex.Networking/Package/PackageHandler.cs
+++ b/Vortex.Networking/Package/PackageHandler.cs
@@ -16,12 +16,12 @@ public sealed class PackageHandler(
IRevisionManager revisionManager,
MessageSystem messageSystem,
ILogger logger,
- IVortexContextAccessor? contextAccessor = null,
- IErrorGroupingSink? errorSink = null
+ IVortexContextAccessor contextAccessor,
+ IErrorGroupingSink errorSink
) : IPackageHandler
{
- private readonly IVortexContextAccessor? _contextAccessor = contextAccessor;
- private readonly IErrorGroupingSink? _errorSink = errorSink;
+ private readonly IVortexContextAccessor _contextAccessor = contextAccessor;
+ private readonly IErrorGroupingSink _errorSink = errorSink;
private readonly ILogger _logger = logger;
private readonly MessageSystem _messageSystem = messageSystem;
private readonly IRevisionManager _revisionManager = revisionManager;
@@ -51,7 +51,9 @@ CancellationToken ct
{
IRevision revision =
_revisionManager.GetRevision(ctx.RevisionId)
- ?? throw new ArgumentNullException("No revision set");
+ ?? throw new InvalidOperationException(
+ $"No revision registered for revision id '{ctx.RevisionId}'."
+ );
if (revision.Parsers.TryGetValue(packet.Header, out IParser? parser))
{
@@ -79,21 +81,18 @@ CancellationToken ct
ctx.SessionKey
);
- IVortexContext? context = _contextAccessor?.Current;
+ IVortexContext? context = _contextAccessor.Current;
- if (_errorSink is not null)
- {
- _errorSink.Record(
- ex,
- "package-handler",
- $"packet:{packet.Header}",
- context?.PlayerId,
- context?.RoomId,
- context?.CorrelationId.Value,
- context?.SessionKey,
- ctx.RemoteIpAddress
- );
- }
+ _errorSink.Record(
+ ex,
+ "package-handler",
+ $"packet:{packet.Header}",
+ context?.PlayerId,
+ context?.RoomId,
+ context?.CorrelationId.Value,
+ context?.SessionKey,
+ ctx.RemoteIpAddress
+ );
}
}
}
diff --git a/Vortex.Networking/Revisions/RevisionManager.cs b/Vortex.Networking/Revisions/RevisionManager.cs
index 4077a03a..345a71fe 100644
--- a/Vortex.Networking/Revisions/RevisionManager.cs
+++ b/Vortex.Networking/Revisions/RevisionManager.cs
@@ -1,3 +1,5 @@
+using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using Vortex.Primitives.Networking.Revisions;
@@ -8,7 +10,9 @@ public sealed class RevisionManager(ILogger logger) : IRevision
{
private readonly ILogger _logger = logger;
- public IDictionary Revisions { get; } = new Dictionary();
+ public IDictionary Revisions { get; } =
+ new ConcurrentDictionary();
+
public string DefaultRevisionId { get; private set; } = string.Empty;
public IRevision? GetRevision(string revisionId) =>
@@ -16,10 +20,7 @@ public sealed class RevisionManager(ILogger logger) : IRevision
public void RegisterRevision(IRevision revision)
{
- if (revision is null)
- {
- return;
- }
+ ArgumentNullException.ThrowIfNull(revision);
_logger.LogInformation("Revision Registered: {Revision}", revision.Revision);
@@ -30,4 +31,18 @@ public void RegisterRevision(IRevision revision)
DefaultRevisionId = revision.Revision;
}
}
+
+ public void SetDefault(string revisionId)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(revisionId);
+
+ if (!Revisions.ContainsKey(revisionId))
+ {
+ throw new InvalidOperationException(
+ $"Cannot set '{revisionId}' as the default revision: no revision with that id is registered."
+ );
+ }
+
+ DefaultRevisionId = revisionId;
+ }
}
diff --git a/Vortex.Observability/Metrics/VortexMetrics.cs b/Vortex.Observability/Metrics/VortexMetrics.cs
index 019179c4..5b84037d 100644
--- a/Vortex.Observability/Metrics/VortexMetrics.cs
+++ b/Vortex.Observability/Metrics/VortexMetrics.cs
@@ -22,6 +22,7 @@ public sealed class VortexMetrics : IVortexMetrics, IDisposable
private readonly Counter _packetReceived;
private readonly Histogram _packetDuration;
private readonly Counter _packetFailed;
+ private readonly Counter _packetDropped;
private readonly ILiveStatsAggregator _liveStats;
public VortexMetrics(
@@ -49,6 +50,11 @@ IOptions options
unit: "{packet}",
description: "Packets whose dispatch threw an exception."
);
+ _packetDropped = _meter.CreateCounter(
+ "Vortex.packet.dropped",
+ unit: "{packet}",
+ description: "Outgoing packets that could not be encoded and were never sent."
+ );
}
public void PacketReceived(string operation, long? actorId = null, int? roomId = null)
@@ -86,6 +92,14 @@ public void PacketFailed(string operation, long? actorId = null, int? roomId = n
_liveStats.RecordPacketFailed(operation, actorId, roomId);
}
+ public void PacketDropped(string reason)
+ {
+ if (_enabled)
+ {
+ _packetDropped.Add(1, new KeyValuePair("reason", reason));
+ }
+ }
+
private static KeyValuePair Tag(string operation) =>
new("operation", operation);
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/OpenMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/OpenMessageHandler.cs
index 55af7b56..81754600 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/OpenMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/OpenMessageHandler.cs
@@ -66,6 +66,6 @@ CancellationToken ct
return;
}
- _ = ctx.SendComposerAsync(composer, ct).ConfigureAwait(false);
+ await ctx.SendComposerAsync(composer, ct).ConfigureAwait(false);
}
}
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateActionMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateActionMessageHandler.cs
index 7b16a6c4..99148369 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateActionMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateActionMessageHandler.cs
@@ -34,7 +34,7 @@ CancellationToken ct
return;
}
- _ = ctx.SendComposerAsync(new WiredSaveSuccessEventMessageComposer(), ct)
+ await ctx.SendComposerAsync(new WiredSaveSuccessEventMessageComposer(), ct)
.ConfigureAwait(false);
}
}
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateAddonMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateAddonMessageHandler.cs
index 580ee9f3..72d402b6 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateAddonMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateAddonMessageHandler.cs
@@ -34,7 +34,7 @@ CancellationToken ct
return;
}
- _ = ctx.SendComposerAsync(new WiredSaveSuccessEventMessageComposer(), ct)
+ await ctx.SendComposerAsync(new WiredSaveSuccessEventMessageComposer(), ct)
.ConfigureAwait(false);
}
}
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateConditionMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateConditionMessageHandler.cs
index 75124ec4..3b89715b 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateConditionMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateConditionMessageHandler.cs
@@ -34,7 +34,7 @@ CancellationToken ct
return;
}
- _ = ctx.SendComposerAsync(new WiredSaveSuccessEventMessageComposer(), ct)
+ await ctx.SendComposerAsync(new WiredSaveSuccessEventMessageComposer(), ct)
.ConfigureAwait(false);
}
}
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateSelectorMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateSelectorMessageHandler.cs
index 6b6cd5a5..00db7186 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateSelectorMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateSelectorMessageHandler.cs
@@ -34,7 +34,7 @@ CancellationToken ct
return;
}
- _ = ctx.SendComposerAsync(new WiredSaveSuccessEventMessageComposer(), ct)
+ await ctx.SendComposerAsync(new WiredSaveSuccessEventMessageComposer(), ct)
.ConfigureAwait(false);
}
}
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateTriggerMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateTriggerMessageHandler.cs
index 2734bcb6..dff2f865 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateTriggerMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateTriggerMessageHandler.cs
@@ -31,7 +31,7 @@ CancellationToken ct
.ConfigureAwait(false)
)
{
- _ = ctx.SendComposerAsync(
+ await ctx.SendComposerAsync(
new WiredValidationErrorEventMessageComposer
{
LocalizationKey = "wired.validation.error",
@@ -44,7 +44,7 @@ CancellationToken ct
return;
}
- _ = ctx.SendComposerAsync(new WiredSaveSuccessEventMessageComposer(), ct)
+ await ctx.SendComposerAsync(new WiredSaveSuccessEventMessageComposer(), ct)
.ConfigureAwait(false);
}
}
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateVariableMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateVariableMessageHandler.cs
index d9f83c87..ab4ede9a 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateVariableMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/UpdateVariableMessageHandler.cs
@@ -34,7 +34,7 @@ CancellationToken ct
return;
}
- _ = ctx.SendComposerAsync(new WiredSaveSuccessEventMessageComposer(), ct)
+ await ctx.SendComposerAsync(new WiredSaveSuccessEventMessageComposer(), ct)
.ConfigureAwait(false);
}
}
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredClearErrorLogsMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredClearErrorLogsMessageHandler.cs
index 3832ba5e..574e83a1 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredClearErrorLogsMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredClearErrorLogsMessageHandler.cs
@@ -33,7 +33,7 @@ CancellationToken ct
List entries = await room.GetWiredErrorLogsAsync(ct)
.ConfigureAwait(false);
- _ = ctx.SendComposerAsync(
+ await ctx.SendComposerAsync(
new WiredErrorLogsEventMessageComposer() { Entries = entries },
ct
)
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetAllVariableHoldersMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetAllVariableHoldersMessageHandler.cs
index 4de145b3..9d477e57 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetAllVariableHoldersMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetAllVariableHoldersMessageHandler.cs
@@ -42,7 +42,7 @@ await _grainFactory
(new RoomObjectId(h.ObjectId), h.Value)
);
- _ = ctx.SendComposerAsync(
+ await ctx.SendComposerAsync(
new WiredAllVariableHoldersEventMessageComposer
{
VariableSnapshot = result.Value.Variable,
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetAllVariablesDiffsMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetAllVariablesDiffsMessageHandler.cs
index d3adec19..fde70322 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetAllVariablesDiffsMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetAllVariablesDiffsMessageHandler.cs
@@ -68,7 +68,7 @@ CancellationToken ct
diffs.Add(variable);
}
- _ = ctx.SendComposerAsync(
+ await ctx.SendComposerAsync(
new WiredAllVariablesDiffsEventMessageComposer()
{
AllVariablesHash = variables.AllVariablesHash,
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetAllVariablesHashMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetAllVariablesHashMessageHandler.cs
index 8ab7667c..4cd3c676 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetAllVariablesHashMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetAllVariablesHashMessageHandler.cs
@@ -30,7 +30,7 @@ CancellationToken ct
.GetWiredVariablesSnapshotAsync(ct)
.ConfigureAwait(false);
- _ = ctx.SendComposerAsync(
+ await ctx.SendComposerAsync(
new WiredAllVariablesHashEventMessageComposer()
{
AllVariablesHash = variables.AllVariablesHash,
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetErrorLogsMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetErrorLogsMessageHandler.cs
index 2b81846a..de6e5fbb 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetErrorLogsMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetErrorLogsMessageHandler.cs
@@ -30,7 +30,7 @@ CancellationToken ct
.GetWiredErrorLogsAsync(ct)
.ConfigureAwait(false);
- _ = ctx.SendComposerAsync(
+ await ctx.SendComposerAsync(
new WiredErrorLogsEventMessageComposer() { Entries = entries },
ct
)
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetRoomLogsMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetRoomLogsMessageHandler.cs
index 745c2938..7fdf5394 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetRoomLogsMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetRoomLogsMessageHandler.cs
@@ -36,6 +36,6 @@ CancellationToken ct
)
.ConfigureAwait(false);
- _ = ctx.SendComposerAsync(page, ct).ConfigureAwait(false);
+ await ctx.SendComposerAsync(page, ct).ConfigureAwait(false);
}
}
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetRoomSettingsMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetRoomSettingsMessageHandler.cs
index 62c94ad8..bb0e8d51 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetRoomSettingsMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetRoomSettingsMessageHandler.cs
@@ -30,6 +30,6 @@ CancellationToken ct
.GetWiredRoomSettingsAsync(new PlayerId(ctx.PlayerId), ct)
.ConfigureAwait(false);
- _ = ctx.SendComposerAsync(settings, ct).ConfigureAwait(false);
+ await ctx.SendComposerAsync(settings, ct).ConfigureAwait(false);
}
}
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetRoomStatsMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetRoomStatsMessageHandler.cs
index 23aabd06..7ce356bd 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetRoomStatsMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetRoomStatsMessageHandler.cs
@@ -29,6 +29,6 @@ CancellationToken ct
.GetWiredRoomStatsAsync(ct)
.ConfigureAwait(false);
- _ = ctx.SendComposerAsync(stats, ct).ConfigureAwait(false);
+ await ctx.SendComposerAsync(stats, ct).ConfigureAwait(false);
}
}
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetUserPermanentVariablesMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetUserPermanentVariablesMessageHandler.cs
index 9981abd9..6ea7ec7e 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetUserPermanentVariablesMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetUserPermanentVariablesMessageHandler.cs
@@ -35,7 +35,7 @@ CancellationToken ct
)
.ConfigureAwait(false);
- _ = ctx.SendComposerAsync(
+ await ctx.SendComposerAsync(
new WiredUserPermanentVariablesComposer() { Snapshot = snapshot },
ct
)
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetVariableOwnersPageMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetVariableOwnersPageMessageHandler.cs
index 9fbafe84..674558f8 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetVariableOwnersPageMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetVariableOwnersPageMessageHandler.cs
@@ -37,7 +37,7 @@ CancellationToken ct
)
.ConfigureAwait(false);
- _ = ctx.SendComposerAsync(new WiredUserVariablesListComposer() { Page = page }, ct)
+ await ctx.SendComposerAsync(new WiredUserVariablesListComposer() { Page = page }, ct)
.ConfigureAwait(false);
}
}
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetVariablesForObjectMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetVariablesForObjectMessageHandler.cs
index 5c474d16..ac40d9c0 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetVariablesForObjectMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredGetVariablesForObjectMessageHandler.cs
@@ -40,7 +40,7 @@ CancellationToken ct
)
.ConfigureAwait(false);
- _ = ctx.SendComposerAsync(
+ await ctx.SendComposerAsync(
new WiredVariablesForObjectEventMessageComposer()
{
TargetType = (WiredVariableTargetType)message.SourceType,
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredSetObjectVariableValueMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredSetObjectVariableValueMessageHandler.cs
index 74ebbfdb..f9f589d3 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredSetObjectVariableValueMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredSetObjectVariableValueMessageHandler.cs
@@ -37,7 +37,7 @@ CancellationToken ct
)
.ConfigureAwait(false);
- _ = ctx.SendComposerAsync(
+ await ctx.SendComposerAsync(
new WiredSetUserPermanentVariableResultComposer() { Success = success },
ct
)
diff --git a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredSetRoomSettingsMessageHandler.cs b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredSetRoomSettingsMessageHandler.cs
index 675577a9..16b6edfd 100644
--- a/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredSetRoomSettingsMessageHandler.cs
+++ b/Vortex.PacketHandlers/UserDefinedRoomEvents/Wiredmenu/WiredSetRoomSettingsMessageHandler.cs
@@ -42,6 +42,6 @@ CancellationToken ct
return;
}
- _ = ctx.SendComposerAsync(settings, ct).ConfigureAwait(false);
+ await ctx.SendComposerAsync(settings, ct).ConfigureAwait(false);
}
}
diff --git a/Vortex.Pipeline.Tests/EnvelopeHostTests.cs b/Vortex.Pipeline.Tests/EnvelopeHostTests.cs
new file mode 100644
index 00000000..5957a7db
--- /dev/null
+++ b/Vortex.Pipeline.Tests/EnvelopeHostTests.cs
@@ -0,0 +1,382 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Vortex.Pipeline.Attributes;
+using Vortex.Pipeline.Delegates;
+using Vortex.Runtime;
+using Xunit;
+
+namespace Vortex.Pipeline.Tests;
+
+///
+/// TEST-01 safety net for the generic dispatch engine in
+/// (shared by Vortex.Messages and Vortex.Events), exercised directly with locally-defined
+/// envelope/context types rather than any real Vortex message.
+///
+public sealed class EnvelopeHostTests
+{
+ private static ServiceProvider NewProvider(Recorder recorder, ConcurrencyTracker? tracker = null)
+ {
+ ServiceCollection services = new();
+ services.AddSingleton(recorder);
+
+ if (tracker is not null)
+ {
+ services.AddSingleton(tracker);
+ }
+
+ return services.BuildServiceProvider();
+ }
+
+ private static EnvelopeHost NewHost(
+ IServiceProvider sp,
+ bool enableInheritance = true,
+ HandlerExecutionMode mode = HandlerExecutionMode.Parallel,
+ int? maxDop = null,
+ Action? onHandlerActivationError = null,
+ Action? onHandlerInvokeError = null,
+ Action? onNoHandlerRegistered = null
+ )
+ {
+ EnvelopeHostOptions options = new()
+ {
+ CreateContextAsync = (env, meta) => Task.FromResult(new TestContext()),
+ EnableInheritanceDispatch = enableInheritance,
+ HandlerMode = mode,
+ MaxHandlerDegreeOfParallelism = maxDop,
+ OnHandlerActivationError = onHandlerActivationError,
+ OnHandlerInvokeError = onHandlerInvokeError,
+ OnNoHandlerRegistered = onNoHandlerRegistered,
+ };
+
+ return new EnvelopeHost(sp, options);
+ }
+
+ private static IDisposable RegisterHandler(
+ EnvelopeHost host,
+ IServiceProvider sp,
+ Type envType
+ )
+ where THandler : class
+ {
+ EnvelopeInvokerFactory factory = new();
+ HandlerInvoker invoker = factory.CreateHandlerInvoker(typeof(THandler), envType);
+ Func activator = ActivatorHelpers.BuildActivator(typeof(THandler));
+
+ return host.RegisterHandler(envType, sp, activator, invoker);
+ }
+
+ private static IDisposable RegisterBehavior(
+ EnvelopeHost host,
+ IServiceProvider sp,
+ Type envType
+ )
+ where TBehavior : class
+ {
+ EnvelopeInvokerFactory factory = new();
+ BehaviorInvoker invoker = factory.CreateBehaviorInvoker(
+ typeof(TBehavior),
+ envType
+ );
+ Func activator = ActivatorHelpers.BuildActivator(typeof(TBehavior));
+ int order = typeof(TBehavior).GetCustomAttribute()?.Value ?? 0;
+
+ return host.RegisterBehavior(envType, sp, activator, invoker, order);
+ }
+
+ [Fact]
+ public async Task RegisterHandler_PublishedEnvelope_IsReceivedByHandler()
+ {
+ Recorder recorder = new();
+ using ServiceProvider sp = NewProvider(recorder);
+ EnvelopeHost host = NewHost(sp);
+ using IDisposable reg = RegisterHandler(host, sp, typeof(TestEnvelope));
+
+ await host
+ .PublishAsync(new TestEnvelope { Payload = "hello" }, null, CancellationToken.None)
+ .ConfigureAwait(true);
+
+ recorder.Entries.Should().Equal("A:hello");
+ }
+
+ [Fact]
+ public void DefaultHandlerMode_IsParallel()
+ {
+ EnvelopeHostOptions options = new()
+ {
+ CreateContextAsync = (env, meta) => Task.FromResult(new TestContext()),
+ };
+
+ options.HandlerMode.Should().Be(HandlerExecutionMode.Parallel);
+ }
+
+ [Fact]
+ public async Task MultipleHandlersForSameEnvelopeType_AllRun_UnderTheDefaultParallelMode()
+ {
+ Recorder recorder = new();
+ using ServiceProvider sp = NewProvider(recorder);
+ EnvelopeHost host = NewHost(sp);
+ using IDisposable regA = RegisterHandler(host, sp, typeof(TestEnvelope));
+ using IDisposable regB = RegisterHandler(host, sp, typeof(TestEnvelope));
+
+ await host
+ .PublishAsync(new TestEnvelope { Payload = "x" }, null, CancellationToken.None)
+ .ConfigureAwait(true);
+
+ recorder.Entries.Should().BeEquivalentTo(["A:x", "B:x"]);
+ }
+
+ [Fact]
+ public async Task SequentialMode_HandlersRunInRegistrationOrder()
+ {
+ Recorder recorder = new();
+ using ServiceProvider sp = NewProvider(recorder);
+ EnvelopeHost host = NewHost(
+ sp,
+ mode: HandlerExecutionMode.Sequential
+ );
+ using IDisposable regB = RegisterHandler(host, sp, typeof(TestEnvelope));
+ using IDisposable regA = RegisterHandler(host, sp, typeof(TestEnvelope));
+
+ await host
+ .PublishAsync(new TestEnvelope { Payload = "y" }, null, CancellationToken.None)
+ .ConfigureAwait(true);
+
+ recorder.Entries.Should().Equal("B:y", "A:y");
+ }
+
+ [Fact]
+ public async Task Behaviors_RunInOrderAttributeSequence_BeforeHandlers()
+ {
+ Recorder recorder = new();
+ using ServiceProvider sp = NewProvider(recorder);
+ EnvelopeHost host = NewHost(sp);
+
+ // Registered deliberately out of numeric order to prove the pipeline sorts by
+ // [Order], not by registration order.
+ using IDisposable regLow = RegisterBehavior(
+ host,
+ sp,
+ typeof(TestEnvelope)
+ );
+ using IDisposable regUnordered = RegisterBehavior(
+ host,
+ sp,
+ typeof(TestEnvelope)
+ );
+ using IDisposable regHigh = RegisterBehavior(
+ host,
+ sp,
+ typeof(TestEnvelope)
+ );
+ using IDisposable regHandler = RegisterHandler(
+ host,
+ sp,
+ typeof(TestEnvelope)
+ );
+
+ await host
+ .PublishAsync(new TestEnvelope { Payload = "z" }, null, CancellationToken.None)
+ .ConfigureAwait(true);
+
+ recorder
+ .Entries.Should()
+ .Equal(
+ "Behavior(-5):before",
+ "Behavior(0):before",
+ "Behavior(10):before",
+ "A:z",
+ "Behavior(10):after",
+ "Behavior(0):after",
+ "Behavior(-5):after"
+ );
+ }
+
+ [Fact]
+ public async Task DisposingTheRegistration_UnregistersTheHandler_StopsFurtherInvocations()
+ {
+ Recorder recorder = new();
+ using ServiceProvider sp = NewProvider(recorder);
+ EnvelopeHost host = NewHost(sp);
+ IDisposable reg = RegisterHandler(host, sp, typeof(TestEnvelope));
+
+ await host
+ .PublishAsync(new TestEnvelope { Payload = "1" }, null, CancellationToken.None)
+ .ConfigureAwait(true);
+
+ reg.Dispose();
+
+ await host
+ .PublishAsync(new TestEnvelope { Payload = "2" }, null, CancellationToken.None)
+ .ConfigureAwait(true);
+
+ recorder.Entries.Should().Equal("A:1");
+ }
+
+ [Fact]
+ public async Task EnableInheritanceDispatch_True_BaseHandlerReceivesDerivedEnvelope()
+ {
+ Recorder recorder = new();
+ using ServiceProvider sp = NewProvider(recorder);
+ EnvelopeHost host = NewHost(sp, enableInheritance: true);
+ using IDisposable reg = RegisterHandler(host, sp, typeof(TestEnvelope));
+
+ await host
+ .PublishAsync(new DerivedTestEnvelope { Payload = "d" }, null, CancellationToken.None)
+ .ConfigureAwait(true);
+
+ recorder.Entries.Should().Equal("A:d");
+ }
+
+ [Fact]
+ public async Task EnableInheritanceDispatch_False_BaseHandlerDoesNotReceiveDerivedEnvelope()
+ {
+ Recorder recorder = new();
+ using ServiceProvider sp = NewProvider(recorder);
+ EnvelopeHost host = NewHost(
+ sp,
+ enableInheritance: false
+ );
+ using IDisposable reg = RegisterHandler(host, sp, typeof(TestEnvelope));
+
+ await host
+ .PublishAsync(new DerivedTestEnvelope { Payload = "d" }, null, CancellationToken.None)
+ .ConfigureAwait(true);
+
+ recorder.Entries.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task OnHandlerActivationError_Fires_AndOtherHandlersStillRun()
+ {
+ Recorder recorder = new();
+ using ServiceProvider sp = NewProvider(recorder);
+ List activationErrors = [];
+ EnvelopeHost host = NewHost(
+ sp,
+ onHandlerActivationError: (ex, env) => activationErrors.Add(ex)
+ );
+
+ EnvelopeInvokerFactory factory = new();
+ HandlerInvoker invoker = factory.CreateHandlerInvoker(
+ typeof(RecordingHandlerA),
+ typeof(TestEnvelope)
+ );
+ Func throwingActivator = _ =>
+ throw new InvalidOperationException("cannot activate");
+ using IDisposable badReg = host.RegisterHandler(
+ typeof(TestEnvelope),
+ sp,
+ throwingActivator,
+ invoker
+ );
+ using IDisposable goodReg = RegisterHandler(host, sp, typeof(TestEnvelope));
+
+ await host
+ .PublishAsync(new TestEnvelope { Payload = "e" }, null, CancellationToken.None)
+ .ConfigureAwait(true);
+
+ activationErrors.Should().ContainSingle();
+ activationErrors[0].Should().BeOfType();
+ recorder.Entries.Should().Equal("B:e");
+ }
+
+ [Fact]
+ public async Task OnHandlerInvokeError_Fires_AndOtherHandlersStillRun()
+ {
+ Recorder recorder = new();
+ using ServiceProvider sp = NewProvider(recorder);
+ List invokeErrors = [];
+ EnvelopeHost host = NewHost(
+ sp,
+ onHandlerInvokeError: (ex, env) => invokeErrors.Add(ex)
+ );
+
+ using IDisposable throwingReg = RegisterHandler(
+ host,
+ sp,
+ typeof(TestEnvelope)
+ );
+ using IDisposable goodReg = RegisterHandler(host, sp, typeof(TestEnvelope));
+
+ await host
+ .PublishAsync(new TestEnvelope { Payload = "f" }, null, CancellationToken.None)
+ .ConfigureAwait(true);
+
+ invokeErrors.Should().ContainSingle();
+ invokeErrors[0].Should().BeOfType();
+ recorder.Entries.Should().Equal("A:f");
+ }
+
+ [Fact]
+ public async Task OnNoHandlerRegistered_FiresWhenTheBucketExistsButHasNoHandlers()
+ {
+ Recorder recorder = new();
+ using ServiceProvider sp = NewProvider(recorder);
+ List noHandlerEnvelopes = [];
+ EnvelopeHost host = NewHost(
+ sp,
+ onNoHandlerRegistered: env => noHandlerEnvelopes.Add(env)
+ );
+
+ // Registering only a behavior still creates the envelope-type bucket, with zero handlers.
+ using IDisposable behaviorReg = RegisterBehavior(
+ host,
+ sp,
+ typeof(TestEnvelope)
+ );
+
+ TestEnvelope published = new() { Payload = "none" };
+
+ await host.PublishAsync(published, null, CancellationToken.None).ConfigureAwait(true);
+
+ noHandlerEnvelopes.Should().ContainSingle();
+ noHandlerEnvelopes[0].Should().BeSameAs(published);
+ recorder.Entries.Should().Equal("Behavior(0):before", "Behavior(0):after");
+ }
+
+ [Fact]
+ public async Task Publish_WithNothingRegisteredForTheEnvelopeType_ReturnsContext_WithoutThrowing()
+ {
+ using ServiceProvider sp = NewProvider(new Recorder());
+ EnvelopeHost host = NewHost(sp);
+
+ TestContext ctx = await host
+ .PublishWithContextAsync(new TestEnvelope { Payload = "none" }, null, CancellationToken.None)
+ .ConfigureAwait(true);
+
+ ctx.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task MaxHandlerDegreeOfParallelism_BoundsConcurrentHandlerExecutions()
+ {
+ Recorder recorder = new();
+ ConcurrencyTracker tracker = new();
+ using ServiceProvider sp = NewProvider(recorder, tracker);
+ EnvelopeHost host = NewHost(sp, maxDop: 2);
+
+ CompositeDisposable regs = new();
+ for (int i = 0; i < 6; i++)
+ {
+ regs.Add(
+ RegisterHandler(host, sp, typeof(TestEnvelope))
+ );
+ }
+
+ await host
+ .PublishAsync(new TestEnvelope { Payload = "p" }, null, CancellationToken.None)
+ .ConfigureAwait(true);
+
+ tracker.Max.Should().BeLessThanOrEqualTo(2);
+ tracker
+ .Max.Should()
+ .BeGreaterThan(1, "degree 2 should allow real overlap, not fully serialize the work");
+
+ regs.Dispose();
+ }
+}
diff --git a/Vortex.Pipeline.Tests/PipelineTestFixtures.cs b/Vortex.Pipeline.Tests/PipelineTestFixtures.cs
new file mode 100644
index 00000000..68cbaefa
--- /dev/null
+++ b/Vortex.Pipeline.Tests/PipelineTestFixtures.cs
@@ -0,0 +1,167 @@
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Vortex.Pipeline.Attributes;
+using Vortex.Pipeline.Registry;
+
+namespace Vortex.Pipeline.Tests;
+
+// A small, self-contained envelope/context/meta shape for exercising EnvelopeHost<,,> directly --
+// deliberately NOT any real Vortex message/event type, per the TEST-01 brief.
+
+public class TestEnvelope
+{
+ public string Payload { get; init; } = string.Empty;
+}
+
+public sealed class DerivedTestEnvelope : TestEnvelope { }
+
+public sealed class TestContext
+{
+ public bool ShortCircuit { get; set; }
+}
+
+/// Thread-safe call-order/invocation recorder shared (via DI) between the host and the
+/// handler/behavior instances it activates for each publish.
+public sealed class Recorder
+{
+ private readonly ConcurrentQueue _entries = new();
+
+ public IReadOnlyCollection Entries => _entries;
+
+ public void Record(string entry) => _entries.Enqueue(entry);
+}
+
+/// Tracks the maximum number of handlers observed running concurrently, to verify
+/// is
+/// actually enforced by the dispatch engine and not just accepted as a no-op setting.
+public sealed class ConcurrencyTracker
+{
+ private int _current;
+ private int _max;
+
+ public int Max => Volatile.Read(ref _max);
+
+ public void Enter()
+ {
+ int now = Interlocked.Increment(ref _current);
+
+ int observedMax;
+ do
+ {
+ observedMax = Volatile.Read(ref _max);
+ if (now <= observedMax)
+ {
+ break;
+ }
+ } while (Interlocked.CompareExchange(ref _max, now, observedMax) != observedMax);
+ }
+
+ public void Exit() => Interlocked.Decrement(ref _current);
+}
+
+public sealed class RecordingHandlerA(Recorder recorder) : IHandler
+{
+ public ValueTask HandleAsync(TestEnvelope env, TestContext ctx, CancellationToken ct)
+ {
+ recorder.Record($"A:{env.Payload}");
+
+ return ValueTask.CompletedTask;
+ }
+}
+
+public sealed class RecordingHandlerB(Recorder recorder) : IHandler
+{
+ public ValueTask HandleAsync(TestEnvelope env, TestContext ctx, CancellationToken ct)
+ {
+ recorder.Record($"B:{env.Payload}");
+
+ return ValueTask.CompletedTask;
+ }
+}
+
+public sealed class ThrowingHandler : IHandler