Subsystem 1b-i: credential intake & FCM pairing → server registration - #5
Conversation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements IPairingHandler / PairingHandler: resolves-or-creates the server via IServerService, upserts the credential via ICredentialStore, and publishes ServerRegisteredEvent only when the server is newly created. Entity notifications are silently ignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR implements Subsystem 1b-i by adding credential intake + per-user FCM pairing listeners and using server pairings to auto-register Rust servers and upsert per-owner player credentials, backed by a new persistence model and EF migration.
Changes:
- Add new
RustPlusBot.Features.Pairingfeature (modal + supervisor + handler + hosted service) to ingest FCM credentials and listen for in-game pairings. - Split credentials persistence into
FcmRegistration(per guild+user) andPlayerCredential(per guild+server+owner), including migration + new unique indexes and cascade delete. - Update workspace
#setupmessage rendering/localization to expose a “Connect account” button handled by the Pairing feature.
Reviewed changes
Copilot reviewed 55 out of 56 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs | Adds tests for endpoint-based resolve-or-create. |
| tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs | Verifies IFcmRegistrationStore DI registration. |
| tests/RustPlusBot.Persistence.Tests/Credentials/PairingSchemaTests.cs | Verifies new unique indexes + cascade delete behavior. |
| tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs | Covers upsert/list/status/get behavior for FCM registrations. |
| tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs | Updates tests for new pairing-based credential upsert semantics. |
| tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs | Asserts setup message includes the connect button. |
| tests/RustPlusBot.Features.Pairing.Tests/TestDb.cs | Test helper DB context with migrations. |
| tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj | New test project for pairing feature. |
| tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs | Unit tests for supervisor connect/retry/notify behavior. |
| tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs | Validates DI wiring for the pairing feature. |
| tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs | Tests server creation, credential upsert, and event publishing. |
| tests/RustPlusBot.Features.Pairing.Tests/FcmCredentialValidatorTests.cs | Tests JSON structural validator. |
| tests/RustPlusBot.Features.Pairing.Tests/Fakes/RecordingOwnerNotifier.cs | Fake notifier for tests. |
| tests/RustPlusBot.Features.Pairing.Tests/Fakes/FakePairingSource.cs | Scripted pairing source + listener fake. |
| src/RustPlusBot.Persistence/Servers/ServerService.cs | Adds ResolveOrCreateByEndpointAsync with concurrency handling. |
| src/RustPlusBot.Persistence/Servers/IServerService.cs | Adds endpoint resolve-or-create API contract. |
| src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs | Registers IFcmRegistrationStore. |
| src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs | Updates snapshot for new entities/indexes/FKs. |
| src/RustPlusBot.Persistence/Migrations/20260615085447_PairingCredentials.Designer.cs | Adds migration designer for new schema. |
| src/RustPlusBot.Persistence/Migrations/20260615085447_PairingCredentials.cs | Migration to split credentials + add unique indexes + cascade FK. |
| src/RustPlusBot.Persistence/Credentials/IFcmRegistrationStore.cs | New persistence API for per-user FCM registrations. |
| src/RustPlusBot.Persistence/Credentials/FcmRegistrationStore.cs | EF-backed FCM registration store. |
| src/RustPlusBot.Persistence/Credentials/CredentialStore.cs | Replaces store API with pairing-based upsert + active/standby selection. |
| src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs | Adds unique index on (GuildId, Ip, Port). |
| src/RustPlusBot.Persistence/Configurations/PlayerCredentialConfiguration.cs | Adds unique index per (GuildId, Server, Owner) + cascade delete FK. |
| src/RustPlusBot.Persistence/Configurations/FcmRegistrationConfiguration.cs | Adds unique index + required secret field for registrations. |
| src/RustPlusBot.Persistence/BotDbContext.cs | Adds DbSet<FcmRegistration> and config application. |
| src/RustPlusBot.Host/RustPlusBot.Host.csproj | References new Pairing feature project. |
| src/RustPlusBot.Host/Program.cs | Adds Pairing options binding/validation and feature registration. |
| src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs | Shared component ID contract for setup connect button. |
| src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs | Adds “Connect account” button to setup message. |
| src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs | Updates setup text + adds button localization keys. |
| src/RustPlusBot.Features.Pairing/Validation/FcmCredentialValidator.cs | Lightweight JSON-object validator for modal input. |
| src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs | Runs/controls one listener per registration + retry/expire flows. |
| src/RustPlusBot.Features.Pairing/Supervisor/IPairingSupervisor.cs | Supervisor abstraction. |
| src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj | New Pairing feature project definition. |
| src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs | DI registration + hosted service + interaction module assembly export. |
| src/RustPlusBot.Features.Pairing/PairingOptions.cs | Config options for probe timeout + backoff. |
| src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs | Converts pairing notifications into server+credential+event. |
| src/RustPlusBot.Features.Pairing/Pairing/IPairingHandler.cs | Handler abstraction. |
| src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs | Notifier abstraction. |
| src/RustPlusBot.Features.Pairing/Notifications/DiscordOwnerNotifier.cs | DM-based notifier implementation. |
| src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs | Interaction handlers for button + modal submit. |
| src/RustPlusBot.Features.Pairing/Modules/ConnectModal.cs | Modal definition for credential intake. |
| src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs | Production FCM listener adapter. |
| src/RustPlusBot.Features.Pairing/Listening/PairingNotification.cs | Pairing notification + enums. |
| src/RustPlusBot.Features.Pairing/Listening/IPairingSource.cs | Pairing source/listener abstractions. |
| src/RustPlusBot.Features.Pairing/Hosting/PairingHostedService.cs | Starts/stops listeners with host lifecycle. |
| src/RustPlusBot.Features.Pairing/AssemblyInfo.cs | InternalsVisibleTo for pairing tests. |
| src/RustPlusBot.Domain/Credentials/PlayerCredential.cs | Removes FCM blob from per-server credentials. |
| src/RustPlusBot.Domain/Credentials/FcmRegistrationStatus.cs | Adds registration lifecycle status enum. |
| src/RustPlusBot.Domain/Credentials/FcmRegistration.cs | Adds new per-user registration domain model. |
| src/RustPlusBot.Abstractions/Credentials/StoreCredentialRequest.cs | Removes FCM JSON from credential request; pairing-focused inputs. |
| src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs | Renames to pairing upsert API + documents semantics. |
| RustPlusBot.slnx | Adds Pairing feature + tests to solution. |
| Directory.Packages.props | Adds RustPlusApi.Fcm package version. |
Files not reviewed (1)
- src/RustPlusBot.Persistence/Migrations/20260615085447_PairingCredentials.Designer.cs: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var isFirstForServer = !await context.PlayerCredentials | ||
| .AnyAsync(c => c.GuildId == request.GuildId && c.RustServerId == request.RustServerId, cancellationToken) | ||
| .ConfigureAwait(false); |
There was a problem hiding this comment.
Fixed in b8e2ec2. The Active/Standby designation now derives from the created flag returned by ResolveOrCreateByEndpointAsync rather than a separate AnyAsync read. Only one concurrent pairing can create the server (the (GuildId, Ip, Port) unique index guarantees it), so only that owner's credential is Active; the rest are Standby. The TOCTOU window is gone and no new migration was needed.
| var registration = new FcmRegistration | ||
| { | ||
| GuildId = guildId, | ||
| OwnerUserId = ownerUserId, | ||
| ProtectedFcmCredentials = protector.Protect(fcmCredentialsJson), | ||
| Status = FcmRegistrationStatus.Active, | ||
| UpdatedAt = clock.UtcNow, | ||
| }; | ||
|
|
||
| context.FcmRegistrations.Add(registration); | ||
| await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); | ||
| return registration.Id; |
There was a problem hiding this comment.
Fixed in b8e2ec2. UpsertAsync now catches the DbUpdateException from the (GuildId, OwnerUserId) unique index on the insert path, detaches the conflicting entry, re-reads the winning row, and applies the update — the same recovery pattern already used in ServerService.ResolveOrCreateByEndpointAsync. A concurrent double-submit of the modal no longer surfaces as a failed interaction.
| /// <inheritdoc /> | ||
| public IPairingListener Create( | ||
| string fcmCredentialsJson, | ||
| Func<PairingNotification, CancellationToken, Task> onNotification) => | ||
| new RustPlusFcmListener(fcmCredentialsJson, onNotification, logger); | ||
|
|
||
| private sealed partial class RustPlusFcmListener : IPairingListener |
There was a problem hiding this comment.
Fixed in b8e2ec2. Create is now non-throwing: a construction/deserialization failure is logged and returns a RejectedListener whose ConnectAsync returns Rejected, so the supervisor reports the outcome as rejected and the modal shows "those credentials were rejected" instead of "still verifying". Added RustPlusFcmPairingSourceTests asserting Create doesn't throw and yields Rejected for unparseable credentials.
| private bool _started; | ||
|
|
||
| /// <inheritdoc /> | ||
| public Task StartAsync(CancellationToken cancellationToken) | ||
| { | ||
| client.Ready += OnReadyAsync; | ||
| return Task.CompletedTask; | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public async Task StopAsync(CancellationToken cancellationToken) | ||
| { | ||
| client.Ready -= OnReadyAsync; | ||
| await supervisor.StopAllAsync().ConfigureAwait(false); | ||
| } | ||
|
|
||
| private async Task OnReadyAsync() | ||
| { | ||
| // Ready fires on every (re)connect; only start listeners once per process. | ||
| if (_started) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| _started = true; |
There was a problem hiding this comment.
Kept the plain bool here intentionally, for consistency with the repo's other two hosted services (DiscordBotService and WorkspaceHostedService), which rely on Discord.Net dispatching Ready serially on the gateway thread — I've expanded the comment in b8e2ec2 to document that rationale. _started is also set before the await, and a double-run would be benign anyway: EnsureListenerAsync stops-then-restarts a listener per owner idempotently, so the worst case is redundant work, not corruption. If you'd prefer, I can standardize all three hosted services on Interlocked.Exchange as a separate cleanup.
…hrowing FCM Create) - CredentialStore: derive Active from server-creation (race-free) instead of a TOCTOU read - FcmRegistrationStore: recover from concurrent-insert unique violation by re-reading the winner - RustPlusFcmPairingSource: Create no longer throws on bad credentials; maps to Rejected - PairingHostedService: document the serial-Ready start-once rationale Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Subsystem 1b-i: users connect their Rust+ FCM credentials and the bot auto-registers servers when they pair in-game. No live Rust+ socket — opening sockets, hot-swap, auto-failover and
#infolive status are deferred to 1b-ii.#setupopens an ephemeral modal; the pasted FCM credentials JSON is validated, protected at rest, and stored as anFcmRegistration.PairingSupervisor(singleton) runs oneRustPlusApi.Fcmlistener per active registration (behind anIPairingSourceseam), reports the first connect outcome for immediate modal feedback, retries a timed-out connect with capped backoff, and on rejection marks the registrationExpired+ DMs the owner.PairingHandlerresolve-or-creates theRustServerby(GuildId, Ip, Port), upserts a per-ownerPlayerCredential(first owner →Active, others →Standby— the credential pool), and fires the realServerRegisteredEvent, which the existing 1a reconciler consumes to provision the server's channels.FcmRegistration(per guild+user) + a slimmedPlayerCredential(per guild+server+owner); migrationPairingCredentialsdrops the old FCM column, adds the new table, unique indexes on(GuildId,Ip,Port)/(GuildId,RustServerId,OwnerUserId)/(GuildId,OwnerUserId), and a cascade FK so removing a server cleans its credentials.New
RustPlusBot.Features.Pairingproject (source seam + supervisor + handler + notifier + hosted service + modal); the realRustPlusApi.Fcmadapter is the only piece without unit tests (integration shim, faked everywhere else).Notable decisions (documented deviations from the spec)
ICredentialStore.SetStatusAsyncis deferred to 1b-ii (no caller in 1b-i, and adding aDomain.CredentialStatusparameter would forceICredentialStoreout of the dependency-freeAbstractionsproject). TheInvalid → Standbyreset on re-pairing lives insideCredentialStore.UpsertFromPairingAsyncinstead.Test Plan
dotnet build— 0 warnings / 0 errors (strict analyzers)dotnet test— 81 passed, 0 failed (Abstractions 4, Workspace 30, Pairing 18, Persistence 29)dotnet jb cleanupcode --profile=ReformatAndReorderclean (matches CI format gate)PairingCredentialshas no model drift (has-pending-model-changesclean)#setupmodal, pair a server in-game, confirm the server's channels are provisioned and aPlayerCredentialis stored🤖 Generated with Claude Code