Skip to content

feat: Implement issues #549, #548, #541 - Secure session management, …#674

Closed
A6dulmalik wants to merge 1 commit into
OpenKnight-Foundation:mainfrom
A6dulmalik:feature/A6dulmalik-issues-549-548-541
Closed

feat: Implement issues #549, #548, #541 - Secure session management, …#674
A6dulmalik wants to merge 1 commit into
OpenKnight-Foundation:mainfrom
A6dulmalik:feature/A6dulmalik-issues-549-548-541

Conversation

@A6dulmalik

@A6dulmalik A6dulmalik commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

…WebSocket scaling, and PGN archiving

  • Issue Backend: Secure session management with JWT and Refresh Tokens #549: Enhanced JWT session management with session tracking, IP binding, and refresh token rotation

    • Added session entity and migration for tracking active user sessions
    • Enhanced JWT claims with session_id and ip_hash fields
    • Implemented SessionService for creating, validating, and revoking sessions
    • Updated login endpoint to create sessions and bind tokens to session IDs
    • Added support for token reuse detection and theft prevention
  • Issue Backend: Websocket scaling with horizontal load balancing #548: WebSocket scaling with horizontal load balancing

    • Enhanced LobbyState with distributed connection tracking
    • Added Redis Pub/Sub for cross-instance communication
    • Implemented connection event publishing for monitoring
    • Added instance_id tracking for multi-server deployments
    • Improved logging for connection/disconnection events
  • Issue Backend: Automated PGN archiving to IPFS/Arweave #541: Automated PGN archiving to IPFS/Arweave

    • Created PgnArchiveService for decentralized storage
    • Implemented IPFS integration with Pinata API
    • Implemented Arweave integration with bundler service
    • Added PGN integrity verification using SHA-256 hashing
    • Created archive-pgn and batch-archive-pgn API endpoints
    • Added proper error handling and logging

All implementations follow existing code patterns and include proper documentation.

closes #538
Closes #541
Closes #548
Closes #549

Summary by CodeRabbit

Release Notes

  • New Features

    • Implemented server-side session management with automatic expiration and revocation capabilities
    • Added ability to archive chess game records to decentralized storage (IPFS and Arweave)
    • Enhanced real-time lobby with improved connection tracking and monitoring
    • Strengthened authentication with request context capture
  • Chores

    • Database migration to support session storage
    • Added dependencies for decentralized archival integration

…on#548, OpenKnight-Foundation#541 - Secure session management, WebSocket scaling, and PGN archiving

- Issue OpenKnight-Foundation#549: Enhanced JWT session management with session tracking, IP binding, and refresh token rotation
  * Added session entity and migration for tracking active user sessions
  * Enhanced JWT claims with session_id and ip_hash fields
  * Implemented SessionService for creating, validating, and revoking sessions
  * Updated login endpoint to create sessions and bind tokens to session IDs
  * Added support for token reuse detection and theft prevention

- Issue OpenKnight-Foundation#548: WebSocket scaling with horizontal load balancing
  * Enhanced LobbyState with distributed connection tracking
  * Added Redis Pub/Sub for cross-instance communication
  * Implemented connection event publishing for monitoring
  * Added instance_id tracking for multi-server deployments
  * Improved logging for connection/disconnection events

- Issue OpenKnight-Foundation#541: Automated PGN archiving to IPFS/Arweave
  * Created PgnArchiveService for decentralized storage
  * Implemented IPFS integration with Pinata API
  * Implemented Arweave integration with bundler service
  * Added PGN integrity verification using SHA-256 hashing
  * Created archive-pgn and batch-archive-pgn API endpoints
  * Added proper error handling and logging

All implementations follow existing code patterns and include proper documentation.
@drips-wave

drips-wave Bot commented Apr 24, 2026

Copy link
Copy Markdown

@A6dulmalik Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR implements three complementary features: server-side session management with JWT embedding, automated PGN archiving to IPFS and Arweave, and distributed WebSocket connection tracking via Redis. It introduces new database migrations, service layers, API endpoints, and integrates sessions into the existing authentication flow.

Changes

Cohort / File(s) Summary
PGN Archiving Dependencies
backend/modules/api/Cargo.toml
Added reqwest (JSON, multipart), sha2, and hex dependencies for IPFS/Arweave integration.
PGN Archiving Implementation
backend/modules/api/src/pgn_archive.rs, backend/modules/api/src/pgn_archive_api.rs
New service and API handlers for archiving PGN content; supports single and batch archival with concurrent IPFS/Arweave posting, error handling, and hashing verification.
API Route Wiring
backend/modules/api/src/server.rs, backend/modules/api/src/lib.rs
Registered new PGN archive endpoints (/v1/games/archive-pgn, /v1/games/batch-archive-pgn) and declared exported modules.
Session Management Infrastructure
backend/modules/security/src/session_service.rs, backend/modules/db/entity/src/session.rs, backend/modules/db/migrations/src/m20260224_000000_create_sessions_table.rs, backend/modules/db/migrations/src/lib.rs
New SessionService with creation, validation, revocation, and cleanup; SeaORM Session entity model; database migration creating sessions table with UUID pk, user fk, timestamps, and revocation flag.
Session Integration into Auth
backend/modules/api/src/auth.rs, backend/modules/security/src/jwt.rs
Login endpoint now captures request context (IP, User-Agent), creates server-side sessions, and embeds session_id and ip_hash into JWT claims via new generate_token_with_session method.
WebSocket Connection Tracking
backend/modules/api/src/ws.rs
Enhanced LobbyState with per-game connection counting, instance_id, optional Redis integration, and async publish methods to emit connect/disconnect events to Redis keyed by game ID.
Module Exports
backend/modules/security/src/lib.rs, backend/modules/db/entity/mod.rs
Exported new SessionService and SessionError from security crate; exported new session and refresh_token entities from database crate.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant AuthAPI as Auth API
    participant SessionSvc as SessionService
    participant Database
    participant JwtSvc as JWT Service

    Client->>AuthAPI: POST /login (credentials, HttpRequest)
    activate AuthAPI
    AuthAPI->>AuthAPI: Extract IP, User-Agent from request
    AuthAPI->>SessionSvc: create_session(user_id, ip, ua, ttl_hours)
    activate SessionSvc
    SessionSvc->>SessionSvc: Generate UUID session_id
    SessionSvc->>SessionSvc: Compute expires_at, created_at, last_active_at
    SessionSvc->>Database: INSERT session record
    Database-->>SessionSvc: session_id
    SessionSvc-->>AuthAPI: Result<Uuid>
    deactivate SessionSvc
    
    AuthAPI->>AuthAPI: Hash IP address
    AuthAPI->>JwtSvc: generate_token_with_session(user_id, username, session_id, ip_hash)
    activate JwtSvc
    JwtSvc->>JwtSvc: Create Claims with session_id, ip_hash, jti=session_id
    JwtSvc->>JwtSvc: Sign JWT token
    JwtSvc-->>AuthAPI: access_token
    deactivate JwtSvc
    
    AuthAPI-->>Client: 200 OK (access_token, session metadata)
    deactivate AuthAPI
Loading
sequenceDiagram
    participant Client
    participant ArchiveAPI as Archive API
    participant ArchiveSvc as PgnArchiveService
    participant IPFS as IPFS Pinning
    participant Arweave as Arweave Bundler

    Client->>ArchiveAPI: POST /v1/games/archive-pgn (pgn_content)
    activate ArchiveAPI
    ArchiveAPI->>ArchiveAPI: Validate pgn_content length
    
    alt Validation Failed
        ArchiveAPI-->>Client: 400 VALIDATION_ERROR
    else Validation Passed
        ArchiveAPI->>ArchiveSvc: archive_pgn(game_id, pgn_content)
        activate ArchiveSvc
        
        par IPFS and Arweave Concurrently
            ArchiveSvc->>IPFS: POST with JSON payload (pgn, timestamp)
            activate IPFS
            IPFS-->>ArchiveSvc: IpfsResponse {cid}
            deactivate IPFS
            
            ArchiveSvc->>ArchiveSvc: Compute SHA-256(pgn_content)
            ArchiveSvc->>Arweave: POST /tx (content, tags, hash)
            activate Arweave
            Arweave-->>ArchiveSvc: ArweaveResponse {tx_id}
            deactivate Arweave
        end
        
        ArchiveSvc->>ArchiveSvc: Aggregate results (IPFS CID, Arweave tx_id, pgn_hash)
        ArchiveSvc-->>ArchiveAPI: PgnArchiveResult
        deactivate ArchiveSvc
        
        ArchiveAPI-->>Client: 200 OK (PgnArchiveResult with cid, tx_id, hash)
    end
    deactivate ArchiveAPI
Loading
sequenceDiagram
    participant Client
    participant WSHandler as WS Handler
    participant LobbyState
    participant Redis as Redis

    Client->>WSHandler: WebSocket CONNECT
    activate WSHandler
    WSHandler->>LobbyState: Get/increment connection_count[game_id]
    activate LobbyState
    LobbyState->>LobbyState: connection_counts[game_id]++
    LobbyState-->>WSHandler: updated count
    deactivate LobbyState
    
    WSHandler->>WSHandler: Log connection event
    WSHandler->>Redis: PUBLISH game_id ({"event":"connected", "count": N, "timestamp": utc})
    activate Redis
    Redis-->>WSHandler: OK
    deactivate Redis
    WSHandler-->>Client: Connection established
    deactivate WSHandler
    
    Client->>WSHandler: WebSocket DISCONNECT
    activate WSHandler
    WSHandler->>LobbyState: Decrement connection_count[game_id]
    activate LobbyState
    LobbyState->>LobbyState: connection_counts[game_id]--
    LobbyState-->>WSHandler: updated count
    deactivate LobbyState
    
    WSHandler->>WSHandler: Log disconnection event
    WSHandler->>Redis: PUBLISH game_id ({"event":"disconnected", "count": N, "timestamp": utc})
    activate Redis
    Redis-->>WSHandler: OK
    deactivate Redis
    deactivate WSHandler
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 Whiskers twitch with archival glee,
Sessions nest in database trees,
IPFS and Arweave dance as one,
WebSocket tracking—work is done!
A rabbit's hop through layers deep,
Storage safe, connections keep. 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main feature: implementing three issues (#549, #548, #541) related to session management, WebSocket scaling, and PGN archiving, matching the changeset's primary additions.
Linked Issues check ✅ Passed The PR addresses all linked issues: #549 (SessionService, JWT extensions, session creation in login), #548/#538 (LobbyState Redis integration, connection tracking), and #541 (PgnArchiveService with IPFS/Arweave endpoints).
Out of Scope Changes check ✅ Passed All changes directly support the three linked issues: database entities/migrations for sessions, security enhancements via JWT and SessionService, WebSocket enhancements for distributed tracking, and complete PGN archiving implementation with API endpoints.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (10)
backend/modules/api/src/ws.rs (4)

405-420: ⚠️ Potential issue | 🔴 Critical

WsSession construction is missing redis_conn and redis_sub_task — compile error.

WsSession declares pub redis_conn: Option<Arc<ConnectionManager>> and pub redis_sub_task: Option<JoinHandle<()>> (lines 225-226), but ws_route constructs the struct without initializing them. The ws::start call will not compile. Either initialize both to None here or, better, pass the ConnectionManager through web::Data and wire it in — otherwise the Redis subscription path in started() is permanently dead.

🐛 Minimal compile fix (initialize to None)
     ws::start(
         WsSession { 
             game_id, 
             lobby: lobby.get_ref().clone(), 
             hb: std::time::Instant::now(),
             user_id: claims.user_id,
             username: claims.username,
             session_id,
+            redis_conn: None,
+            redis_sub_task: None,
         },
         &req,
         stream,
     )

To actually enable cross-instance delivery, propagate the shared ConnectionManager via web::Data<Arc<ConnectionManager>> and thread it into the session.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/ws.rs` around lines 405 - 420, WsSession is being
constructed in ws_route without initializing its redis_conn and redis_sub_task
fields causing a compile error and disabling Redis subscription logic; update
ws_route's WsSession construction to set redis_conn and redis_sub_task to None
if you want the quick fix, or preferably accept a
web::Data<Arc<ConnectionManager>> in the handler and pass that ConnectionManager
into the WsSession.redis_conn while spawning/assigning redis_sub_task (see
WsSession, redis_conn, redis_sub_task, started(), and ConnectionManager) so the
Redis subscription path is wired and functional.

292-315: ⚠️ Potential issue | 🔴 Critical

ReconnectToken will never reach the client.

In stopped(), the session actor is already terminating. Calling ctx.address().do_send(reconnect_msg) enqueues a message to an actor that is no longer accepting messages, so the Handler<WsMessage> that would serialize and write to ctx.text(...) never runs. The log::info!("Sent reconnection token ...") is misleading — nothing is actually sent over the socket.

Emit the reconnect token before teardown. Options:

  • Send it in Handler<ws::Message::Close> / when the heartbeat is about to terminate the connection (inside hb / StreamHandler), so it goes out over the wire before ctx.stop().
  • Or call ctx.text(...) directly in stopped(), though by then the write half is typically closed and this will also be a no-op for the peer.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/ws.rs` around lines 292 - 315, The reconnect token is
queued in stopped() using ctx.address().do_send(reconnect_msg) but the actor is
shutting down so Handler<WsMessage> never runs; move emission of the token to
before teardown (e.g., send it from the heartbeat/StreamHandler close branch or
inside the hb timeout path) by calling ctx.text(...) with the serialized
WsMessage::ReconnectToken or sending the reconnect message from within the
active handler that still writes to the socket (instead of using
ctx.address().do_send in stopped()); ensure generate_reconnect_token() is called
before ctx.stop()/teardown and remove the misleading log that claims delivery
after stopped().

218-228: ⚠️ Potential issue | 🔴 Critical

Duplicate hb field — this will fail to compile.

hb: std::time::Instant is declared on line 221 and again on line 227. Rust forbids duplicate struct fields, so this struct won't compile as-is. Additionally, the new fields redis_conn and redis_sub_task are pub, but hb is not — and the trailing private hb duplicate suggests an accidental leftover from a merge.

🐛 Proposed fix
 pub struct WsSession {
     pub game_id: String,
     pub lobby: Addr<LobbyState>,
     pub hb: std::time::Instant,
     pub user_id: i32,
     pub username: String,
     pub session_id: String,
     pub redis_conn: Option<Arc<ConnectionManager>>, // Redis connection for Pub/Sub
     pub redis_sub_task: Option<JoinHandle<()>>,     // Handle for the subscription task
-    hb: std::time::Instant,
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/ws.rs` around lines 218 - 228, Struct WsSession
currently declares hb twice which breaks compilation; remove the duplicated
private field and keep a single hb with the intended visibility (retain the
existing pub hb: std::time::Instant and delete the trailing hb:
std::time::Instant). Ensure visibility consistency with the other new fields
(redis_conn and redis_sub_task) and rebuild to confirm the duplicate is gone.

274-289: ⚠️ Potential issue | 🔴 Critical

Multiple compile and architectural errors in the Redis subscription task.

Three critical concerns:

  1. Line 279: if let Ok(payload): Result<String, _> = msg.get_payload() uses invalid type ascription syntax. Stable Rust doesn't support type ascription in patterns. Use if let Ok::<String, _>(payload) = msg.get_payload() or refactor to a separate let binding with explicit type.

  2. Line 275: redis_conn.clone().into_pubsub()redis::aio::ConnectionManager does not expose into_pubsub(). That method exists only on AsyncConnection. You'll need to store a dedicated pubsub connection separately from the ConnectionManager used for publishing, or use the RESP3-based pubsub API via ConnectionManagerConfig::set_push_sender().

  3. Line 278: stream.next().await requires futures::StreamExt (or futures_util::StreamExt) in scope. It is not currently imported in this file.

Additionally, there is no deduplication logic between Redis-forwarded messages and local broadcasts: if the same instance publishes on match:<game_id> (lines 336-339) and also has local subscribers, every session on the publishing instance will receive the message twice (once via broadcast, once via its own Redis subscription). Add instance_id tagging and self-origin filtering to prevent this.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/ws.rs` around lines 274 - 289, The Redis subscription
task is using unsupported pattern type-ascription and the wrong connection type
and missing StreamExt import, and it lacks self-origin deduplication; fix by:
replace the pattern `if let Ok(payload): Result<String, _> = msg.get_payload()`
with an explicit binding (e.g., `let payload: String = msg.get_payload()?` or
use `match msg.get_payload()`), replace `redis_conn.clone().into_pubsub()` with
a dedicated PubSub connection (create and store a separate async pubsub
connection/Client instead of using ConnectionManager or use the RESP3 push API
via ConnectionManagerConfig) and spawn the task using that pubsub connection
(update where `self.redis_sub_task` is set), add `use futures::StreamExt` so
`stream.next().await` resolves, and include an `instance_id` field in the
serialized WsMessage payload and filter out messages whose instance_id equals
this instance before calling `addr.do_send` to avoid duplicated local delivery.
backend/modules/api/src/auth.rs (4)

135-135: ⚠️ Potential issue | 🟡 Minor

expires_in: 3600 is hardcoded and can diverge from the actual JWT expiration.

JwtService is constructed with a configurable expiration_time, but the response advertises a fixed 3600. Clients using this value to schedule token refresh will drift once the configured lifetime differs from one hour. Surface the configured value via JwtService (add a getter) or environment variable instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/auth.rs` at line 135, The response currently
hardcodes expires_in: 3600; instead of reflecting the configured JWT lifetime;
add an accessor on JwtService (e.g., JwtService::expiration_time() or
get_expiration) that returns the configured expiration_time and replace the
literal 3600 in the response with that getter (locate where expires_in is set in
auth.rs and the JwtService implementation) so clients receive the actual token
lifetime.

141-150: ⚠️ Potential issue | 🟠 Major

Cookie secure(false) hardcoded — same issue in /refresh and /logout.

Three cookie builders in this file hardcode .secure(false) with a "set to true in production" comment. This is an easy footgun: a production deploy forgets to patch three places and ships with refresh-token cookies over plaintext. Drive this from config/env so there's a single source of truth.

🔧 Proposed fix
+fn cookie_secure() -> bool {
+    env::var("COOKIE_SECURE").map(|v| v != "false" && v != "0").unwrap_or(true)
+}
...
-        .secure(false) // Set to true in production HTTPS
+        .secure(cookie_secure())

Apply at all three sites (login, refresh, logout).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/auth.rs` around lines 141 - 150, Replace the three
hardcoded .secure(false) cookie builders with a config-driven flag: read a
boolean (e.g., cookie_secure or cookie_secure_cookie) from your app config/env
and use it when building the cookie in the handlers that create refresh_token
cookies (the places that call Cookie::build("refresh_token", ...) — i.e., the
login, refresh, and logout handlers). Update the cookie builders to call
.secure(cookie_secure) instead of .secure(false), ensure the config value is
parsed once and injected or globally available to these handlers, and keep the
existing comment and SameSite/max_age settings unchanged; also ensure
response.add_cookie(&cookie).ok() behavior remains the same.

247-256: ⚠️ Potential issue | 🟠 Major

Refresh silently drops session_id, breaking session tracking after the first refresh.

You call generate_token(...) here instead of generate_token_with_session(...). The result: the new access token has session_id: None and ip_hash: None, so any middleware that relies on claims.session_id to revoke/validate against SessionService will be bypassed on every request after a refresh. This defeats the core goal of issue #549.

You should also validate the session is still active before issuing a refreshed token.

🔧 Proposed fix
-    // Generate new access token
-    let new_access_token = match jwt_service.generate_token(claims.user_id, &claims.username) {
+    // Ensure the session is still active before issuing a new access token
+    if let Some(sid) = claims.session_id.as_ref().and_then(|s| Uuid::parse_str(s).ok()) {
+        if let Err(e) = SessionService::validate_session(&db, sid).await {
+            log::warn!("Refresh attempted against invalid session: {}", e);
+            return HttpResponse::Unauthorized().json(ErrorResponse {
+                message: "Session is no longer valid".to_string(),
+                code: "INVALID_SESSION".to_string(),
+            });
+        }
+    }
+
+    // Generate new access token, preserving session and ip binding claims
+    let new_access_token = match jwt_service.generate_token_with_session(
+        claims.user_id,
+        &claims.username,
+        claims.session_id.clone(),
+        claims.ip_hash.clone(),
+    ) {
         Ok(t) => t,
         ...
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/auth.rs` around lines 247 - 256, The refresh handler
is calling jwt_service.generate_token(...) which drops session details; update
it to call jwt_service.generate_token_with_session(...) (passing the existing
claims.session_id and claims.ip_hash) so the new_access_token preserves
session_id/ip_hash, and before generating the token validate the session via
SessionService (e.g., check SessionService::is_active or validate_session for
claims.session_id) and return an appropriate error HttpResponse (e.g.,
Unauthorized or TOKEN_ERROR) if the session is inactive or missing; reference
the existing variables claims, jwt_service, and SessionService when making these
changes.

339-351: ⚠️ Potential issue | 🟠 Major

Logout does not revoke the server-side session.

The handler revokes refresh tokens but never calls SessionService::revoke_session/revoke_all_user_sessions. With session-bound access tokens now in use, a stolen access token remains valid for the full access-token lifetime after logout — which directly contradicts the stated security goal.

Also note the pre-existing user_id = 1 MVP hack at line 342 makes this endpoint revoke the wrong user's state entirely. At minimum, once a real JWT validation is wired up, use claims.session_id to revoke the specific session here.

🔧 Proposed fix (sketch)
-    // We would validate token here, but for now just extract user_id from the request
-    // In a real implementation, we'd use a JWT service to validate and extract claims
-    // For MVP, we'll accept it and revoke for user_id 1
-    let user_id = 1;
+    let claims = match jwt_service.validate_token(token) {
+        Ok(c) => c,
+        Err(_) => return HttpResponse::Unauthorized().json(ErrorResponse {
+            message: "Invalid or expired access token".to_string(),
+            code: "INVALID_ACCESS_TOKEN".to_string(),
+        }),
+    };
+    let user_id = claims.user_id;
+
+    // Revoke the current session so the access token can no longer be used
+    if let Some(sid) = claims.session_id.as_ref().and_then(|s| Uuid::parse_str(s).ok()) {
+        if let Err(e) = SessionService::revoke_session(&db, sid).await {
+            log::error!("Failed to revoke session: {}", e);
+        }
+    }

(You'll need to add jwt_service: web::Data<JwtService> to the handler signature.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/auth.rs` around lines 339 - 351, The logout handler
currently uses a hardcoded user_id and only calls
TokenService::revoke_player_tokens, leaving session-bound access tokens valid;
update the handler to validate the JWT (add jwt_service: web::Data<JwtService>
to the function signature), extract the claims (including claims.session_id and
claims.user_id), replace the user_id = 1 hack with the real claims.user_id, call
SessionService::revoke_session(claims.session_id) (or
SessionService::revoke_all_user_sessions(claims.user_id) if you intend
full-account logout) and then call TokenService::revoke_player_tokens(db,
claims.user_id); ensure error handling/logging mirrors the existing pattern when
either revocation fails.
backend/modules/security/src/jwt.rs (2)

81-91: ⚠️ Potential issue | 🟡 Minor

Using session_id as jti conflates session and token identity.

Setting jti = session_id.clone() means every access token minted for the same session shares the same JWT ID. This defeats per-token revocation (e.g., block-listing a leaked access token without nuking the whole session) and breaks RFC 7519's expectation that jti is unique per issued token. Consider generating a fresh UUID for jti and keeping session_id strictly in its dedicated claim.

🔧 Proposed fix
         let claims = Claims {
             sub: user_id.to_string(),
             user_id,
             username: username.to_string(),
             exp: now + self.expiration_time,
             iat: now,
-            jti: session_id.clone(),
+            jti: Some(uuid::Uuid::new_v4().to_string()),
             token_type: TokenType::Access,
             session_id,
             ip_hash,
         };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/security/src/jwt.rs` around lines 81 - 91, The Claims
construction sets jti = session_id.clone(), conflating token ID with session ID;
change this so jti is a fresh unique token identifier (e.g., generate a new UUID
via Uuid::new_v4().to_string()) while leaving session_id in its own claim and
keeping token_type as TokenType::Access; update any code that relied on jti
equaling session_id to use session_id explicitly or to perform token-level
revocation using the new jti.

109-117: ⚠️ Potential issue | 🔴 Critical

Critical: generate_reconnect_token will not compile — missing session_id and ip_hash field initializations in Claims literal.

The Claims struct (lines 32, 34) requires both session_id and ip_hash fields to be explicitly initialized in every struct literal, even though they are Option<T>. The Claims construction at lines 109–117 omits both fields, causing a compile error.

🔧 Proposed fix
         let claims = Claims {
             sub: user_id.to_string(),
             user_id,
             username: username.to_string(),
             exp: now + self.reconnect_expiration_time,
             iat: now,
             jti: Some(session_id.to_string()),
             token_type: TokenType::Reconnect,
+            session_id: Some(session_id.to_string()),
+            ip_hash: None,
         };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/security/src/jwt.rs` around lines 109 - 117, The Claims
struct literal in generate_reconnect_token is missing the required Option fields
session_id and ip_hash; update the Claims construction to include session_id:
Some(session_id.to_string()) and set ip_hash to an appropriate Option value
(e.g., ip_hash: None if no hash is available here or ip_hash:
Some(computed_hash) when you can compute one) so the struct initializes all
required fields.
🧹 Nitpick comments (4)
backend/modules/api/src/pgn_archive_api.rs (1)

51-51: Share PgnArchiveService via app_data instead of instantiating per request.

Both handlers call PgnArchiveService::new() on every request, which in turn builds a fresh reqwest::Client. Each reqwest::Client owns its own connection pool, so per-request construction defeats HTTPS connection reuse to Pinata/the bundler and adds TLS-handshake latency on the hot path. Construct a single PgnArchiveService in server::main, register it with .app_data(web::Data::new(archive_service.clone())), and inject it into the handlers.

Also applies to: 87-87

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/pgn_archive_api.rs` at line 51, Handlers are creating
a new PgnArchiveService per request (via PgnArchiveService::new()), which builds
a fresh reqwest::Client and prevents connection reuse; instead, construct a
single PgnArchiveService in server::main, wrap it with
web::Data::new(archive_service.clone()) and register it via .app_data(...), then
change the handlers to accept web::Data<PgnArchiveService> (or Arc/Cloneable
wrapper) from their parameters rather than calling PgnArchiveService::new()
inside each handler so the shared reqwest::Client is reused across requests.
backend/modules/api/src/pgn_archive.rs (2)

160-208: Redundant SHA-256 computation; also consider what “success” means when one provider fails.

Two small things on this path:

  1. calculate_hash(pgn_content) runs on line 165, and then archive_to_arweave re-hashes the same content on line 125. For large PGNs and batch calls this is wasted work — compute once and pass the hash into archive_to_arweave.
  2. The handler returns Ok(...) whenever either provider succeeds, with the failing side silently set to None. That’s a reasonable resilience choice, but the caller has no machine-readable signal of partial failure other than inspecting ipfs_cid / arweave_tx_id. Consider including per-service status (or an errors: Vec<String>) in PgnArchiveResult so clients/monitors can alert on “archived to only one store”.
♻️ Proposed de-duplication of hashing
-    pub async fn archive_to_arweave(&self, pgn_content: &str) -> Result<String, PgnArchiveError> {
+    pub async fn archive_to_arweave(
+        &self,
+        pgn_content: &str,
+        pgn_hash: &str,
+    ) -> Result<String, PgnArchiveError> {
         let arweave_url = env::var("ARWEAVE_GATEWAY_URL")
             .unwrap_or_else(|_| "https://arweave.net".to_string());
         ...
-        let pgn_hash = self.calculate_hash(pgn_content);
-
         let payload = serde_json::json!({
             "data": pgn_content,
             "tags": [
                 { "name": "Content-Type", "value": "application/x-chess-pgn" },
                 { "name": "App", "value": "XLMate" },
-                { "name": "PGN-Hash", "value": &pgn_hash },
+                { "name": "PGN-Hash", "value": pgn_hash },
                 { "name": "Archived-At", "value": &chrono::Utc::now().to_rfc3339() }
             ]
         });

And in archive_pgn:

-        let ipfs_future = self.archive_to_ipfs(pgn_content);
-        let arweave_future = self.archive_to_arweave(pgn_content);
+        let ipfs_future = self.archive_to_ipfs(pgn_content);
+        let arweave_future = self.archive_to_arweave(pgn_content, &pgn_hash);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/pgn_archive.rs` around lines 160 - 208, Compute the
PGN hash once in archive_pgn using calculate_hash(pgn_content) and pass that
hash into archive_to_arweave (update the archive_to_arweave signature/uses) to
avoid duplicate hashing; also extend the PgnArchiveResult struct to include
per-service status or an errors: Vec<String> field (e.g., record "ipfs_failed:
bool" / "arweave_failed: bool" or push error messages) and populate it in
archive_pgn where ipfs_result and arweave_result are matched so callers can
detect partial failures, while keeping PgnArchiveError for the case where both
services fail.

69-77: Read config once at startup and fail fast on missing IPFS credentials.

env::var("IPFS_API_URL" / "IPFS_API_KEY" / "IPFS_SECRET_KEY") is called on every archive request, and missing keys silently default to empty strings. The result is:

  • The handler still POSTs to Pinata with empty pinata_api_key / pinata_secret_api_key headers, wasting a network round-trip and returning an opaque non-2xx error to the user.
  • Ops cannot tell a misconfiguration apart from an upstream outage in logs.

Prefer loading these once (e.g., in server::main alongside the other env::var reads — see backend/modules/api/src/server.rs:45-79) into a config struct passed to PgnArchiveService::new(...), and log a clear warning (or disable IPFS archival) if the keys are absent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/pgn_archive.rs` around lines 69 - 77, The code
currently reads IPFS env vars inside archive_to_ipfs and defaults missing keys
to empty strings; change this to read IPFS_API_URL, IPFS_API_KEY and
IPFS_SECRET_KEY once at startup (e.g., in server::main) into a Config struct,
pass that Config into PgnArchiveService via PgnArchiveService::new(...), and
update archive_to_ipfs to use the stored config values instead of calling
env::var; if keys are missing make startup fail fast or explicitly disable IPFS
archival and log a clear, actionable warning indicating missing credentials so
requests no longer attempt a wasted POST with empty headers.
backend/modules/security/src/session_service.rs (1)

72-101: Consider extending validate_session to cross-check user and/or IP binding.

Today the function is pure "does this session_id exist and isn't dead" — it doesn't verify that the requester owns the session. Once IP binding (ip_hash) is actually populated in tokens (see the auth.rs comment on ip_hash), this validator is the natural place to compare claims.ip_hash / claims.user_id against session_record.ip_address / session_record.user_id and return SessionNotFound or a new SessionMismatch error on drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/security/src/session_service.rs` around lines 72 - 101,
Extend validate_session to verify the requester actually owns the session by
accepting the JWT claims (or at least claims.user_id and claims.ip_hash) and
comparing them to the loaded session_record's user_id and ip_address/ip_hash; if
they mismatch return SessionError::SessionNotFound or introduce and return
SessionError::SessionMismatch as appropriate. Locate the function
validate_session and its use of session_record (session::Entity::find,
session_record.ok_or(...)), add parameters for the claim values (e.g., user_id
and ip_hash), perform equality checks against session_record.user_id and
session_record.ip_address/ip_hash before the expired/revoked checks (or after,
per policy), and ensure callers (e.g., auth code that validates tokens) pass the
claims through; update error handling to return the new SessionMismatch variant
(and add that variant to SessionError) when a binding check fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/modules/api/src/auth.rs`:
- Around line 77-101: The login handler extracts ip_address but never computes
or passes ip_hash to the token, so compute a server-side peppered SHA-256 (e.g.,
sha256(pepper + ip)) using a secret from env (e.g., JWT_IP_PEPPER), convert to
hex/base64 and pass that value into jwt_service.generate_token_with_session
(where Claims.ip_hash is expected) instead of None; ensure
SessionService::create_session still gets the raw or stored IP as needed, and
implement middleware in your JWT validation path that recomputes the same
peppered hash from req.connection_info().real_ip_remote_addr() and compares it
to claims.ip_hash, rejecting requests when they differ.
- Line 78: The current use of req.connection_info().real_ip_remote_addr() can be
spoofed; replace it by using peer_addr() for direct deployments or, if behind a
trusted proxy, validate that peer_addr() is within configured trusted proxy
CIDRs before extracting and trusting X-Forwarded-For, and only then parse the
leftmost address; update the call site that sets ip_address (currently calling
req.connection_info().real_ip_remote_addr()) and ensure
generate_token_with_session() receives a validated ip_hash (not None) so
sessions store a concrete IP hash, and apply the same validation/assignment
logic consistently in the login, refresh, and validate handlers so IP-based
session checks work end-to-end.

In `@backend/modules/api/src/pgn_archive_api.rs`:
- Around line 83-111: The batch_archive_pgn handler currently accepts an
unbounded Vec<ArchivePgnRequest> and processes each request serially via
PgnArchiveService::archive_pgn, which is a DoS/amplification risk; enforce an
explicit max batch size (e.g., reject payloads with more than 50 items and
return HttpResponse::PayloadTooLarge()) and ensure the Actix JSON extractor
scope has an appropriate limit configured so you don't rely on the default 32
KiB. Replace the serial for-loop awaiting archive_pgn with concurrent execution
(e.g., use futures or tokio: create tasks for each ArchivePgnRequest calling
PgnArchiveService::archive_pgn and run them with
futures::stream::iter(...).map(...).buffer_unordered(CONCURRENCY).collect or
tokio::JoinSet) with a reasonable concurrency bound (e.g., 8–16) to avoid
unbounded parallelism, then gather the ArchivePgnResponse results and return
them as before; keep error handling/logging semantics for each individual
result.

In `@backend/modules/api/src/pgn_archive.rs`:
- Around line 62-66: The PgnArchiveService currently creates a reqwest::Client
with Client::new() which has no connect or overall request timeouts; update
PgnArchiveService::new to build the client via reqwest::Client::builder() and
set sensible connect_timeout and timeout values (e.g., a few seconds for
connect_timeout and a shorter overall request timeout) before calling .build(),
then assign that client to the client field so archive_pgn calls cannot block
Actix threads indefinitely.
- Around line 115-157: The archive_to_arweave function currently posts an
unsigned JSON payload to the bundler /tx endpoint which will 4xx in production;
fix by either implementing proper ANS-104 signing and sending a binary DataItem
with Content-Type: application/octet-stream (use an Arweave/Bundlr SDK to
create/sign the DataItem before POST) inside archive_to_arweave, or feature‑gate
the Arweave path (e.g., check an env var like ARWEAVE_ENABLED in
archive_to_arweave or the caller archive_pgn) and immediately return a clear
PgnArchiveError variant (e.g., NotSupported/Disabled) if not implemented; also
ensure archive_pgn no longer silently degrades by propagating the explicit error
from archive_to_arweave instead of hiding it.

In `@backend/modules/api/src/ws.rs`:
- Around line 56-83: The LobbyState::with_redis variant is never used so
redis_conn stays None; modify the server startup to create a Redis
ConnectionManager, pass its Arc clone into
LobbyState::with_redis(redis_conn.clone()) instead of LobbyState::new(), and
only fall back to LobbyState::new() when Redis configuration/env is absent; also
emit a startup log indicating whether the app started in "distributed (redis)"
or "local" mode so the publish/subscribe paths in Connect/Disconnect see an
active redis_conn.
- Around line 58-63: The module currently contains dead code: the HashMap field
connection_counts, the helper functions publish_connection_event,
get_connection_count, get_total_connections, and the AtomicUsize import are
unused while Connect/Disconnect inline duplicate JSON/publish logic; either
remove them entirely or wire them up. Recommend: drop connection_counts and the
atomic import if you won't expose per-game metrics, and consolidate the
duplicated publish logic by replacing the inline actix::spawn blocks in the
Connect and Disconnect handlers with calls to a single async helper
publish_connection_event(&msg.game_id, "connected" | "disconnected",
count).await (expose a small sync adapter that spawns the async helper from the
sync handler), have that helper build the JSON payload once using instance_id
and redis_conn, and remove the unused get_connection_count/get_total_connections
or add a message handler that calls them if you need to expose counts; update
references from sessions where necessary to compute count before calling the
helper.
- Around line 122-156: The current handle (fn handle with Connect) spawns a task
(actix::spawn) that publishes a snapshot (count) to Redis, which can be observed
out-of-order by subscribers; fix by either (A) serializing publishes per game_id
by introducing a dedicated publisher actor/task/mailbox keyed on game_id that
receives publish requests (instead of spawning per event) so order is preserved,
or (B) make events idempotent/ordering-safe by adding a monotonic sequence
and/or authoritative timestamp to the payload (use a per-game AtomicU64 or Redis
INCR) and include that sequence/timestamp alongside instance_id, game_id and
count so subscribers can reconcile by “highest sequence/timestamp wins”; update
the code that currently uses redis_conn, game_id, count and actix::spawn to use
the chosen approach.

In `@backend/modules/security/src/session_service.rs`:
- Around line 147-156: The cleanup_expired_sessions function is never scheduled;
wire it into app startup as a periodic background task that calls
session_service::cleanup_expired_sessions(db) on an interval (e.g.,
tokio::time::interval or actix_rt::spawn with a loop) so expired sessions are
removed regularly; ensure you pass a cloneable/shared DatabaseConnection (Arc or
DatabaseConnection::clone if supported) or a connection pool into the task,
handle/ log any SessionError returned (don’t unwrap), and choose an appropriate
interval (e.g., hourly) and graceful shutdown behavior so the task is started
once during server bootstrap (e.g., in your server startup/init function).

---

Outside diff comments:
In `@backend/modules/api/src/auth.rs`:
- Line 135: The response currently hardcodes expires_in: 3600; instead of
reflecting the configured JWT lifetime; add an accessor on JwtService (e.g.,
JwtService::expiration_time() or get_expiration) that returns the configured
expiration_time and replace the literal 3600 in the response with that getter
(locate where expires_in is set in auth.rs and the JwtService implementation) so
clients receive the actual token lifetime.
- Around line 141-150: Replace the three hardcoded .secure(false) cookie
builders with a config-driven flag: read a boolean (e.g., cookie_secure or
cookie_secure_cookie) from your app config/env and use it when building the
cookie in the handlers that create refresh_token cookies (the places that call
Cookie::build("refresh_token", ...) — i.e., the login, refresh, and logout
handlers). Update the cookie builders to call .secure(cookie_secure) instead of
.secure(false), ensure the config value is parsed once and injected or globally
available to these handlers, and keep the existing comment and SameSite/max_age
settings unchanged; also ensure response.add_cookie(&cookie).ok() behavior
remains the same.
- Around line 247-256: The refresh handler is calling
jwt_service.generate_token(...) which drops session details; update it to call
jwt_service.generate_token_with_session(...) (passing the existing
claims.session_id and claims.ip_hash) so the new_access_token preserves
session_id/ip_hash, and before generating the token validate the session via
SessionService (e.g., check SessionService::is_active or validate_session for
claims.session_id) and return an appropriate error HttpResponse (e.g.,
Unauthorized or TOKEN_ERROR) if the session is inactive or missing; reference
the existing variables claims, jwt_service, and SessionService when making these
changes.
- Around line 339-351: The logout handler currently uses a hardcoded user_id and
only calls TokenService::revoke_player_tokens, leaving session-bound access
tokens valid; update the handler to validate the JWT (add jwt_service:
web::Data<JwtService> to the function signature), extract the claims (including
claims.session_id and claims.user_id), replace the user_id = 1 hack with the
real claims.user_id, call SessionService::revoke_session(claims.session_id) (or
SessionService::revoke_all_user_sessions(claims.user_id) if you intend
full-account logout) and then call TokenService::revoke_player_tokens(db,
claims.user_id); ensure error handling/logging mirrors the existing pattern when
either revocation fails.

In `@backend/modules/api/src/ws.rs`:
- Around line 405-420: WsSession is being constructed in ws_route without
initializing its redis_conn and redis_sub_task fields causing a compile error
and disabling Redis subscription logic; update ws_route's WsSession construction
to set redis_conn and redis_sub_task to None if you want the quick fix, or
preferably accept a web::Data<Arc<ConnectionManager>> in the handler and pass
that ConnectionManager into the WsSession.redis_conn while spawning/assigning
redis_sub_task (see WsSession, redis_conn, redis_sub_task, started(), and
ConnectionManager) so the Redis subscription path is wired and functional.
- Around line 292-315: The reconnect token is queued in stopped() using
ctx.address().do_send(reconnect_msg) but the actor is shutting down so
Handler<WsMessage> never runs; move emission of the token to before teardown
(e.g., send it from the heartbeat/StreamHandler close branch or inside the hb
timeout path) by calling ctx.text(...) with the serialized
WsMessage::ReconnectToken or sending the reconnect message from within the
active handler that still writes to the socket (instead of using
ctx.address().do_send in stopped()); ensure generate_reconnect_token() is called
before ctx.stop()/teardown and remove the misleading log that claims delivery
after stopped().
- Around line 218-228: Struct WsSession currently declares hb twice which breaks
compilation; remove the duplicated private field and keep a single hb with the
intended visibility (retain the existing pub hb: std::time::Instant and delete
the trailing hb: std::time::Instant). Ensure visibility consistency with the
other new fields (redis_conn and redis_sub_task) and rebuild to confirm the
duplicate is gone.
- Around line 274-289: The Redis subscription task is using unsupported pattern
type-ascription and the wrong connection type and missing StreamExt import, and
it lacks self-origin deduplication; fix by: replace the pattern `if let
Ok(payload): Result<String, _> = msg.get_payload()` with an explicit binding
(e.g., `let payload: String = msg.get_payload()?` or use `match
msg.get_payload()`), replace `redis_conn.clone().into_pubsub()` with a dedicated
PubSub connection (create and store a separate async pubsub connection/Client
instead of using ConnectionManager or use the RESP3 push API via
ConnectionManagerConfig) and spawn the task using that pubsub connection (update
where `self.redis_sub_task` is set), add `use futures::StreamExt` so
`stream.next().await` resolves, and include an `instance_id` field in the
serialized WsMessage payload and filter out messages whose instance_id equals
this instance before calling `addr.do_send` to avoid duplicated local delivery.

In `@backend/modules/security/src/jwt.rs`:
- Around line 81-91: The Claims construction sets jti = session_id.clone(),
conflating token ID with session ID; change this so jti is a fresh unique token
identifier (e.g., generate a new UUID via Uuid::new_v4().to_string()) while
leaving session_id in its own claim and keeping token_type as TokenType::Access;
update any code that relied on jti equaling session_id to use session_id
explicitly or to perform token-level revocation using the new jti.
- Around line 109-117: The Claims struct literal in generate_reconnect_token is
missing the required Option fields session_id and ip_hash; update the Claims
construction to include session_id: Some(session_id.to_string()) and set ip_hash
to an appropriate Option value (e.g., ip_hash: None if no hash is available here
or ip_hash: Some(computed_hash) when you can compute one) so the struct
initializes all required fields.

---

Nitpick comments:
In `@backend/modules/api/src/pgn_archive_api.rs`:
- Line 51: Handlers are creating a new PgnArchiveService per request (via
PgnArchiveService::new()), which builds a fresh reqwest::Client and prevents
connection reuse; instead, construct a single PgnArchiveService in server::main,
wrap it with web::Data::new(archive_service.clone()) and register it via
.app_data(...), then change the handlers to accept web::Data<PgnArchiveService>
(or Arc/Cloneable wrapper) from their parameters rather than calling
PgnArchiveService::new() inside each handler so the shared reqwest::Client is
reused across requests.

In `@backend/modules/api/src/pgn_archive.rs`:
- Around line 160-208: Compute the PGN hash once in archive_pgn using
calculate_hash(pgn_content) and pass that hash into archive_to_arweave (update
the archive_to_arweave signature/uses) to avoid duplicate hashing; also extend
the PgnArchiveResult struct to include per-service status or an errors:
Vec<String> field (e.g., record "ipfs_failed: bool" / "arweave_failed: bool" or
push error messages) and populate it in archive_pgn where ipfs_result and
arweave_result are matched so callers can detect partial failures, while keeping
PgnArchiveError for the case where both services fail.
- Around line 69-77: The code currently reads IPFS env vars inside
archive_to_ipfs and defaults missing keys to empty strings; change this to read
IPFS_API_URL, IPFS_API_KEY and IPFS_SECRET_KEY once at startup (e.g., in
server::main) into a Config struct, pass that Config into PgnArchiveService via
PgnArchiveService::new(...), and update archive_to_ipfs to use the stored config
values instead of calling env::var; if keys are missing make startup fail fast
or explicitly disable IPFS archival and log a clear, actionable warning
indicating missing credentials so requests no longer attempt a wasted POST with
empty headers.

In `@backend/modules/security/src/session_service.rs`:
- Around line 72-101: Extend validate_session to verify the requester actually
owns the session by accepting the JWT claims (or at least claims.user_id and
claims.ip_hash) and comparing them to the loaded session_record's user_id and
ip_address/ip_hash; if they mismatch return SessionError::SessionNotFound or
introduce and return SessionError::SessionMismatch as appropriate. Locate the
function validate_session and its use of session_record (session::Entity::find,
session_record.ok_or(...)), add parameters for the claim values (e.g., user_id
and ip_hash), perform equality checks against session_record.user_id and
session_record.ip_address/ip_hash before the expired/revoked checks (or after,
per policy), and ensure callers (e.g., auth code that validates tokens) pass the
claims through; update error handling to return the new SessionMismatch variant
(and add that variant to SessionError) when a binding check fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dc71c835-18e0-4e4f-a85c-18549ff16ccb

📥 Commits

Reviewing files that changed from the base of the PR and between 1abf09b and aad986a.

📒 Files selected for processing (14)
  • backend/modules/api/Cargo.toml
  • backend/modules/api/src/auth.rs
  • backend/modules/api/src/lib.rs
  • backend/modules/api/src/pgn_archive.rs
  • backend/modules/api/src/pgn_archive_api.rs
  • backend/modules/api/src/server.rs
  • backend/modules/api/src/ws.rs
  • backend/modules/db/entity/mod.rs
  • backend/modules/db/entity/src/session.rs
  • backend/modules/db/migrations/src/lib.rs
  • backend/modules/db/migrations/src/m20260224_000000_create_sessions_table.rs
  • backend/modules/security/src/jwt.rs
  • backend/modules/security/src/lib.rs
  • backend/modules/security/src/session_service.rs

Comment on lines +77 to +101
// Extract IP address and user agent for session tracking
let ip_address = req.connection_info().real_ip_remote_addr().map(|s| s.to_string());
let user_agent = req.headers().get("User-Agent")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());

// Create a new session
let session_ttl = env::var("SESSION_TTL_HOURS")
.unwrap_or_else(|_| "168".to_string()) // 7 days default
.parse::<i64>()
.unwrap_or(168);

let session_id = match SessionService::create_session(&db, user_id, ip_address, user_agent, session_ttl).await {
Ok(sid) => sid,
Err(e) => {
log::error!("Failed to create session: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse {
message: "Failed to create session".to_string(),
code: "SESSION_ERROR".to_string(),
});
}
};

// Generate access token with session ID
let access_token = match jwt_service.generate_token_with_session(user_id, &username, Some(session_id.to_string()), None) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ip_hash is never computed — the advertised "IP binding" in JWT is a no-op.

The PR objective explicitly promises "IP binding to mitigate token theft", and Claims now carries ip_hash, but the login handler passes None. Even the ip_address you just extracted on line 78 is not being hashed into the token. No downstream validator can enforce binding if the claim is never populated.

Hash the IP (e.g., SHA-256 with a server-side pepper to avoid exposing raw IPs in logs/tokens) and thread it into generate_token_with_session. You'll also need middleware that cross-checks the incoming request IP's hash against claims.ip_hash — otherwise this remains decorative.

🔧 Proposed fix (login handler)
     // Extract IP address and user agent for session tracking
     let ip_address = req.connection_info().real_ip_remote_addr().map(|s| s.to_string());
+    let ip_hash = ip_address.as_deref().map(|ip| {
+        use sha2::{Digest, Sha256};
+        let pepper = env::var("IP_HASH_PEPPER").unwrap_or_default();
+        let mut hasher = Sha256::new();
+        hasher.update(pepper.as_bytes());
+        hasher.update(ip.as_bytes());
+        format!("{:x}", hasher.finalize())
+    });
     let user_agent = req.headers().get("User-Agent")
         ...

-    let access_token = match jwt_service.generate_token_with_session(user_id, &username, Some(session_id.to_string()), None) {
+    let access_token = match jwt_service.generate_token_with_session(user_id, &username, Some(session_id.to_string()), ip_hash) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/auth.rs` around lines 77 - 101, The login handler
extracts ip_address but never computes or passes ip_hash to the token, so
compute a server-side peppered SHA-256 (e.g., sha256(pepper + ip)) using a
secret from env (e.g., JWT_IP_PEPPER), convert to hex/base64 and pass that value
into jwt_service.generate_token_with_session (where Claims.ip_hash is expected)
instead of None; ensure SessionService::create_session still gets the raw or
stored IP as needed, and implement middleware in your JWT validation path that
recomputes the same peppered hash from
req.connection_info().real_ip_remote_addr() and compares it to claims.ip_hash,
rejecting requests when they differ.

// Generate access token
let access_token = match jwt_service.generate_token(user_id, &username) {
// Extract IP address and user agent for session tracking
let ip_address = req.connection_info().real_ip_remote_addr().map(|s| s.to_string());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

actix-web ConnectionInfo real_ip_remote_addr spoofing X-Forwarded-For trusted proxy

💡 Result:

actix-web ConnectionInfo does not provide a built-in “trusted proxy” allowlist; it will read client IP information from spoofable headers by default unless you control who can set them (e.g., by stripping/overwriting at a reverse proxy). 1) What ConnectionInfo uses for real_ip_remote_addr - ConnectionInfo::realip_remote_addr resolves the “real IP” (remote address of the client) in this order: - Forwarded header (RFC 7239) - X-Forwarded-For header - peer address from the opened socket (same as peer_addr) This behavior is defined in actix-web’s ConnectionInfo docs/source. 2) Spoofing risk (no trust model) - The docs explicitly warn: do not use realip_remote_addr for security purposes unless you can be sure the Forwarded and X-Forwarded-For headers cannot be spoofed by the client. If you’re running without a proxy, obtaining the peer address would be more appropriate. - actix-web also notes that Forwarded/X-Forwarded-* values expose privacy-sensitive info (like client IP) and must be considered when deploying. 3) “trusted proxy” guidance for X-Forwarded-For - Because actix-web’s ConnectionInfo simply parses these headers, your application needs a deployment-level strategy to ensure only your reverse proxy can influence them. Typical approach: - Configure your reverse proxy to overwrite X-Forwarded-For (and Forwarded if used) and to remove any incoming copies from the client. - Ensure the actix-web server is not directly reachable from untrusted networks (only through the reverse proxy / internal network). - Then, it is safe to treat realip_remote_addr as the proxy-supplied client IP for logging/diagnostics; for security decisions, still prefer using peer_addr or implement explicit trust logic yourself. 4) actix-web-related misconception: “trusted proxy” doesn’t exist in ConnectionInfo - There is an open discussion/issue specifically highlighting that realip_remote_addr behavior can lead to insecure code when apps use it unconditionally. 5) Practical options if you need explicit trust - actix-web itself: no built-in trusted-proxy config for ConnectionInfo. - If you need “trusted proxy” behavior, you generally implement it via: - middleware that extracts IP only when the request comes from known proxy IPs, or - custom header parsing with an allowlist of proxy addresses (not shown in actix-web ConnectionInfo docs). Example takeaway: If you want “X-Forwarded-For trusted proxy” semantics, you must enforce them outside ConnectionInfo (reverse proxy configuration + network isolation, or custom middleware/logic).

Citations:


🏁 Script executed:

# First, let's examine the exact code at line 78 with surrounding context
sed -n '70,90p' backend/modules/api/src/auth.rs

Repository: NOVUS-X/XLMate

Length of output: 793


🏁 Script executed:

# Search for session.ip_address usage to understand the impact scope
rg 'session.*ip_address|ip_address.*session' -i -A 2 -B 2

Repository: NOVUS-X/XLMate

Length of output: 405


🏁 Script executed:

# Search for IP binding or trusted proxy validation patterns
rg 'ip.*bind|trusted.*proxy|proxy.*ip|X-Forwarded-For' -i -A 2 -B 2

Repository: NOVUS-X/XLMate

Length of output: 742


🏁 Script executed:

# Search for other uses of real_ip_remote_addr or peer_addr in the codebase
rg 'real_ip_remote_addr|peer_addr|connection_info' -i -A 2

Repository: NOVUS-X/XLMate

Length of output: 1657


🏁 Script executed:

# Find and examine SessionService implementation
fd 'SessionService|session.*service' -i --type f | head -20

Repository: NOVUS-X/XLMate

Length of output: 106


🏁 Script executed:

# Look for the create_session method implementation
rg 'fn create_session|impl SessionService' -A 15 -B 2

Repository: NOVUS-X/XLMate

Length of output: 1671


🏁 Script executed:

# Check if IP is validated on session validation/refresh
rg 'validate.*session|refresh.*session|session.*valid' -i -A 5 -B 2

Repository: NOVUS-X/XLMate

Length of output: 808


🏁 Script executed:

# Search for proxy configuration or middleware
rg 'proxy|middleware|X-Forwarded|Forwarded' -i --type f

Repository: NOVUS-X/XLMate

Length of output: 84


🏁 Script executed:

# Look for proxy or middleware configuration without the invalid --type flag
rg 'proxy|middleware|X-Forwarded|Forwarded' -i -A 2 -B 2

Repository: NOVUS-X/XLMate

Length of output: 11672


🏁 Script executed:

# Get the full validate_session implementation
sed -n '1,200p' backend/modules/security/src/session_service.rs | grep -A 30 'fn validate_session'

Repository: NOVUS-X/XLMate

Length of output: 1004


🏁 Script executed:

# Check if IP validation happens during request processing
rg 'ip_address.*validate|validate.*ip|ip.*compare|ip.*mismatch' -i -A 3 -B 2

Repository: NOVUS-X/XLMate

Length of output: 513


🏁 Script executed:

# Search for generate_token_with_session to see if IP binding is actually used
rg 'generate_token_with_session|ip_hash' -A 10 -B 2

Repository: NOVUS-X/XLMate

Length of output: 4085


Remove real_ip_remote_addr() or validate it through a trusted proxy.

ConnectionInfo::real_ip_remote_addr() reads the leftmost X-Forwarded-For or Forwarded header without validation. Any direct client can send X-Forwarded-For: 8.8.8.8 and poison the stored ip_address. This is amplified by the incomplete IP binding feature—generate_token_with_session() currently passes ip_hash: None, so the session IP is stored but never validated on refresh/validate, leaving spoofing undetected.

Use peer_addr() for direct deployments, or if behind a proxy, validate that the request's peer_addr matches a configured trusted proxy CIDR before accepting X-Forwarded-For. Also implement consistent IP validation across login, refresh, and validate endpoints (currently missing).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/auth.rs` at line 78, The current use of
req.connection_info().real_ip_remote_addr() can be spoofed; replace it by using
peer_addr() for direct deployments or, if behind a trusted proxy, validate that
peer_addr() is within configured trusted proxy CIDRs before extracting and
trusting X-Forwarded-For, and only then parse the leftmost address; update the
call site that sets ip_address (currently calling
req.connection_info().real_ip_remote_addr()) and ensure
generate_token_with_session() receives a validated ip_hash (not None) so
sessions store a concrete IP hash, and apply the same validation/assignment
logic consistently in the login, refresh, and validate handlers so IP-based
session checks work end-to-end.

Comment on lines +83 to +111
#[post("/batch-archive-pgn")]
pub async fn batch_archive_pgn(
payload: web::Json<Vec<ArchivePgnRequest>>,
) -> HttpResponse {
let archive_service = PgnArchiveService::new();
let mut results = Vec::new();

for request in payload.into_inner() {
match archive_service.archive_pgn(&request.game_id, &request.pgn_content).await {
Ok(archive_result) => {
results.push(ArchivePgnResponse {
success: true,
archive_result: Some(archive_result),
message: "PGN archived successfully".to_string(),
});
}
Err(e) => {
log::warn!("Failed to archive PGN for game {}: {}", request.game_id, e);
results.push(ArchivePgnResponse {
success: false,
archive_result: None,
message: format!("Failed to archive: {}", e),
});
}
}
}

HttpResponse::Ok().json(results)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Batch endpoint is an unbounded amplification vector — cap and parallelize.

Two problems with this handler:

  1. No size limit. A single authenticated client can POST, say, 100k ArchivePgnRequest entries; each triggers two external HTTP calls (IPFS + Arweave). That’s trivially a DoS amplifier against both this service and the upstream providers (and will blow through any Pinata quota). Enforce a small, explicit max (e.g., 50) and reject 413 Payload Too Large otherwise. Also ensure the actix JSON payload limit is configured on this scope (default is 32 KiB, which may already clip it, but the check shouldn’t rely on that).
  2. Serial await inside a for loop. Each iteration blocks the next; for N items the wall-clock is ~N·(IPFS+Arweave latency). Use futures::future::join_all (or buffered) to run them concurrently (optionally with a concurrency bound).
🛠️ Suggested fix (cap + concurrent execution)
+const MAX_BATCH_SIZE: usize = 50;
+
 #[post("/batch-archive-pgn")]
 pub async fn batch_archive_pgn(
     payload: web::Json<Vec<ArchivePgnRequest>>,
 ) -> HttpResponse {
-    let archive_service = PgnArchiveService::new();
-    let mut results = Vec::new();
-
-    for request in payload.into_inner() {
-        match archive_service.archive_pgn(&request.game_id, &request.pgn_content).await {
-            ...
-        }
-    }
-
-    HttpResponse::Ok().json(results)
+    let items = payload.into_inner();
+    if items.len() > MAX_BATCH_SIZE {
+        return HttpResponse::PayloadTooLarge().json(ErrorResponse {
+            message: format!("Batch size exceeds limit of {}", MAX_BATCH_SIZE),
+            code: "BATCH_TOO_LARGE".to_string(),
+        });
+    }
+
+    let archive_service = PgnArchiveService::new();
+    let futures = items.into_iter().map(|request| {
+        let svc = archive_service.clone();
+        async move {
+            match svc.archive_pgn(&request.game_id, &request.pgn_content).await {
+                Ok(archive_result) => ArchivePgnResponse {
+                    success: true,
+                    archive_result: Some(archive_result),
+                    message: "PGN archived successfully".to_string(),
+                },
+                Err(e) => {
+                    log::warn!("Failed to archive PGN for game {}: {}", request.game_id, e);
+                    ArchivePgnResponse {
+                        success: false,
+                        archive_result: None,
+                        message: format!("Failed to archive: {}", e),
+                    }
+                }
+            }
+        }
+    });
+    let results: Vec<_> = futures::future::join_all(futures).await;
+    HttpResponse::Ok().json(results)
 }

(Requires adding the futures crate or using tokio’s JoinSet.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/pgn_archive_api.rs` around lines 83 - 111, The
batch_archive_pgn handler currently accepts an unbounded Vec<ArchivePgnRequest>
and processes each request serially via PgnArchiveService::archive_pgn, which is
a DoS/amplification risk; enforce an explicit max batch size (e.g., reject
payloads with more than 50 items and return HttpResponse::PayloadTooLarge()) and
ensure the Actix JSON extractor scope has an appropriate limit configured so you
don't rely on the default 32 KiB. Replace the serial for-loop awaiting
archive_pgn with concurrent execution (e.g., use futures or tokio: create tasks
for each ArchivePgnRequest calling PgnArchiveService::archive_pgn and run them
with futures::stream::iter(...).map(...).buffer_unordered(CONCURRENCY).collect
or tokio::JoinSet) with a reasonable concurrency bound (e.g., 8–16) to avoid
unbounded parallelism, then gather the ArchivePgnResponse results and return
them as before; keep error handling/logging semantics for each individual
result.

Comment on lines +62 to +66
pub fn new() -> Self {
PgnArchiveService {
client: reqwest::Client::new(),
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add HTTP timeouts to the reqwest::Client.

The default reqwest::Client::new() has no connect or request timeout. Because archive_pgn is invoked on the Actix request thread, a slow/hung IPFS or Arweave bundler can block the handler until the TCP stack gives up (minutes), tying up worker threads and degrading the whole API. This is the classic blocking-on-request-thread hazard for external calls.

🛠️ Suggested fix
-impl PgnArchiveService {
-    pub fn new() -> Self {
-        PgnArchiveService {
-            client: reqwest::Client::new(),
-        }
-    }
+impl PgnArchiveService {
+    pub fn new() -> Self {
+        let client = reqwest::Client::builder()
+            .connect_timeout(std::time::Duration::from_secs(5))
+            .timeout(std::time::Duration::from_secs(30))
+            .build()
+            .expect("failed to build reqwest client");
+        PgnArchiveService { client }
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn new() -> Self {
PgnArchiveService {
client: reqwest::Client::new(),
}
}
pub fn new() -> Self {
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(5))
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("failed to build reqwest client");
PgnArchiveService { client }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/pgn_archive.rs` around lines 62 - 66, The
PgnArchiveService currently creates a reqwest::Client with Client::new() which
has no connect or overall request timeouts; update PgnArchiveService::new to
build the client via reqwest::Client::builder() and set sensible connect_timeout
and timeout values (e.g., a few seconds for connect_timeout and a shorter
overall request timeout) before calling .build(), then assign that client to the
client field so archive_pgn calls cannot block Actix threads indefinitely.

Comment on lines +115 to +157
pub async fn archive_to_arweave(&self, pgn_content: &str) -> Result<String, PgnArchiveError> {
let arweave_url = env::var("ARWEAVE_GATEWAY_URL")
.unwrap_or_else(|_| "https://arweave.net".to_string());

// For simplicity, we're using a bundler service
// In production, you'd use the Arweave SDK with a wallet
let bundler_url = env::var("ARWEAVE_BUNDLER_URL")
.unwrap_or_else(|_| "https://node2.bundlr.network".to_string());

// Calculate SHA-256 hash of PGN for integrity
let pgn_hash = self.calculate_hash(pgn_content);

let payload = serde_json::json!({
"data": pgn_content,
"tags": [
{ "name": "Content-Type", "value": "application/x-chess-pgn" },
{ "name": "App", "value": "XLMate" },
{ "name": "PGN-Hash", "value": &pgn_hash },
{ "name": "Archived-At", "value": &chrono::Utc::now().to_rfc3339() }
]
});

let response = self.client
.post(&format!("{}/tx", bundler_url))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await
.map_err(|e| PgnArchiveError::NetworkError(e.to_string()))?;

if !response.status().is_success() {
return Err(PgnArchiveError::ArweaveError(
format!("Arweave upload failed with status: {}", response.status())
));
}

let arweave_response: ArweaveResponse = response
.json()
.await
.map_err(|e| PgnArchiveError::SerializationError(e.to_string()))?;

Ok(arweave_response.id)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Bundlr Irys node /tx endpoint request format signed dataitem ANS-104

💡 Result:

The /tx endpoint on Bundlr/Irys nodes (e.g., https://node1.irys.xyz/tx or https://upload.ardrive.io/v1/tx) accepts POST requests with a signed ANS-104 DataItem as the raw binary request body. Request format: - Method: POST - URL: /{token}/tx or /tx or /v1/tx (token-specific path optional) - Headers: Content-Type: application/octet-stream - Body: Raw bytes of the fully signed DataItem (binary format per ANS-104) The signed DataItem is a binary structure: - Signature type (2 bytes) - Signature (variable) - Owner (variable) - Target (32 bytes + presence byte, optional) - Anchor (32 bytes + presence byte, optional) - Number of tags (8 bytes) - Number of tag bytes (8 bytes) - Tags (binary Avro array) - Data (variable binary payload) To create: Use libraries like @irys/bundles or arbundles to build, sign (with owner key), serialize to raw bytes via dataItem.getRaw, then POST as body. Example (from ArConnect docs): fetch(https://upload.ardrive.io/v1/tx, { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: dataItem.getRaw, // signed DataItem binary }) Response: Typically JSON with tx ID { id: "txid" }. Nodes verify signature, deduct fees from pre-funded balance, bundle into Arweave tx, return ID.

Citations:


🏁 Script executed:

# Check the file exists and examine the lines in question
head -c 5000 backend/modules/api/src/pgn_archive.rs | tail -c 3000

Repository: NOVUS-X/XLMate

Length of output: 3058


🏁 Script executed:

# Get more context around lines 115-157
sed -n '110,160p' backend/modules/api/src/pgn_archive.rs

Repository: NOVUS-X/XLMate

Length of output: 1924


🏁 Script executed:

# Search for any signing-related code in the file
rg -n "sign|Sign|bundler|ANS-104|dataitem" backend/modules/api/src/pgn_archive.rs

Repository: NOVUS-X/XLMate

Length of output: 235


🏁 Script executed:

# Find the archive_pgn method to see the fallback logic
sed -n '160,220p' backend/modules/api/src/pgn_archive.rs

Repository: NOVUS-X/XLMate

Length of output: 2062


🏁 Script executed:

# Search for where archive_to_arweave is called and how errors are handled
rg -n "archive_to_arweave|Arweave" backend/modules/api/src/pgn_archive.rs -A 3

Repository: NOVUS-X/XLMate

Length of output: 2232


Arweave bundler request is unsigned — will 4xx in production.

Bundlr/Irys /tx endpoint requires a signed ANS-104 DataItem as raw binary (Content-Type: application/octet-stream), not a JSON payload with application/json. The code at lines 121–157 sends an unsigned JSON request that will fail with a 4xx error on any real bundler. The inline comment (lines 119–120) acknowledges this is not production-ready, but the function returns Result<String, PgnArchiveError> as if it succeeds; in practice, the error is caught in archive_pgn() and silently degraded to IPFS-only archival.

Either:

  • Integrate a proper Arweave SDK with wallet signing (e.g., using libraries with ANS-104 support) before enabling this endpoint, or
  • Feature-gate the Arweave path until signing is implemented, so callers are aware the feature is unavailable.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/pgn_archive.rs` around lines 115 - 157, The
archive_to_arweave function currently posts an unsigned JSON payload to the
bundler /tx endpoint which will 4xx in production; fix by either implementing
proper ANS-104 signing and sending a binary DataItem with Content-Type:
application/octet-stream (use an Arweave/Bundlr SDK to create/sign the DataItem
before POST) inside archive_to_arweave, or feature‑gate the Arweave path (e.g.,
check an env var like ARWEAVE_ENABLED in archive_to_arweave or the caller
archive_pgn) and immediately return a clear PgnArchiveError variant (e.g.,
NotSupported/Disabled) if not implemented; also ensure archive_pgn no longer
silently degrades by propagating the explicit error from archive_to_arweave
instead of hiding it.

Comment on lines 56 to +83
pub struct LobbyState {
sessions: HashMap<String, HashSet<Recipient<WsMessage>>>,
// Track number of connections per game for monitoring
connection_counts: HashMap<String, AtomicUsize>,
// Server instance ID for distributed tracking
instance_id: String,
// Redis connection for cross-instance communication
redis_conn: Option<Arc<ConnectionManager>>,
}

impl LobbyState {
pub fn new() -> Self {
LobbyState { sessions: HashMap::new() }
LobbyState {
sessions: HashMap::new(),
connection_counts: HashMap::new(),
instance_id: Uuid::new_v4().to_string(),
redis_conn: None,
}
}

pub fn with_redis(redis_conn: Arc<ConnectionManager>) -> Self {
LobbyState {
sessions: HashMap::new(),
connection_counts: HashMap::new(),
instance_id: Uuid::new_v4().to_string(),
redis_conn: Some(redis_conn),
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

LobbyState::with_redis is never called, so horizontal scaling is inactive.

Per backend/modules/api/src/server.rs:84-87, the app still starts the lobby via LobbyState::new().start(), meaning redis_conn is always None in production. All the publish logic added in Connect/Disconnect (lines 139-155, 183-199) is effectively dead in deployed instances, which contradicts the goal of issue #548.

Wire the Redis ConnectionManager in server.rs and replace LobbyState::new() with LobbyState::with_redis(redis_conn.clone()). Consider falling back to new() only when Redis env config is absent, and log at startup which mode was selected.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/ws.rs` around lines 56 - 83, The
LobbyState::with_redis variant is never used so redis_conn stays None; modify
the server startup to create a Redis ConnectionManager, pass its Arc clone into
LobbyState::with_redis(redis_conn.clone()) instead of LobbyState::new(), and
only fall back to LobbyState::new() when Redis configuration/env is absent; also
emit a startup log indicating whether the app started in "distributed (redis)"
or "local" mode so the publish/subscribe paths in Connect/Disconnect see an
active redis_conn.

Comment on lines +58 to +63
// Track number of connections per game for monitoring
connection_counts: HashMap<String, AtomicUsize>,
// Server instance ID for distributed tracking
instance_id: String,
// Redis connection for cross-instance communication
redis_conn: Option<Arc<ConnectionManager>>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Dead code: connection_counts, publish_connection_event, and the two count helpers are never used.

  • connection_counts: HashMap<String, AtomicUsize> is declared and initialized but never written or read; get_connection_count reads from sessions instead, so the atomic map is inert.
  • publish_connection_event is defined but never invoked — Connect/Disconnect inline the exact same JSON-build-and-publish logic (lines 139-155, 183-199), producing three near-identical copies.
  • get_connection_count and get_total_connections have no callers inside the module and aren't exposed through any message/handler, so external actors can't reach them either.

Either remove them or actually use them: route Connect/Disconnect through publish_connection_event(&msg.game_id, "connected", count).await (you'll need to spawn from within the sync handler, so keep a small adapter), and drop connection_counts unless you intend to expose per-game metrics.

♻️ Suggested consolidation
 pub struct LobbyState {
     sessions: HashMap<String, HashSet<Recipient<WsMessage>>>,
-    // Track number of connections per game for monitoring
-    connection_counts: HashMap<String, AtomicUsize>,
     // Server instance ID for distributed tracking
     instance_id: String,
     // Redis connection for cross-instance communication
     redis_conn: Option<Arc<ConnectionManager>>,
 }

And in both handlers, replace the inline actix::spawn { ... json!{...} ... publish ... } with a single shared helper that builds the payload once.

Also, since AtomicUsize is no longer needed, the std::sync::atomic import added on line 19 can go too.

Also applies to: 85-112

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/ws.rs` around lines 58 - 63, The module currently
contains dead code: the HashMap field connection_counts, the helper functions
publish_connection_event, get_connection_count, get_total_connections, and the
AtomicUsize import are unused while Connect/Disconnect inline duplicate
JSON/publish logic; either remove them entirely or wire them up. Recommend: drop
connection_counts and the atomic import if you won't expose per-game metrics,
and consolidate the duplicated publish logic by replacing the inline
actix::spawn blocks in the Connect and Disconnect handlers with calls to a
single async helper publish_connection_event(&msg.game_id, "connected" |
"disconnected", count).await (expose a small sync adapter that spawns the async
helper from the sync handler), have that helper build the JSON payload once
using instance_id and redis_conn, and remove the unused
get_connection_count/get_total_connections or add a message handler that calls
them if you need to expose counts; update references from sessions where
necessary to compute count before calling the helper.

Comment on lines 122 to 156
fn handle(&mut self, msg: Connect, _: &mut Context<Self>) {
let entry = self.sessions.entry(msg.game_id).or_default();
let entry = self.sessions.entry(msg.game_id.clone()).or_default();
entry.insert(msg.addr);

let count = entry.len();
log::info!(
"[Instance {}] Client connected to game {}. Total connections: {}",
self.instance_id,
msg.game_id,
count
);

// Publish connection event to Redis for horizontal scaling
let game_id = msg.game_id.clone();
let redis_conn = self.redis_conn.clone();
let instance_id = self.instance_id.clone();

actix::spawn(async move {
if let Some(conn) = redis_conn {
let mut conn = (*conn).clone();
let channel = format!("ws:connections:{}", game_id);
let message = json!({
"event": "connected",
"game_id": game_id,
"instance_id": instance_id,
"count": count,
"timestamp": chrono::Utc::now().to_rfc3339()
});

if let Ok(payload) = serde_json::to_string(&message) {
let _: redis::RedisResult<()> = conn.publish(channel, payload).await;
}
}
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor: count is captured before the spawn, but the entry-len read is fine; event ordering is not guaranteed.

Because the Redis publish is spawned on a separate task per event, two near-simultaneous connects/disconnects may be published out of order (the count snapshot is correct at enqueue time, but subscribers can't rely on monotonic ordering). If consumers derive state from this stream, consider either serializing publishes through a dedicated mailbox/task per game_id, or switching subscribers to a "source of truth = who publishes latest with highest timestamp" model.

Not a blocker given this is currently monitoring-only, but worth documenting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/ws.rs` around lines 122 - 156, The current handle (fn
handle with Connect) spawns a task (actix::spawn) that publishes a snapshot
(count) to Redis, which can be observed out-of-order by subscribers; fix by
either (A) serializing publishes per game_id by introducing a dedicated
publisher actor/task/mailbox keyed on game_id that receives publish requests
(instead of spawning per event) so order is preserved, or (B) make events
idempotent/ordering-safe by adding a monotonic sequence and/or authoritative
timestamp to the payload (use a per-game AtomicU64 or Redis INCR) and include
that sequence/timestamp alongside instance_id, game_id and count so subscribers
can reconcile by “highest sequence/timestamp wins”; update the code that
currently uses redis_conn, game_id, count and actix::spawn to use the chosen
approach.

Comment on lines +147 to +156
pub async fn cleanup_expired_sessions(
db: &DatabaseConnection,
) -> Result<u64, SessionError> {
let result = session::Entity::delete_many()
.filter(session::Column::ExpiresAt.lt(Utc::now()))
.exec(db)
.await?;

Ok(result.rows_affected)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify there are no existing callers of cleanup_expired_sessions.
rg -nP -C2 '\bcleanup_expired_sessions\s*\('

Repository: NOVUS-X/XLMate

Length of output: 498


Schedule cleanup_expired_sessions — it is defined but never invoked.

The function is exposed with a doc comment saying it "should be called periodically," but searching the codebase finds no callers. Expired sessions will accumulate indefinitely in the database. Wire this into a periodic background task (e.g., actix_rt::spawn loop on interval, or an external cron job hitting an admin endpoint).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/security/src/session_service.rs` around lines 147 - 156, The
cleanup_expired_sessions function is never scheduled; wire it into app startup
as a periodic background task that calls
session_service::cleanup_expired_sessions(db) on an interval (e.g.,
tokio::time::interval or actix_rt::spawn with a loop) so expired sessions are
removed regularly; ensure you pass a cloneable/shared DatabaseConnection (Arc or
DatabaseConnection::clone if supported) or a connection pool into the task,
handle/ log any SessionError returned (don’t unwrap), and choose an appropriate
interval (e.g., hourly) and graceful shutdown behavior so the task is started
once during server bootstrap (e.g., in your server startup/init function).

@A6dulmalik A6dulmalik closed this Apr 24, 2026
@A6dulmalik A6dulmalik deleted the feature/A6dulmalik-issues-549-548-541 branch April 24, 2026 15:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant