Video Telemedicine — Jitsi Rooms, JWT, and Online Visits
Audiences: doctor, clinical-buyer, developer, internal, investor
Self-hosted Jitsi Meet rooms minted by NestJS, joined from a Dudoxx-branded Next.js client, recorded into per-tenant MinIO — every byte of a doctor-patient video visit stays inside the clinic's infrastructure perimeter.
Business Purpose
Telemedicine is no longer optional for a German GP/specialist suite — the post-2024 Krankenkassen reimbursement codes for Video-Sprechstunde (07211-07223) make it a billable line item, and patients increasingly self-select for video consults. The Dudoxx position is unusually strong:
- In-product, not bundled — "Start visit" inside an appointment opens a Dudoxx-branded room. The patient never sees a Zoom logo, the doctor never has to copy-paste a meeting link, and the recording lives next to the chart, not in a vendor inbox.
- Self-hosted, not SaaS — the full Jitsi stack (signaling, media bridge, recorder, branding) runs on the clinic's infra. No DPA chain, no cross-border data transfer concern, no per-minute fee.
- Tied to FHIR
Appointment/Encounter— meetings are not free-floating; they are children of a clinical context. Cancelling the appointment invalidates the room.
Revenue lever: telemedicine is a per-seat upsell on the core HMS subscription. Replacing a third-party Zoom/Doxy.me/Teams add-on with an in-product, recorded, FHIR-linked visit removes a per-doctor per-month line item from the clinic's stack and converts it into Dudoxx ARR.
Defensibility: every competitor that ships telemed via a hosted SaaS is exposed to the same procurement objection — "where does the video actually flow?". Dudoxx answers "our hardware, your DPA, end of conversation."
Audiences
- Investor: video is a moat and a margin driver. Owning the recorder + the JWT issuer + the branding means the entire telemed line is captured ARR with no third-party per-minute cost. The architecture also makes "ddx-telemed for partner brands" a viable resale model — the same compose stack, different branding overlay.
- Doctor: one click inside an appointment opens the call. Recording is one click. The MP4 is in the patient chart by the time the next appointment starts. No vendor admin panel, no extra login.
- Clinical buyer (receptionist/clinic-admin): the meeting URL is auto-generated and copied into the appointment confirmation email; no manual link-pasting. The lobby keeps the patient out until the doctor admits them.
- Developer/partner: NestJS mints HS256 JWTs (
JitsiJwtService), Jitsi Meetstable-10978validates them via the sharedJWT_APP_SECRET, the doctor portal renders the Jitsi external API inDoctorVideoClient.tsx. No Jitsi business code is forked — only branding + JWT issuer + room-naming convention. - Internal (ops/support): 5-container stack, prosody uid=100 permission gotcha, JWT domain mismatch is the #2 support hit. See infra-jitsi operational notes for the canonical checklist.
Architecture
The video surface area straddles three layers:
ddx-web (Next.js, 6200)
| Surface | Target |
|---|---|
/portal/doctor/video/[appointmentId] | → DoctorVideoClient.tsx |
/portal/doctor/appointments/[id] | → AppointmentDetailClient (tab) |
videoApi.getJitsiConfig() | → /video/jitsi-config |
videoApi.createMeeting / joinMeeting / updateStatus / endMeeting | — |
ddx-api (NestJS, 6100) — VideoModule
| Provider | Role |
|---|---|
VideoController | HTTP |
VideoService | Prisma, room naming, lobby, recording flags |
JitsiJwtService | HS256 mint + verify, JWT_APP_SECRET |
VideoEventsPublisher | SSE on session:{id} |
ddx-docker-jitsi (5 containers)
| Container | Role | Ports |
|---|---|---|
jitsi-web | Nginx, custom branding | :15180 / :15543 |
jitsi-prosody | XMPP + JWT validator | :5347 internal |
jitsi-jicofo | conference focus | internal |
jitsi-jvb | SFU, WebRTC media | UDP :15100 / TCP :15443 |
jitsi-jibri | privileged, Chrome headless recorder | → ./recordings |
Boundaries that matter:
- Jitsi vs LiveKit — Jitsi handles video rooms (doctor-patient, multi-party, recorded, longer-lived). LiveKit (
infra-livekit) handles the voice agent (real-time STT + turn-taking + ambient room audio). They are NOT substitutes. The REMINDERS doc andfeedback_voice_state_machine_designmemory both call this out as a high-frequency confusion. - NestJS does NOT proxy media —
ddx-apimints a JWT and stores meeting metadata. WebRTC media flows browser → JVB directly over UDP15100(or TCP15443fallback). The API is on the control plane, never the data plane. - Browser does NOT call Jitsi directly without a JWT — the doctor portal hits
videoApi.joinMeeting()to obtain a participant-scoped token, then opens the Jitsi external API. Prosody rejects unsigned join attempts. - Recording is server-side only — Jibri is the recorder; the moderator's "Record" button signals Jicofo, which spawns a Jibri instance. No client-side recording — that path leaks PHI to whatever device the doctor is on.
For the underlying container stack, port matrix, and prosody pitfalls see infra-jitsi.
Tech Stack & Choices
- Jitsi Meet
stable-10978(Apache 2.0) — chosen because it is the only mature self-hostable WebRTC SFU + signaling + recording stack with a JWT-gated entry. LiveKit ships an SFU but has no recorder + no browser-JWT plug-in story for video rooms. - JWT HS256 (jsonwebtoken) — symmetric secret shared between ddx-api (issuer,
JITSI_JWT_SECRET) and prosody (validator). Asymmetric RS256 was rejected — same trust domain, no third-party verifier needed, HS256 halves CPU on the signing path. @nestjs/jwtwas NOT used —JitsiJwtServiceuses the lower-leveljsonwebtokenAPI directly because the payload shape is Jitsi-specific (context.user,context.features) and the abstraction would have leaked everywhere.- Room naming convention —
ddx-{clinic-code}-{appointment-short}-{random}(seeROOM_NAME_PREFIXconstant). Predictable enough to debug, random enough to prevent collision; the random suffix usescrypto.randomBytes. - One meeting per appointment —
videoMeeting.appointmentIdis@unique. Trying to create a second meeting throwsMEETING_ALREADY_EXISTS. Restarts go throughupdateMeetingStatus, notcreateMeeting. - Lobby + recording flags are per-meeting, with clinic-level defaults —
lobbyEnabledandrecordingEnabledare stored on theVideoMeetingrow at create time; if absent, the values come fromClinicVideoConfig.defaultLobbyEnabled/defaultRecordingEnabled(video.service.ts:107-122). recordingJWT feature is doctor-scoped —jitsiFeatures.recording = features?.recording ?? participant.isDoctor(jitsi-jwt.service.ts:52). Patients cannot record even if they figure out the moderator endpoint.- Recording target = MinIO, not S3 — recordings go to the per-tenant bucket
{clinic-slug}-recordings, matching the rest of infra-minio. - Branding overlay only — Dudoxx forks zero Jitsi business code; only
config/web/css/custom.css+branding.json. Any upstream Jitsi security fix lands without merge conflicts.
Data Flow
Meeting creation
- Doctor portal calls
POST /video/meetings(or "Start meeting" CTA fromVideoTab.tsx). VideoController→VideoService.createMeeting(dto, organizationId, createdBy).videoMeeting.findUnique({ appointmentId })— duplicate guard.generateRoomName(organizationId, appointmentId)→ddx-{clinic-code}-{appointment-short}-{random}.jitsiJwtService.buildBaseMeetingUrl(jitsiRoomName)→ composes the public meeting URL.getOrCreateClinicConfig(organizationId)reads/writes default lobby + recording flags.prisma.videoMeeting.create({ ... status: CREATED })— single row tying the FHIR appointment to a Jitsi room.publishMeetingCreated(meeting)fires SSE onSSEChannels.session(id)(wiresession:{id}) so any other open tab updates.- Response includes
meetingUrl,jitsiRoomName, and the freshly-minted moderator JWT (only for the requesting doctor).
Join flow (doctor)
- Doctor portal renders
/portal/doctor/video/[appointmentId]→DoctorVideoClient.tsx. useQuery(queryKeys.jitsiConfig())fetches global Jitsi config (domain, public URL, default features) once per 30 min.useVideoMeeting(appointmentId)polls for the meeting row.videoApi.joinMeeting(meetingId)(POST /video/meetings/:id/join) →VideoService.joinMeeting():- Computes the participant role (HOST vs ATTENDEE) from
request.user. - Mints
JitsiJwtService.generateToken(roomName, participant, features)withJWT_TOKEN_DURATION_HOURS(default short-lived) andaud: jitsi,iss: dudoxx_clinic,sub: meet.jitsi. - Returns
{ token, meetingUrl, participantInfo }.
- Computes the participant role (HOST vs ATTENDEE) from
- The component instantiates the Jitsi external API into a div; the JWT goes in the URL fragment, never persisted.
- Prosody validates the JWT against the shared
JITSI_JWT_SECRET; on success, Jicofo allocates a JVB; WebRTC starts on UDP15100.
Join flow (patient)
Same as above, but rendered from the patient portal route. The minted JWT carries moderator: false, recording: false, livestreaming: false. The patient is held in the lobby if lobbyEnabled=true on the meeting; the doctor admits via the Jitsi UI.
Recording
- Doctor clicks "Record" in the Jitsi UI; the client-side prosody-validated
moderatorclaim allows the command. - Jicofo allocates a
jitsi-jibriinstance from the brewery MUC. - Jibri launches headless Chromium and joins the room as
recorder@recorder.meet.jitsi. - Stream is captured to the volume-mounted
./recordings/<room>-<date>.mp4. - A post-recording webhook calls back into ddx-api (consumed by
VideoController), which:- Moves the file into MinIO bucket
{clinic-slug}-recordings. - Persists a
VideoRecordingrow tied to theVideoMeeting. - Optionally hands the recording off to the Doxing OCR + summarisation pipeline.
- Moves the file into MinIO bucket
End / post-call
- Doctor exits the Jitsi UI.
- Portal calls
PATCH /video/meetings/:id/statuswithIN_PROGRESS → COMPLETED. VideoService.endMeeting()updates the row, optionally writesendSummarywith the post-meeting note form payload (updatePostMeetingDto).VideoEventsPublisher.publishMeetingEnded(...)fires SSE so any open appointment tab refreshes.
SSE channels used
The video domain rides the canonical SSE bus (packages/ddx-sse-contract/, @ddx/sse-contract 4.1.0):
video.created,video.status_changed,video.endedevents emitted on the appointment's session channel.- No Jitsi-specific event types — meeting state changes funnel through the standard inventory.
Implicated Code
MANDATORY: ≥3 file:line citations.
ddx-api/src/voice-video/video/video.service.ts:11—import { PrismaService } from '../../platform/prisma/prisma.service'— main Prisma client for thevideoMeetingtable.ddx-api/src/voice-video/video/video.service.ts:12—import { JitsiJwtService } from './jitsi-jwt.service'— JWT minter is a peer service in the same module.ddx-api/src/voice-video/video/video.service.ts:42—import { VideoEventsPublisher } from './publishers/video-events.publisher'— SSE publisher for video lifecycle events.ddx-api/src/voice-video/video/video.service.ts:84—async createMeeting(dto, organizationId, createdBy)— the single entry point for room creation.ddx-api/src/voice-video/video/video.service.ts:90—prisma.videoMeeting.findUnique({ where: { appointmentId: dto.appointmentId } })— duplicate guard.ddx-api/src/voice-video/video/video.service.ts:101—generateRoomName(organizationId, dto.appointmentId)— naming convention applied here.ddx-api/src/voice-video/video/video.service.ts:105—meetingUrl = this.jitsiJwtService.buildBaseMeetingUrl(jitsiRoomName)— composes the public Jitsi URL.ddx-api/src/voice-video/video/video.service.ts:120-122— lobby/recording flags fall back toclinicConfig.default*when not in DTO.ddx-api/src/voice-video/video/video.service.ts:125—status: VideoMeetingStatus.CREATED— initial state of every meeting row.ddx-api/src/voice-video/video/jitsi-jwt.service.ts:13—export class JitsiJwtService— sole JWT issuer.ddx-api/src/voice-video/video/jitsi-jwt.service.ts:21—JITSI_JWT_APP_ID || 'dudoxx_clinic'— defaultissclaim.ddx-api/src/voice-video/video/jitsi-jwt.service.ts:22—JITSI_JWT_SECRETshared with prosody.ddx-api/src/voice-video/video/jitsi-jwt.service.ts:24—JITSI_DOMAIN || 'meet.jitsi'— internal XMPP domain, becomes thesubclaim.ddx-api/src/voice-video/video/jitsi-jwt.service.ts:52—recording: features?.recording ?? participant.isDoctor— only doctors get the recording feature.ddx-api/src/voice-video/video/jitsi-jwt.service.ts:58-69— full JWT payload assembly (aud: jitsi,iss,sub,room,exp,context.user,context.features).ddx-api/src/voice-video/video/jitsi-jwt.service.ts:75-77—jwt.sign(payload, this.appSecret, { algorithm: 'HS256' }).ddx-api/src/voice-video/video/jitsi-jwt.service.ts:83-87—verifyToken()symmetric verification of inbound tokens for the webhook path.ddx-docker-jitsi/docker-compose.yml:17—image: jitsi/web:stable-10978— pinned upstream version.ddx-docker-jitsi/docker-compose.yml:21—${JITSI_HTTP_PORT:-15180}:80+${JITSI_HTTPS_PORT:-15543}:443— HMS port range, not the upstream 8000-range.ddx-docker-jitsi/docker-compose.yml:150—${JITSI_JVB_PORT:-15100}/udpmedia port +15443TCP fallback.ddx-docker-jitsi/docker-compose.yml:176—jitsi-jibriprivileged container for headless-Chrome recording.ddx-docker-jitsi/docker-compose.yml:214—ddx-jitsibridge network — isolates all internal signaling.ddx-docker-jitsi/ARCHITECTURE.md:147— JWT token flow sequence diagram (Client → NestJS → Jitsi → Prosody).ddx-docker-full-hms/docker-compose.yml:702—NEXT_PUBLIC_JITSI_DOMAIN: ${NEXT_PUBLIC_JITSI_DOMAIN:-localhost:36443}— build-time arg into ddx-web image.ddx-web/src/app/[locale]/portal/doctor/video/[appointmentId]/DoctorVideoClient.tsx:55—export function DoctorVideoClient({ appointmentId, locale, user })— top-level doctor video page.ddx-web/src/app/[locale]/portal/doctor/video/[appointmentId]/DoctorVideoClient.tsx:75—useQuery(queryKeys.jitsiConfig())— fetches Jitsi config fromvideoApi.getJitsiConfig()(cache 30 min).ddx-web/src/app/[locale]/portal/doctor/video/[appointmentId]/DoctorVideoClient.tsx:93—useVideoMeeting(appointmentId)— shared meeting query.ddx-web/src/app/[locale]/portal/doctor/(with-nav)/appointments/[id]/tabs/VideoTab.tsx:24—jitsiRoomName: string— surfaced inside the appointment detail tab for ops debug.ddx-web/src/lib/api/rbac-permissions/role-patient.ts:213—p('/video/jitsi-config', 'GET')— explicit RBAC grant for patients to fetch Jitsi config (room joins are gated by per-meeting JWT, not by RBAC on this route).
Operational Notes
- JWT_APP_SECRET drift is silent and fatal. If
JITSI_JWT_SECRETin ddx-api differs from prosody'sJWT_APP_SECRET, the browser showsconnection.passwordRequiredwith zero useful logs. Verify after any redeploy:bashdocker exec ddx-hms-jitsi-prosody-1 env | grep JWT_APP_SECRET grep JITSI_JWT_SECRET ddx-api/.env
JITSI_DOMAINvsJITSI_PUBLIC_URLpitfall (#2 support hit per infra-jitsi andMEMORY.md):JITSI_DOMAIN=meet.jitsi— the internal XMPP virtualhost, used as thesubclaim. NEVER the public URL.JITSI_PUBLIC_URL=https://meet-tucan.dudoxx.com— what the browser hits. Surfaced asNEXT_PUBLIC_JITSI_DOMAINto the build.- Mixing them produces
Token error: Invalid subin the prosody log.
- Prosody uid=100 permission pitfall (#1 support hit):
jitsi-config-1/prosody/config/data/MUST be owned bydhcpcd:tss(uid=100:gid=102). Symptom:Failed to load roster storage … Permission denied. Fix:sudo ./fix-permissions.shfromddx-docker-jitsi/. Sourced in infra-jitsi operational notes. - Apache vhost rule (production edge): do NOT add
ProxyPass /css/ !orProxyPass /images/ !. Jitsi serves its own static assets — Apache must proxy everything. - LiveKit-vs-Jitsi confusion (REMINDERS pitfall +
feedback_voice_state_machine_designmemory): LiveKit (infra-livekit) is for the voice agent (RTC + STT/TTS turn-taking). Jitsi is for video visits. Routing a video visit through LiveKit (or VoiceAgent through Jitsi) is a category error — they share no protocol. - Recording orphans: Jibri writes to
./recordingsfirst; the post-recording webhook moves into MinIO. If the webhook fails (ddx-api down at the wrong moment) the MP4 stays on disk. Cleanup script needed in ops — listed in infra-jitsi "Operational Notes". - Firewall: only
${JITSI_JVB_PORT:-15100}/udpmust be open externally for media. TCP fallback15443covers restrictive client networks. HTTPS web port15543. - One meeting per appointment is enforced at the DB level. To recreate, mark the existing meeting
CANCELLEDand create a new appointment — never delete the meeting row. - Per-meeting features cannot be widened after mint. A JWT issued with
recording: falsecannot be promoted torecording: truewithout a new join call. This is by design — moderator escalation is server-controlled. - Health validation:
bash
curl -k https://meet.dudoxx.local:15543/about/ # web reachable docker logs ddx-hms-jitsi-prosody-1 --tail 30 | grep "Authenticated as" # JWT OK curl -sf http://localhost:6100/api/v1/video/jitsi-config -H "X-API-Key: $KEY" # API contract live
Related Topics
- infra-jitsi — the underlying 5-container stack (web/prosody/jicofo/jvb/jibri), port matrix, prosody permission pitfall, branding overlay
- infra-livekit — voice-agent RTC; NOT a substitute for video — the boundary is deliberate
- infra-minio — recording archive bucket
{clinic-slug}-recordings - infra-traefik — TLS termination + routing for
meet-*.dudoxx.com - on-prem-stt-tts-llm — sibling on-prem stack for STT/TTS/LLM; voice rooms vs video rooms share no SFU but share the same data-residency story
- flowagent-scenarios — voice-agent state-machine that runs alongside a video call when both are active
- CLAUDE_DEPLOYMENT_STT_ENGINE — companion deployment guide that also covers Jitsi prosody fix-permissions