01-infrastructurewave: W1filled14 citations

Jitsi — Video Conferencing Infrastructure

Audiences: developer, internal

Jitsi — Video Conferencing Infrastructure

Self-hosted, JWT-gated Jitsi Meet stack that powers HIPAA-compliant doctor-patient video visits, with server-side recording into MinIO.

Business Purpose

Jitsi is the video pillar of the Dudoxx telemedicine offering. Doctors and patients connect to JWT-scoped meeting rooms keyed to a specific FHIR Appointment / Encounter, so a clinic owns the entire signaling, media, and recording path — no cross-tenant data leaks to a hosted SaaS like Zoom. Recordings stream into the per-tenant MinIO bucket for compliance retention. This single change ("we host the video") is what makes Dudoxx defensible against bundled US/EU GP suites that ship their telemed via a third-party SaaS.

Revenue lever: telemedicine is a per-seat upsell on the core HMS subscription; replacing a Zoom/Teams add-on with an in-product, recorded, FHIR-linked visit removes a per-month per-doctor third-party cost.

Audiences

  • Investor: Owning the full stack (signaling, media bridge, recorder, branding) is a moat — every competitor that re-sells Zoom/Doxy.me is locked into their pricing and exposed to data-residency objections.
  • Clinical buyer (doctor/nurse/receptionist): "Start visit" inside a calendar appointment opens a Dudoxx-branded room; recording is one click; the file lives alongside the patient chart, not in a separate vendor inbox.
  • Developer/partner: 5 containers (web/prosody/jicofo/jvb/jibri) on a private bridge network, JWT minted by NestJS, custom branding overlay. No Jitsi business code is forked — only branding + JWT issuer + room-naming convention.
  • Internal (ops/support): Five-container restart contract; the prosody data directory must be owned by uid=100 (dhcpcd:tss on this host) or auth will silently fail with Permission denied. Run sudo ./fix-permissions.sh after any git pull.

Architecture

Self-hosted Jitsi Meet (stable-10978) is decomposed into five purpose-built containers, all living on a private bridge network ddx-jitsi (or attached to the umbrella stack network in full-HMS mode). Only the web frontend and the JVB media port are exposed to the host; all XMPP signaling and management ports are internal-only.

Rendering diagram…

Container roles, in dependency order:

  1. jitsi-prosody — XMPP signaling + JWT validation, only host that holds the shared secret.
  2. jitsi-jicofo — conference lifecycle, allocates a JVB instance per room.
  3. jitsi-jvb — Selective-Forwarding Unit (SFU), only port exposed externally for media (UDP JITSI_JVB_PORT, default 15100 in HMS, 10000 upstream).
  4. jitsi-jibri — privileged container running headless Chromium for server-side recording; writes MP4 to ./recordings then a webhook calls back into ddx-api.
  5. jitsi-web — Nginx frontend with custom Dudoxx branding (config/web/css/custom.css, config/web/branding/branding.json).

The video domain in ddx-api is src/voice-video/video/ — it mints JWTs and stores meeting metadata; it does NOT proxy media. Media goes browser → JVB directly (WebRTC).

For full system context see coding_context/ddx-hms-context.md and the umbrella deployment in infra-full-hms-compose.

Tech Stack & Choices

  • Jitsi Meet stable-10978 (Apache 2.0) — chosen because it is the only mature self-hostable WebRTC SFU + signaling + recording stack; LiveKit ships an SFU but no recorder + no JWT plug-in story for browser-based video rooms.
  • Why not LiveKit for video? LiveKit is used for the voice agent (real-time STT + agent turn-taking); a video visit room is a longer-lived multi-party session with recording — see infra-livekit for the boundary. LiveKit = voice FlowAgent + ambient room audio; Jitsi = doctor-patient video rooms (memory: MEMORY.md topic feedback_voice_state_machine_design).
  • JWT HS256 — symmetric secret shared between ddx-api (issuer) and prosody (validator). Asymmetric RS256 was rejected: same trust domain, no third-party verifier, HS256 halves CPU on signing path.
  • VP9 preferred over VP8 — better quality at telemedicine bitrates (~800 kbps start, adaptive).
  • Headless Chrome (Jibri) — Jitsi-stock recorder; alternatives (FFmpeg pipe out of JVB) ruled out because no upstream support and would need bespoke layout rendering.
  • Bridge network ddx-jitsi — isolated; only jitsi-web HTTPS port + jitsi-jvb UDP port hit the host.

Data Flow

Meeting creation

  1. Frontend calls POST /video/meetings on ddx-api with an appointmentId.
  2. VideoService writes a VideoMeeting row, then JitsiJwtService.generateMeetingJwt(...) mints a short-lived HS256 token with:
    • iss: dudoxx_clinic
    • aud: jitsi
    • sub: meet.jitsi (matches prosody VirtualHost)
    • room: ddx-{clinic-slug}-{appointment-short}-{random}
    • context.user: name, email, FHIR practitioner id
    • context.features: { moderator, recording }
  3. Browser opens https://meet.dudoxx.local:15543/<room>?jwt=<token>.
  4. jitsi-web BOSH/WebSocket-bridges into jitsi-prosody; prosody validates the JWT against the shared JWT_APP_SECRET.
  5. On success, jitsi-jicofo allocates a JVB; the browser starts WebRTC against jitsi-jvb (UDP 15100 or TCP 15443 fallback).

Recording

  1. Moderator (JWT claim moderator: true) hits "Record".
  2. Jicofo allocates a jitsi-jibri instance from its brewery MUC.
  3. Jibri launches headless Chrome → joins the room with a recorder@recorder.meet.jitsi JID.
  4. Stream is captured to /recordings/<room>-<date>.mp4 (volume-mounted to ./recordings).
  5. Jibri fires a webhook back to ddx-api (consumed by voice-video/video/video.controller.ts), which moves the file into the tenant's MinIO {clinic-slug}-recordings bucket — see infra-minio.

SSE: no Jitsi-specific events are published on the canonical SSE bus today; visit lifecycle events (visit.started, visit.ended) are emitted by the visits domain on SSEChannels.session(id) (wire session:{id}) once the JWT is dispensed.

Implicated Code

MANDATORY: ≥3 file:line citations.

  • ddx-docker-jitsi/docker-compose.yml:12name: ${COMPOSE_PROJECT_NAME:-ddx-hms-${DUDOXX_HMS_SETUP_ID:-1}} (live COMPOSE_PROJECT_NAME=ddx-001 → containers ddx-001-jitsi-* via the JITSI_*_CONTAINER env vars); full 5-container stack on the ddx-jitsi bridge network.
  • ddx-docker-jitsi/docker-compose.yml:17image: jitsi/web:stable-10978 (all five containers pinned to stable-10978).
  • ddx-docker-jitsi/docker-compose.yml:21${JITSI_HTTP_PORT:-15180}:80 and ${JITSI_HTTPS_PORT:-15543}:443 host port mapping (Jitsi keeps the 15xxx telemed band — the documented exception to the 6xxx dev band, per context/ENVIRONMENT.md).
  • ddx-docker-jitsi/docker-compose.yml:218 — JVB media port ${JITSI_JVB_PORT:-15100}:${JITSI_JVB_PORT:-15100}/udp and TCP fallback 15443.
  • ddx-docker-jitsi/docker-compose.yml:176jitsi-jibri privileged container with SYS_ADMIN cap for Chrome.
  • ddx-docker-jitsi/docker-compose.yml:214ddx-jitsi bridge network definition.
  • ddx-docker-jitsi/ARCHITECTURE.md:71 — Jicofo conference focus responsibilities + bridge allocation policy.
  • ddx-docker-jitsi/ARCHITECTURE.md:147 — JWT token flow sequence diagram (Client → NestJS → Jitsi → Prosody).
  • ddx-api/src/voice-video/video/jitsi-jwt.service.ts:13export class JitsiJwtService — mints + verifies meeting tokens.
  • ddx-api/src/voice-video/video/jitsi-jwt.service.ts:22this.appSecret = this.configService.get<string>('JITSI_JWT_SECRET') || '' — shared secret read from env.
  • ddx-api/src/voice-video/video/jitsi-jwt.service.ts:60 — JWT payload assembly (iss, aud, sub, room, context.user, context.features).
  • ddx-api/src/voice-video/video/jitsi-jwt.service.ts:75jwt.sign(payload, this.appSecret, ...) HS256 signing.
  • ddx-api/src/voice-video/video/video.module.ts:11VideoModule wires VideoController, VideoService, JitsiJwtService, VideoEventsPublisher.
  • ddx-docker-full-hms/docker-compose.yml:831jitsi-web service registration inside the umbrella stack (profile: jitsi).

Operational Notes

  • Prosody permission pitfall (high-frequency support hit): jitsi-config-1/prosody/config/data/ MUST be owned by dhcpcd:tss (uid=100:gid=102) because the prosody process drops privileges to uid=100. Symptom: connection.passwordRequired in the browser; prosody log shows Failed to load roster storage ... Permission denied. Fix: sudo ./fix-permissions.sh from ddx-docker-jitsi/.
  • JWT domain mismatch is the #2 support hit: prosody VirtualHost "meet.jitsi" MUST match the JWT sub claim. JITSI_DOMAIN=meet.jitsi (internal XMPP domain) — NOT the public URL. JITSI_PUBLIC_URL=https://meet-tucan.dudoxx.com is browser-facing only.
  • Apache vhost rule (production): do NOT add ProxyPass /css/ ! or ProxyPass /images/ ! in the public vhost. Jitsi serves its own static assets — Apache must proxy everything.
  • LiveKit vs Jitsi confusion (REMINDERS pitfall): LiveKit (localhost:36880, voice agent RTC) and Jitsi (localhost:15180/15543, video rooms) serve different use cases. Do NOT route video visits through LiveKit, and do NOT route VoiceAgent through Jitsi.
  • Recording storage: Jibri writes to a local volume first; the post-recording webhook moves into per-tenant MinIO bucket {clinic-slug}-recordings — failed webhooks leave orphan MP4s under ./recordings/. Cleanup script needed in ops.
  • Firewall: only ${JITSI_JVB_PORT:-15100}/udp must be open for media. TCP fallback 15443 is for restrictive client networks.
  • Health validation:
    bash
    curl -k https://meet.dudoxx.local:15543/about/   # web reachable
    docker logs ddx-001-jitsi-prosody-1 --tail 30 | grep "Authenticated as"   # JWT OK