Skip to content

Commit 413eacf

Browse files
HandyS11claude
andauthored
Subsystem 1b-i: credential intake & FCM pairing → server registration (#5)
* feat(persistence): split FCM registration from player credential pool Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(persistence): add FcmRegistrationStore Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(persistence): add resolve-or-create-by-endpoint to ServerService Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(persistence): cover pairing schema cascade and unique indexes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(pairing): scaffold Features.Pairing project and listener seam Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(pairing): add PairingHandler for server registration 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> * feat(pairing): add owner DM notifier seam Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(pairing): add PairingSupervisor with expiry and backoff Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(pairing): add RustPlusApi.Fcm adapter for IPairingSource Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(pairing): add PairingHostedService Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(workspace): add Connect account button to #setup Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(pairing): add credential validator and connect modal Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(pairing): wire pairing feature into the host Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style: apply ReSharper ReformatAndReorder Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: address PR #5 review (race-safe active flag, upsert retry, non-throwing 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> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ec10b64 commit 413eacf

57 files changed

Lines changed: 2672 additions & 47 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
1212
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
1313
<PackageVersion Include="Persistord.Core" Version="1.0.0-beta.1" />
14+
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.1" />
1415
</ItemGroup>
1516
<ItemGroup>
1617
<!-- Test + sample -->

RustPlusBot.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
<Project Path="src/RustPlusBot.Discord/RustPlusBot.Discord.csproj" />
55
<Project Path="src/RustPlusBot.Domain/RustPlusBot.Domain.csproj" />
66
<Project Path="src/RustPlusBot.Host/RustPlusBot.Host.csproj" />
7+
<Project Path="src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj" />
78
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
89
<Project Path="src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj" />
910
</Folder>
1011
<Folder Name="/tests/">
1112
<Project Path="tests/RustPlusBot.Abstractions.Tests/RustPlusBot.Abstractions.Tests.csproj" />
13+
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
1214
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
1315
<Project Path="tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj" />
1416
</Folder>

src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
namespace RustPlusBot.Abstractions.Credentials;
22

3-
/// <summary>Persists and counts player credentials. Tokens are protected at rest.</summary>
3+
/// <summary>Persists and counts per-server player credentials. Tokens are protected at rest.</summary>
44
public interface ICredentialStore
55
{
6-
/// <summary>Stores a credential (as Standby) and returns its new id.</summary>
6+
/// <summary>
7+
/// Inserts or refreshes the credential for (GuildId, RustServerId, OwnerUserId). A NEWLY INSERTED
8+
/// credential is <c>Active</c> when <paramref name="markActive"/> is true (the owner whose pairing
9+
/// first registered the server), otherwise <c>Standby</c>. Re-pairing refreshes the SteamId and token
10+
/// and resets an <c>Invalid</c> credential to <c>Standby</c> (the existing designation is preserved;
11+
/// <paramref name="markActive"/> is ignored on update). Returns the credential's id.
12+
/// </summary>
713
/// <param name="request">The plaintext credential inputs.</param>
14+
/// <param name="markActive">When inserting, whether this credential becomes the server's active identity.</param>
815
/// <param name="cancellationToken">A cancellation token.</param>
9-
/// <returns>The new credential's id.</returns>
10-
Task<Guid> StoreAsync(StoreCredentialRequest request, CancellationToken cancellationToken = default);
16+
/// <returns>The credential's id.</returns>
17+
Task<Guid> UpsertFromPairingAsync(
18+
StoreCredentialRequest request,
19+
bool markActive,
20+
CancellationToken cancellationToken = default);
1121

1222
/// <summary>Counts stored credentials for a server within a guild.</summary>
1323
/// <param name="guildId">Owning Discord guild snowflake.</param>
Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
namespace RustPlusBot.Abstractions.Credentials;
22

3-
/// <summary>Plaintext inputs to register a credential; tokens are protected by the store before persistence.</summary>
3+
/// <summary>Plaintext inputs to upsert a per-server player credential from a pairing; the token is protected before persistence.</summary>
44
/// <param name="GuildId">Owning Discord guild snowflake.</param>
55
/// <param name="RustServerId">The server this credential connects to.</param>
6-
/// <param name="OwnerUserId">The Discord user registering the credential.</param>
7-
/// <param name="SteamId">The player's Steam64 id.</param>
6+
/// <param name="OwnerUserId">The Discord user who owns the paired identity.</param>
7+
/// <param name="SteamId">The player's Steam64 id (from the pairing notification).</param>
88
/// <param name="PlayerToken">The plaintext Rust+ player token (protected before storage).</param>
9-
/// <param name="FcmCredentialsJson">The plaintext FCM/Expo credential JSON (protected before storage).</param>
109
public sealed record StoreCredentialRequest(
1110
ulong GuildId,
1211
Guid RustServerId,
1312
ulong OwnerUserId,
1413
ulong SteamId,
15-
string PlayerToken,
16-
string FcmCredentialsJson);
14+
string PlayerToken);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
namespace RustPlusBot.Domain.Credentials;
2+
3+
/// <summary>
4+
/// One Discord user's Rust+ FCM listener registration within a guild. One per (GuildId, OwnerUserId).
5+
/// The credentials blob is stored protected at rest (see ICredentialProtector) and feeds the pairing listener.
6+
/// </summary>
7+
public sealed class FcmRegistration
8+
{
9+
/// <summary>Surrogate primary key.</summary>
10+
public Guid Id { get; set; } = Guid.NewGuid();
11+
12+
/// <summary>The owning Discord guild snowflake.</summary>
13+
public ulong GuildId { get; set; }
14+
15+
/// <summary>The Discord user who connected these credentials.</summary>
16+
public ulong OwnerUserId { get; set; }
17+
18+
/// <summary>Protected FCM/Expo credentials JSON.</summary>
19+
public string ProtectedFcmCredentials { get; set; } = string.Empty;
20+
21+
/// <summary>Listener lifecycle state.</summary>
22+
public FcmRegistrationStatus Status { get; set; } = FcmRegistrationStatus.Active;
23+
24+
/// <summary>When the registration was last upserted or had its status changed (UTC).</summary>
25+
public DateTimeOffset UpdatedAt { get; set; }
26+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace RustPlusBot.Domain.Credentials;
2+
3+
/// <summary>Lifecycle state of a user's FCM listener registration.</summary>
4+
public enum FcmRegistrationStatus
5+
{
6+
/// <summary>Credentials accepted; the listener should run.</summary>
7+
Active = 0,
8+
9+
/// <summary>FCM rejected the credentials; the user must reconnect.</summary>
10+
Expired = 1,
11+
12+
/// <summary>Removed by the user; the listener must not run.</summary>
13+
Disabled = 2,
14+
}

src/RustPlusBot.Domain/Credentials/PlayerCredential.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,6 @@ public sealed class PlayerCredential
2424
/// <summary>Protected Rust+ player token.</summary>
2525
public string ProtectedPlayerToken { get; set; } = string.Empty;
2626

27-
/// <summary>Protected FCM/Expo credential blob (JSON), used by the pairing listener later.</summary>
28-
public string ProtectedFcmCredentials { get; set; } = string.Empty;
29-
3027
/// <summary>Pool lifecycle state.</summary>
3128
public CredentialStatus Status { get; set; } = CredentialStatus.Standby;
3229
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
using System.Runtime.CompilerServices;
2+
3+
[assembly: InternalsVisibleTo("RustPlusBot.Features.Pairing.Tests")]
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using Discord.WebSocket;
2+
using Microsoft.Extensions.Hosting;
3+
using Microsoft.Extensions.Logging;
4+
using RustPlusBot.Features.Pairing.Supervisor;
5+
6+
namespace RustPlusBot.Features.Pairing.Hosting;
7+
8+
/// <summary>Drives the supervisor from the host lifecycle: start listeners on Ready, stop them on shutdown.</summary>
9+
/// <param name="client">The socket client (for the Ready event).</param>
10+
/// <param name="supervisor">The pairing supervisor.</param>
11+
/// <param name="logger">The logger.</param>
12+
internal sealed partial class PairingHostedService(
13+
DiscordSocketClient client,
14+
IPairingSupervisor supervisor,
15+
ILogger<PairingHostedService> logger) : IHostedService
16+
{
17+
private bool _started;
18+
19+
/// <inheritdoc />
20+
public Task StartAsync(CancellationToken cancellationToken)
21+
{
22+
client.Ready += OnReadyAsync;
23+
return Task.CompletedTask;
24+
}
25+
26+
/// <inheritdoc />
27+
public async Task StopAsync(CancellationToken cancellationToken)
28+
{
29+
client.Ready -= OnReadyAsync;
30+
await supervisor.StopAllAsync().ConfigureAwait(false);
31+
}
32+
33+
private async Task OnReadyAsync()
34+
{
35+
// Ready fires on every (re)connect; only start listeners once per process. Ready is dispatched
36+
// serially on the gateway thread, so the plain bool guard needs no synchronization (same pattern
37+
// as DiscordBotService and WorkspaceHostedService).
38+
if (_started)
39+
{
40+
return;
41+
}
42+
43+
_started = true;
44+
try
45+
{
46+
await supervisor.StartAllActiveAsync().ConfigureAwait(false);
47+
}
48+
#pragma warning disable CA1031 // Broad catch is intentional: a failed startup must not crash the host.
49+
catch (Exception ex)
50+
{
51+
LogStartupFailed(ex);
52+
}
53+
#pragma warning restore CA1031
54+
}
55+
56+
[LoggerMessage(Level = LogLevel.Error, Message = "Failed to start FCM pairing listeners.")]
57+
private partial void LogStartupFailed(Exception exception);
58+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace RustPlusBot.Features.Pairing.Listening;
2+
3+
/// <summary>A live FCM listener for one user's credentials. Pushes pairing notifications to a callback.</summary>
4+
internal interface IPairingListener : IAsyncDisposable
5+
{
6+
/// <summary>
7+
/// Connects and waits up to <paramref name="timeout"/> for the first successful check-in. After a
8+
/// <see cref="PairingConnectOutcome.Connected"/> result the listener keeps delivering notifications
9+
/// to its callback until disposed.
10+
/// </summary>
11+
/// <param name="timeout">How long to wait for the initial check-in.</param>
12+
/// <param name="cancellationToken">Cancels the connection.</param>
13+
/// <returns>The initial connect outcome.</returns>
14+
Task<PairingConnectOutcome> ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken);
15+
}
16+
17+
/// <summary>Creates <see cref="IPairingListener"/>s from credentials. The seam that abstracts FCM for tests.</summary>
18+
internal interface IPairingSource
19+
{
20+
/// <summary>Creates a listener that delivers notifications to <paramref name="onNotification"/>.</summary>
21+
/// <param name="fcmCredentialsJson">The plaintext FCM credentials JSON.</param>
22+
/// <param name="onNotification">Invoked for every pairing notification received.</param>
23+
/// <returns>A not-yet-connected listener.</returns>
24+
IPairingListener Create(
25+
string fcmCredentialsJson,
26+
Func<PairingNotification, CancellationToken, Task> onNotification);
27+
}

0 commit comments

Comments
 (0)