CODEX // oaoisme wiki

§ unswayed-backend · Platform infrastructure (Phase 1)

Push & realtime

updated 2026-06-04

Two delivery channels for notifications, both ported from legacy code that was quietly broken.

Push — FCM v1 (PushPort)

The legacy push never worked, for two reasons that produced no error:

  1. The body was read from $notification->message — a property that doesn't exist on the resource — so every push had an empty body.
  2. The endpoint URL embedded {env('FCM_PROJECT_ID')}, which PHP does not interpolate, so the literal text was sent in the path and every call 404'd.

The FcmPushService fixes both: the body is the notification's description, and the URL is built from typed config — https://fcm.googleapis.com/v1/projects/${fcmProjectId}/messages:send. It mints a Google OAuth token from the service account (scope firebase.messaging) and caches it until just before expiry, instead of minting one per send.

Device tokens

A first-class DeviceToken table (one-to-many to User, token unique) replaces the single overloaded users.device_token column — so a second device no longer evicts the first. sendToUser fans out to all of a user's tokens and prunes any that FCM reports NOT_REGISTERED. The §23 POST /notification/set-token wire is unchanged: registering a token you already have returns 422 "Token Already Set."; a token owned by another user is reassigned; success is 200 "Device Token Set Successfully." When FCM isn't configured, a LogPushService no-op keeps dev flows working (and never logs full token values).

Realtime — Socket.IO gateway

The legacy broadcast surface authorized nothing: every channel callback in routes/channels.php was return true, on public channels — so any authenticated client could subscribe to any user's private notification or chat stream. That hole is the headline fix here.

The RealtimeGateway keeps the exact ralli.* channel names and payloads (they're part of the contract) but enforces real authorization in a pure, exhaustively-tested canAccessChannel function:

  • ralli.notification.{userId} / ralli.notify.{userId} / ralli.userChats.{participantId} → require socketUserId === channelUserId.
  • ralli.chat.{chatId} → require verified participation (a ChatMembershipChecker port; a deny-all stub until Phase 6 wires real chats).

The socket's identity comes from verifying the JWT on the handshake; an invalid token disconnects. Events are pure DTOs — no DB writes in constructors (the legacy side-effect that silently failed to re-order chats).

Realtime is opt-in (REALTIME_ENABLED, default off, mirroring the legacy BROADCAST_CONNECTION=null). When off, a NoopRealtimeBroadcaster is injected and NotificationsService simply skips broadcasting — clients fall back to FCM + polling /notifications/count. Because the service depends on the RealtimeBroadcaster port, it doesn't care which is wired.