Livestreaming & Voice
Two different real-time media systems, used for two different things.
| Livestreaming | Voice rooms | |
|---|---|---|
| Provider | Mux | LiveKit (self-hosted) |
| Topology | One-to-many broadcast | Many-to-many conference |
| Protocol | RTMP in, HLS out | WebRTC |
| Latency | Seconds | Sub-second |
| Where it runs | Mux's infrastructure | A dedicated DigitalOcean droplet |
| Used for | Creator broadcasts with chat and coin gifting | Channel rooms and 1-on-1 sessions |
Livestreaming (Mux)
Credentials are provisioned early
Every user gets a LiveStream record with a Mux stream key — it is created during registration (registerUser calls createLiveStream). Streaming is a matter of retrieving credentials rather than provisioning them at broadcast time:
GET /api/v1/livestreams/credentials → { streamKey, muxPlaybackId, status }
POST /api/v1/livestreams/credentials/regenerate → new stream keyGoing live
The User.isLive and User.activeLiveStreamId denormalised columns are what let the feed and discovery surfaces show a live badge without joining to LiveStream.
Webhook events handled
POST /api/v1/webhooks/mux (apps/api/src/router/webhookRoutes.ts):
| Event | Effect |
|---|---|
video.asset.ready | Media marked ready, dimensions and duration stored, post marked ready, realtime update emitted |
video.live_stream.active | Stream active, User.isLive = true |
video.live_stream.idle / .disconnected | Stream idle, User.isLive = false, activeLiveStreamId cleared |
.connected, .recording, .updated, .enabled, .disabled, .warning | Acknowledged, no action |
The Mux webhook is unauthenticated
MUX_WEBHOOK_SECRET is present in the environment and the deployment manifests, but the handler does not verify the signature. Anyone who can reach api.playpalz.gg/api/v1/webhooks/mux can post a crafted video.live_stream.active and mark an arbitrary creator live, or mark arbitrary media ready.
Fix by verifying Mux-Signature with mux.webhooks.verifySignature(rawBody, headers, secret) before the switch — which also requires capturing the raw body for that route, since express.json() has already consumed it.
Livestream chat
Chat is stored, not ephemeral — LiveStreamMessage rows via:
GET /api/v1/livestreams/:id/messages
POST /api/v1/livestreams/:id/messagesCoin gifting during a stream is what feeds the creator earnings calculation in Monetization.
Voice rooms (LiveKit)
Why self-hosted
LiveKit runs on a dedicated DigitalOcean droplet rather than in the Kubernetes cluster. WebRTC needs a wide UDP port range and direct host networking, which is awkward to express through an ingress controller. The droplet is provisioned by Ansible (infra/ansible/playbooks/livekit.yml) and configured by livekit.yaml.
Locally, LiveKit runs in Docker Compose with the dev key pair devkey / secret:
docker compose up -d livekit| Port | Purpose |
|---|---|
| 7880 | HTTP API |
| 7881 | RTC over TCP |
| 7882/udp | RTC over UDP |
Joining a room
The API is the only issuer of LiveKit tokens, so room access rules live in one place. Tokens are short-lived (1 hour) and scoped to a single room with explicit canPublish / canSubscribe grants. Empty rooms are torn down after emptyTimeout: 300 seconds.
Room access rules
apps/api/src/lib/roomAccess.ts — canAccessRoom(userId, roomId):
| Visibility | Who may enter |
|---|---|
public | Anyone |
members (default) | The channel owner, or a ChannelMember |
vip | The channel owner, or a channel member with an active CreatorSubscription to the channel owner |
private | Only the two parties of the Session attached to the room (the PlayPal and the purchaser) |
vip is the paywall: membership alone is not enough, the fan must hold a live subscription whose expiresAt is null or in the future.
private is how 1-on-1 booked sessions work — the room is bound to a Session row, and only the playpalId and purchaserId can get a token.
Client side
useLiveKitCall (apps/mobile/hooks/useLiveKitCall.ts) wraps @livekit/react-native. The native WebRTC module means voice rooms cannot be tested in Expo Go — you need a development build.
Configuration
| Variable | Service | Notes |
|---|---|---|
MUX_ACCESS_TOKEN, MUX_SECRET_KEY | api, ogun | Mux API credentials |
MUX_WEBHOOK_SECRET | api | Present but unused — see the warning above |
LIVEKIT_HOST | api | HTTP API base, e.g. https://livekit.playpalz.gg |
LIVEKIT_WS_URL | api | Client WebSocket URL, e.g. wss://livekit.playpalz.gg |
LIVEKIT_API_KEY, LIVEKIT_API_SECRET | api | Token signing |
In production these come from the mux-secrets and livekit-secrets sealed secrets. See Secrets Management.
