Kanał WebSockets

Komunikacja WebSockets wykorzystuje aplikację serwerową respondo-ws (Node.js 24 LTS, Socket.IO 4, Redis pub/sub). Aplikacja dla wersji beta jest dostępna pod adresem https://ws.beta.respondo.pl. Do obsługi komunikacji serwer ↔ klient została użyta biblioteka socket.io. Komunikacja jest podzielona na pokoje odpowiadające zasobom API.

Po wdrożeniu RES-6967 (modernizacja stacku) obowiązują nowe wymagania protokołu — patrz sekcja Autoryzacja.

Skalowanie horyzontalne

respondo-ws jest stateless i może działać w dowolnej liczbie instancji za load balancerem. Synchronizację broadcastów między podami obsługuje @socket.io/redis-adapter (publish/subscribe na wspólnym Redisie). Cały stan użytkownika (userSocketIds:<userId>, presenceLast:<userId>, userCustomers:<userId>, customerOnlineUsers:<customerId>) trzymany jest w Redisie — żaden pod nie pamięta nic lokalnie poza buforem debounce'u flapów (presenceBroadcast.js).

Autoryzacja

Do autoryzacji handshake'u WS używamy:

  1. ws_token — opaque token (sha512), wystawiany przez API w trakcie generowania sesji OAuth (OauthWsToken::setForUser($userId)). TTL = 24h, rotacja przy każdym wystawieniu nowego tokenu lub po removeExpiredTokens() (cron).
  2. userId — identyfikator użytkownika (staff lub client) do którego token jest przypięty.

Format handshake'u (Socket.IO 4 — auth)

Klient v4 (respondo-ui ≥ socket.io-client@4) przekazuje dane w obiekcie auth:

import { io } from 'socket.io-client';

const socket = io(WS_URL, {
    transports: ['websocket'],
    reconnection: true,
    auth: { token, userId },
});

Serwer akceptuje też legacy format query: { token, userId } (Socket.IO 2/3) gdy flaga allowEIO3=1 w respondo-ws — okno przejściowe dla starszych klientów. W produkcji docelowo allowEIO3=0 (po pełnym rolloutcie UI).

Walidacja serwera

Middleware (respondo-ws/src/lib/io.js) sprawdza w kolejności:

  1. Obecność danych — bez token lub userId zwracamy connect_error z code: 'missing_credentials'.
  2. Token zarejestrowany dla useraSISMEMBER userWsTokens:<userId> <token>. Jeśli SET nie istnieje → code: 'no_tokens_registered' (PHP nie wystawiło tokenu lub Redis był wyczyszczony). Jeśli SET istnieje, ale token nie należy → code: 'invalid_token'.
  3. ScopeGET wsTokenScope:<token> zwraca JSON {customer_ids, user_role_id, can_break_locks}. Scope jest wymagany do walidacji pokojów customer-skoped (customer:update:*, chatInternal*:*, usersStatuses:<customerId>).

Klient po connect_error powinien rozróżnić kody:

  • invalid_token / no_tokens_registered — wymuś OAuth refresh + reconnect ze świeżym tokenem (patrz respondo-ui AbstractWsConnectionService).
  • missing_credentials / auth_lookup_error — błąd konfiguracji / Redisa, nie próbuj reconnectu.

Dane w Redisie

Klucz Typ Wystawca Konsument Opis TTL
userWsTokens:<userId> SET PHP (API) respondo-ws (auth) Lista aktywnych ws_tokenów użytkownika brak (cleanup po expire DB)
wsTokenScope:<token> STRING PHP (API) respondo-ws (auth) JSON: {customer_ids[], user_role_id, can_break_locks} 86400s
userSocketIds:<userId> SET respondo-ws respondo-ws Aktualne socket.id użytkownika (multi-tab) brak (SREM przy disconnect)
userCustomers:<userId> SET respondo-ws respondo-ws Customer IDs aktywnej sesji (z wsTokenScope) brak
customerOnlineUsers:<customerId> SET respondo-ws respondo-ws Userzy online dla danego customer'a brak
onlineUsers SET respondo-ws PHP (legacy reads) Globalna lista online (legacy compat) brak
presenceLast:<userId> STRING respondo-ws respondo-ws Ostatni broadcast online/offline (flap suppression cross-instance) 300s
userIssueAccess:<userId> SET PHP (API) respondo-ws (ACL) issueId, do których user ma dostęp (per-issue ACL) brak
userChatThreadAccess:<userId> SET PHP (API) respondo-ws (ACL) chat thread IDs brak

Migracja (RES-6967): klucz userWsTokens był wcześniej HASH z polem CSV — patrz scripts/e2e-ws-scope/run-migration-hash-to-set.php (one-time conversion).

Format wsTokenScope:<token>

Generowany przez OauthWsToken::buildUserScope() przy każdym wystawieniu nowego tokenu lub po invalidateUserScope($userId) (np. dodanie/usunięcie wpisu w customer_user_links).

{
  "customer_ids": [42, 88],
  "user_role_id": 1,
  "can_break_locks": true
}
  • customer_ids — dla staff: pojedyncze users.customer_id. Dla client (is_client=1): agregacja z customer_user_links (multi-customer).
  • user_role_id — wartość z users.user_role_id (UserRolePermissionDto::$rolesId).
  • can_break_lockstrue dla ROLE_ADMIN / ROLE_EXPERT (lista w OauthWsToken::LOCK_BREAKER_ROLE_NAMES). Pozwala wymusić przejęcie locka zajętego przez innego usera (createLockHandler.js).

Inwalidacja scope

Scope musi zostać odświeżony, gdy zmienia się przypisanie user ↔ customer:

  • CustomerUserLink::save() (app + client) wywołuje OauthWsToken::invalidateUserScope($userClientId).
  • CustomerUserLink::del() analogicznie.
  • Zmiana users.user_role_id powinna w przyszłości też wywołać invalidate (TODO).

W praktyce invalidateUserScope woła updateUserWsTokensInRedis, który full-replace'uje SET tokenów + przepisuje wszystkie wsTokenScope:* z nową treścią.

Reguły walidacji pokojów (server-side)

Centralna walidacja w respondo-ws/src/lib/roomAuth.js. Każda próba socket.join przechodzi przez canJoinRoom(socket, roomName) — jeśli false, serwer emituje unauthorized (bez disconnectu — patrz Reguła 5) i loguje próbę.

Wzorzec pokoju Reguła autoryzacji
userNotifications:<X>, userMail*:<X> socket.userId === X
customer:update:<X>, chatInternal*:<X> X ∈ socket.customerIds (z wsTokenScope)
issueLockChanges:<X>, issueSentences:<X> X ∈ userIssueAccess:<userId> (PHP zarządza ACL)
chatMessages:<X> X ∈ userChatThreadAccess:<userId>
usersStatuses:<X>, chatThreads:changes, issues:changes tylko zalogowani (per-customer izolacja przez sklejony pokój)

Reguła 5: próba dołączenia do cudzego pokoju nie powoduje rozłączenia — serwer ignoruje request i emituje unauthorized event z {room, reason}. Klient może to zalogować, ale nie powinien zrywać sesji (legitymowany ruch może potem wystartować pod tym samym socket'em).

Token revocation push

Po skasowaniu wygasłych rekordów z oauth_ws_tokens (OauthWsToken::removeExpiredTokens, cron auth_tokens_clean) PHP publikuje każdy usunięty ws_token na kanale Redis tokenRevoked:

PUBLISH tokenRevoked <ws_token>

respondo-ws subskrybuje ten kanał (src/lib/tokenRevocation.js) i dla każdej otrzymanej wiadomości iteruje aktywne sockety (io.fetchSockets() przez @socket.io/redis-adapter), filtruje te z data.accessToken === <ws_token> i wywołuje socket.disconnect(true). Klient dostaje natychmiast disconnect event i wpada w OAuth-refresh-recovery flow (patrz AbstractWsConnectionService w UI).

Bez tego mechanizmu idle long-lived sesja żyła ze starym tokenem aż do naturalnego disconnectu — nawet po skasowaniu tokenu z DB i SET-u.

Trust model: kanał tokenRevoked to zaufany — w produkcji tylko PHP API do niego pisze. Atakujący który ma write-access do Redisa może sterować rozłączaniem socketów (low-impact: klient automatycznie się reconnectuje ze świeżym tokenem), ale ma też dostęp do całego stanu autoryzacji — większy problem niż token revocation hijack.

Graceful shutdown

Serwer obsługuje SIGTERM/SIGINT:

  1. Stop przyjmowania nowych połączeń.
  2. Disconnect wszystkich aktywnych socketów (cleanup userSocketIds, customerOnlineUsers).
  3. io.close() + redisClient.quit() + pubClient.quit() + subClient.quit().
  4. Timeout 30s → force exit.

Health-check

GET /health zwraca JSON {status, uptime, timestamp, checks: {redis, socketio}}. Status 200 jeśli Redis jest reachable i Socket.IO listenuje, 503 w przeciwnym razie. Endpoint nie zawiera danych wrażliwych — bezpieczny do odpytywania publicznie z LB/k8s.