Service Topology
Who talks to whom, over what, and with which credential. This is the page to read before adding a service or debugging a cross-service failure.
Communication matrix
| From | To | Transport | Auth | Purpose |
|---|---|---|---|---|
| Mobile | api | HTTPS REST | Authorization: Bearer <JWT> | Everything |
| Mobile | esu | Socket.IO (WSS) | JWT in the handshake | Realtime |
| Mobile | LiveKit | WebRTC | LiveKit access token minted by the API | Voice rooms |
| Mobile | Mux | HLS / RTMP | Public playback ID; stream key for broadcast | Livestreaming |
| Web | api | HTTPS REST | — | Public data |
| api | esu | HTTP POST /admin/emit/* | X-Realtime-Admin-Token | Fan out realtime events |
| esu | api | HTTP GET /api/v1/{dm,room}/:id/authz | Forwards the user's JWT | Authorize a join |
| ogun | esu | HTTP POST /admin/emit/* | X-Realtime-Admin-Token | Media-ready notification |
| api | Redis | BullMQ | password | Enqueue media jobs |
| ogun | Redis | BullMQ | password | Consume media jobs |
| esu | Redis | Socket.IO Redis adapter | password | Cross-pod pub/sub |
| api | Redis | ioredis | password | Feed / trending cache |
| all services | PostgreSQL | Prisma | connection string | Data |
| RevenueCat | api | HTTPS webhook | shared secret | Purchase events |
| Mux | api | HTTPS webhook | signing secret | Asset and livestream state |
| anansi | Stripe | HTTPS API | secret key | Top-ups and creator transfers |
The interservice channel
esu exposes an admin router at /admin, guarded by a shared token (apps/esu/src/interservice/adminRoutes.ts):
router.use(requireAdminToken); // checks X-Realtime-Admin-Token
router.post("/emit/dm/message/new", …);
router.post("/emit/dm/read", …);
router.post("/emit/room/message/new", …);
router.post("/emit/channel/room/created", …);
router.post("/emit/notification/new", …);
router.post("/emit/media/processing/update", …);Each handler does one thing: emit a Socket.IO event into a room. No database access, no authorization beyond the token.
The API's helper adds the /admin prefix itself (apps/api/src/lib/interservice.ts):
fetch(`${process.env.REALTIME_SERVICE_URL}/admin${path}`, …)Ogun's helper does not (apps/ogun/src/utils/interservice.ts):
fetch(`${process.env.REALTIME_SERVICE_URL}${path}`, …)Known defect: REALTIME_SERVICE_URL means two different things
The two helpers disagree about whether REALTIME_SERVICE_URL includes the /admin prefix, and the deployment manifests set the same value for both:
- name: REALTIME_SERVICE_URL
value: "http://playpalz-esu:4010" # api-deployment.yaml AND ogun-deployment.yamlThat is correct for the API and wrong for ogun — ogun POSTs to /emit/media/processing/update, which esu does not route, so the "your photo finished processing" realtime event never fires in production. The mobile app falls back to polling (useMediaProcessingPoll), which is why the bug has not been visible.
Locally the polarity is reversed: apps/api/.env.example ships REALTIME_SERVICE_URL="http://localhost:4010/admin", which makes the API POST to /admin/admin/….
The fix is to pick one convention. Recommended: make REALTIME_SERVICE_URL the service root (http://playpalz-esu:4010), add the /admin prefix in ogun's helper to match the API's, and update both .env.example files.
Realtime room naming
esu groups sockets into rooms (apps/esu/src/rooms/roomNames.ts). Every emit targets one:
| Room | Contains | Used for |
|---|---|---|
user:<userId> | Every socket for one user | Notifications, media-ready, presence-targeted events |
dm:<conversationId> | Participants of a direct conversation | Messages, read receipts, typing |
room:<roomId> | Members currently in a channel room | Messages, typing |
channel:<channelId> | Channel members | Room created / updated, member joined / left |
A socket joins user:<id> automatically on connect. All other joins are explicit and authorized via the callback to the API.
Why esu calls back into the API
It would be faster for esu to query Postgres directly for "is this user allowed in this conversation". It does not, on purpose:
- Access rules for channels involve subscription state, blocks, and membership. Duplicating them would mean two implementations that drift.
esustays free of business logic, so it can be restarted, scaled, or replaced without coordinating a data-model change.
The cost is one HTTP round trip per join. That is acceptable — joins are rare compared to messages.
Scaling characteristics
| Service | Scales by | Constraint |
|---|---|---|
api | Horizontal, stateless | Postgres connection count |
esu | Horizontal via the Redis adapter | Ingress needs sticky sessions for long-polling — hence the ESUSESSION cookie on the socket ingress |
ogun | Horizontal, more workers = more throughput | CPU-bound (Sharp); Mux is rate-limited |
anansi | Single effective runner | Guarded by a Redis distributed lock; a second replica would double-pay creators |
igdb-heartbeat | Single | Cron |
web | Horizontal, stateless | — |
anansi and igdb-heartbeat must not be scaled up casually
Both are cron-driven. anansi uses a Redis lock to make duplicate runs safe, but that lock is the only thing standing between you and duplicate Stripe transfers. Treat replica counts for those two deployments as a deliberate decision, not a knob.
Failure modes
| If this dies | What breaks | What keeps working |
|---|---|---|
api | Everything | Nothing meaningful |
esu | Live message delivery, typing, presence | Sending and reading messages (the API persists them; clients see them on refresh) |
ogun | Thumbnails and video transcoding | Uploads still succeed; media stays pending and processes when the worker returns |
anansi | Payout runs | The product; earnings still accrue in the database |
igdb-heartbeat | Game catalog freshness | Everything |
| Redis | Queue, realtime fan-out, caching | Degraded but mostly functional — cache misses fall through to Postgres |
| PostgreSQL | Everything | Nothing |
The useful property here is that esu and ogun are both deferrable: losing them degrades the experience but does not lose data.
