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.
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).
Do autoryzacji handshake'u WS używamy:
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).userId — identyfikator użytkownika (staff lub client) do którego token jest
przypięty.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).
Middleware (respondo-ws/src/lib/io.js) sprawdza w kolejności:
token lub userId zwracamy connect_error
z code: 'missing_credentials'.SISMEMBER 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'.GET 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.| 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).
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_locks — true dla ROLE_ADMIN / ROLE_EXPERT (lista w
OauthWsToken::LOCK_BREAKER_ROLE_NAMES). Pozwala wymusić przejęcie locka
zajętego przez innego usera (createLockHandler.js).Scope musi zostać odświeżony, gdy zmienia się przypisanie user ↔ customer:
CustomerUserLink::save() (app + client) wywołuje OauthWsToken::invalidateUserScope($userClientId).CustomerUserLink::del() analogicznie.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ą.
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).
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.
Serwer obsługuje SIGTERM/SIGINT:
userSocketIds,
customerOnlineUsers).io.close() + redisClient.quit() + pubClient.quit() + subClient.quit().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.