Skip to content

Streaming, the Virtual Experience & Overflow — How It All Fits Together

Audience: Operators, Show Admins, Support, QA, Developers Apps: Show Admin · summit-api · Event Site (event-site + theme) · Digital Signage (openstacksignage) · pub-api Last verified against code: 2026-07-08 (all file:line citations checked against the repos' default branches on that date; 2026-07-08 added §2.4, the build-time bake mechanism, after an observed working example prompted re-verification)

This guide documents, in one place, how session streaming works on the FN platform: the legacy virtual experience (full virtual shows on the event site), the overflow streaming system built on top of it (room-full → QR → watch on your phone), every access rule in the chain, and where the pieces live in code. None of this was fully written down before; several of the "rules" people remember are no longer what the code does — those are called out explicitly.

The one-paragraph version: Streams are configured per event (session), never per room. The legacy virtual experience — log in to the event site, click a session in the schedule, watch at /a/event/{id} — is gated by ticket + the VIRTUAL badge access level, but is currently inoperative end-to-end (the API no longer serves the stream URL or the secure-stream tokens on the paths the site calls). What runs today is overflow: an operator pushes a room past FULL in Show Admin's Room Occupancy page and enters a stream URL; summit-api mints an unguessable key and broadcasts it; the room's digital sign flips to "THIS ROOM IS FULL" with a QR code; scanning it opens a public player page where possession of the key is the only access control — no login, no ticket.


1. Where streams are configured (always on the event, never the room)

An admin attaches streaming to a session on the event edit form's "Live Info" panel (summit-admin src/components/forms/event-form.js:1668-1746):

Field Meaning
streaming_url HLS/DASH feed (.m3u8/.mpd) or Mux/Vimeo/YouTube URL
streaming_type LIVE (only playable once the session starts) or VOD (playable any time) — the only two values (summit-api SummitEvent.php:289-292)
stream_is_secure If on and the URL is a Mux stream, playback requires signed Mux JWTs (see §5)
meeting_url Zoom/Jitsi/Meet link — a join link, distinct from a broadcast stream
etherpad_link Collaborative notes link

Server-side these are ordinary event fields validated on create/update (SummitEventValidationRulesFactory.php:35-38,86-89), with a dedicated slim endpoint PUT /summits/{id}/events/{event_id}/live-info that updates only streaming_url + streaming_type (OAuth2SummitEventsApiController.php:2502-2547, route routes/api_v1.php:710).

There is no per-room stream URL anywhere. The room form in Show Admin has no stream field (summit-admin src/components/forms/room-form.js), and the sessions' schedule placement (room + time) is what ties a stream to a physical room. Rooms matter only as the filter on the Room Occupancy page (§3) and the binding for digital signs (§4).

⚠️ Hidden coupling (source of confusing data): setting streaming_url also silently copies it into overflow_streaming_url, and stream_is_secure into overflow_stream_is_secure (summit-api SummitEvent.php:1428-1429, 1868-1880). And in the other direction, putting an event into overflow overwrites the primary streaming_url with the overflow URL (SummitEvent.php:1992-2001). Don't be surprised when these fields shadow each other.


2. The legacy virtual experience (full virtual shows) — and why it doesn't work today

2.1 How it was designed to work

On a virtual/hybrid show, the event site's authenticated area (/a/…) is the venue:

  • Lobby at /a/ — "Live now" (live-event-widget), "Up Next" (upcoming-events-widget), My Schedule; every card links to the session page (event-site src/templates/lobby-page.js:33-35,77-100).
  • Schedule page — the full-schedule-widget; clicking a session navigates to /a/event/{id} (event-site src/templates/schedule-page.js:82-88). Clicking is enabled only when the theme sets widgets.schedule.allowClick: true (theme src/content/site-settings/index.json:38-40), the user is logged in, and the show has started. The schedule never renders streaming_url itself — no widget uses it; the session page is where streaming happens.
  • Session page at /a/event/{id} — fetches the published event and its streaming info, and renders the player (VideoComponent, dispatching to Mux/Vimeo/YouTube/HLS players by URL — event-site src/components/VideoComponent.js:25-101) once the session has started (or streaming_type === "VOD") (event-site src/templates/event-page.js:54-57,110-120).

2.2 The access-rule chain on /a/event/{id} (four gates, in order)

All enforced client-side in event-site's route guards (src/pages/a/[...].js:83-97):

  1. LoginWithAuthRoute redirects to login if unauthenticated (src/routes/WithAuthRoute.js:10-18).
  2. Ticket + VIRTUAL badgeWithAuthzRoute: you need a ticket for the show (userProfile.summit_tickets non-empty), and for stream-bearing routes (/a/event/:eventId is in pathsRequiringVirtualBadge) one of your tickets' badge types must carry an access level named VIRTUAL (src/routes/WithAuthzRoute.js:9-16,46-60; src/utils/authorizedGroups.js:6,19-26). Members of the env-configured "authorized groups" (staff/admin) bypass both.
  3. Show open + virtual check-inShowOpenRoute blocks entry before the show starts, forces completion of mandatory extra questions, and fires a virtual check-in (src/routes/ShowOpenRoute.js:33-40,63-73) — the server records it via PUT /summits/{id}/attendees/{attendee_id}/virtual-check-in, which is itself hard-gated on the attendee holding the VIRTUAL access level (summit-api SummitAttendee.php:1059-1071, routes/api_v1.php:1643). This is the only server-side rule hard-coded to VIRTUAL.
  4. Per-session badge checkWithBadgeRoute: if the session's track has allowed_access_levels configured, your badge's access levels must intersect them; an empty track allow-list admits everyone (event-site src/routes/WithBadgeRoute.js:11, src/utils/authorizedGroups.js:35-45). This is how "sponsored track only for Gold badges" works.

So the remembered rule is right: VIRTUAL on your badge type's access levels was the key to the virtual experience — plus a ticket, plus any per-track access-level restriction. VIRTUAL itself isn't special in Show Admin — it's just an access-level row (created under Badges → Access Levels, attached to badge types via PUT …/badge-types/{id}/access-levels/{level_id}, summit-admin src/actions/badge-actions.js:743-758) — but it's one of the three access levels summit-api seeds on every show (IN_PERSON, VIRTUAL, CHATSummit.php:5355-5374) and the name the event site matches on. Bonus: a show's modality (Virtual/InPerson/Hybrid) is derived from which of those levels its badges carry (summit-api Summit.php:6340-6361).

2.3 Why the live authorization path is broken (verified 2026-07-07)

If a client fetches a session fresh, right now, with a real user's token, both things the player needs are dead:

  • The stream URL never arrives on a live fetch. The site reads event.streaming_url from GET /api/v1/summits/{id}/events/{event_id}/published (the single-event endpoint, getScheduledEvent) — the non-admin event serializer lists streaming_url/streaming_type only in $allowed_fields with no mapping, so they are never emitted (summit-api app/ModelSerializers/Summit/SummitEventSerializer.php:90-91; only admin serializers map them, AdminSummitEventSerializer.php:23-24). Unlike the list endpoint (§2.4), this single-event method always uses the Public serializer — no admin/service escalation exists here (OAuth2SummitEventsApiController.php:911-931).
  • The secure-token endpoint is disabled by design. The site fetches GET …/events/{event_id}/published/streaming-info on every session view (event-site src/actions/event-actions.js:97-104); the server's first line is throw new EntityNotFoundException(…) with the real implementation commented out (summit-api OAuth2SummitEventsApiController.php:2875-2904, commit 4700284f3, 2025-10-07, "disable get regular stream endpoint / stampede issue" — a deliberate operational kill-switch, not an accident). Secure Mux sessions therefore hang at "Loading secure event" (event-site src/templates/event-page.js:100-102).

A live replacement token endpoint exists server-side — GET …/events/{event_id}/published/tokens (getScheduledEventJWT, OAuth2SummitEventsApiController.php:2576-2610), which mints the Mux JWTs behind OAuth scope + secure-stream checks — but the event site never calls it (no reference in event-site). Similarly, the ticket-based server-side access check people remember — SummitEvent::hasAccess() (paid ticket + the track's allowed access levels + LIVE-not-started rules, summit-api SummitEvent.php:1505-1608) — is real but only referenced by the disabled streaming-info path, i.e. it's dead code today.

2.4 ⚠️ Why playback still works on existing show sites anyway — a build-time bake, not a working live path

A show that already has sessions playing in its old virtual site is not evidence the live path works — it's a side effect of how the site was built. Verified end to end (2026-07-08, prompted by an observed working example — yoco.fnvirtual.app/a/event/616, a VOD keynote from a 2020-era show). This is not a years-stale artifact: the theme's Netlify project shows its last production deploy as main@37863cd, published 2026-06-02 — the actual HEAD of the fnevent-yoco-theme repo at time of writing (that commit's message: "chore: update @openeventkit/event-site to 2.1.56"). So this is a genuinely current build, roughly five weeks old at observation time, running near-HEAD event-site code — not an old abandoned deploy. The session's streaming_url was baked in at that June 2 build and has been served unchanged since, exactly as this mechanism predicts:

  1. Every event-site-based theme (this is shared package code, not specific to any one show) runs a Gatsby build step that fetches the summit's schedule via SSR_getEvents() (gatsby-node.js:99-120), authenticated with an OAuth2 client-credentials grant (new ClientCredentials(config), gatsby-node.js:49-56) — a machine/build credential, not a user session.
  2. That call hits GET /api/v1/summits/{id}/events/published — the list endpoint (getScheduledEvents, plural — a different method from the single-event one in §2.3). This method explicitly computes its serializer type per request (OAuth2SummitEventsApiController.php:324-352, getSerializerType() at lines 117-132): it returns the Private/Admin serializer whenever the caller's application_type is ApplicationType_Service — true for a client-credentials caller — or the user is an admin.
  3. The Admin-level serializer does map streaming_url/streaming_type (AdminSummitEventSerializer.php:23-24). So the build fetches the real, admin-level event payload — streaming fields included.
  4. That payload is written to a static file and bundled directly into the site's JS: fs.writeFileSync(EVENTS_FILE_PATH, JSON.stringify(allEvents), "utf8") (gatsby-node.js:303), then import eventsData from "data/events.json" seeds the app's default Redux state (src/reducers/all-schedules-reducer.js:9,16: DEFAULT_STATE.allEvents = eventsData).
  5. At runtime, getEventById() checks that baked array first, before ever calling the live single-event endpoint (event-site src/actions/event-actions.js:37-45) — finds the event there, streaming_url and all, and never touches the (broken) live path.
  6. event-page.js only ever reads event.streaming_url (never media_uploads, despite it being expanded in every fetch — src/actions/event-actions.js:57); once that field is populated from the bake, canRenderVideo passes and the player renders (src/templates/event-page.js:54-57).
  7. If the asset is a plain hosted URL (not *.mux.com), the secure-token gate (checkMuxTokens, only engaged if (isMuxVideo(url) && stream_is_secure)) never triggers, so no live token call is ever needed — sidestepping the disabled/unused endpoints in §2.3 entirely.

What this means in practice: - Playback via this path works only for what was already true at the site's last Gatsby build — a streaming_url an admin sets after the last deploy will not appear until the next rebuild. A show that provisions its stream same-day (the common case for genuinely live events) will hit the broken §2.3 path, not this one. - It works reliably for non-secure URLs (plain hosted files, non-Mux). A secure Mux stream still needs live-minted tokens — those are still genuinely broken (§2.3) — so a secure asset would stall at "Loading secure event" regardless of the bake. - ⚠️ Unintended exposure, worth flagging to the API owner: this means every session's admin-level fields — not just streaming ones — ship inside the public, unauthenticated JS bundle of every event-site-based show, for anyone to read via view-source/devtools, regardless of login or ticket status. The Oct-2025 kill-switch closed the live per-request path; it did nothing about this build-time channel, because the build's client-credentials token was never in scope for that fix. - Do not build new work on this mechanism. It is an accidental side effect of the build pipeline's serializer-escalation rule, not a supported API, and it doesn't help any new client (the attendee app has no Gatsby build baking data for it) — see §7 item 7 and the unified-stream-player SDS.

Bottom line: re-enabling full virtual on demand, or letting any new client (e.g. the attendee app) reliably play regular session streams, still requires server work — either restore streaming-info, or expose streaming_url on an attendee-authorized serializer path and use /published/tokens for secure streams, re-wiring hasAccess into whichever path is chosen. The build-time bake is not a substitute for that work; it's a fragile, unauthenticated leak that happens to look like a working feature on older, unchanged shows.


3. Overflow streaming — what actually runs today

Overflow answers a physical problem: the room is full, people are standing in the hall. The operator flips the room's current session into overflow; the sign tells the crowd to scan and watch on their phones.

3.1 The operator flow (Show Admin → Room Occupancy)

https://showadmin.fnvirtual.app/app/summits/{id}/room-occupancy (summit-admin src/layouts/room-occupancy-layout.js). Despite the name, the page lists published sessions (filterable by room, with a "current events only" ±15-minute toggle), not rooms — because occupancy is a field on the event (summit-api SummitEvent.php:126-127).

  • Occupancy ladder: EMPTY → 25% → 50% → 75% → FULL → OVERFLOW (summit-admin src/utils/constants.js:239-246; server whitelist summit-api SummitEvent.php:1170-1186). The +/- buttons step through it; a plain step is PUT …/events/{id} with {occupancy}.
  • Going past FULL is special: the "+" opens the overflow modal where the operator enters the Stream URL and an "Is Secure?" checkbox (summit-admin src/components/tables/room-occupancy-table/overflow-modal/index.js:39-61). Save calls PUT /summits/{id}/events/{event_id}/overflow (src/actions/room-occupancy-actions.js:285-309). There is no automatic stream discovery — the operator supplies the URL at the moment of overflow (typically the same feed the room's recording/stream rig produces).
  • Ending overflow: the modal's Remove button (or stepping "−" off OVERFLOW) calls DELETE …/events/{event_id}/overflow with the occupancy to fall back to (default EMPTY) (room-occupancy-actions.js:311-332).
  • Permissions: the admin endpoints require the …/summits/write-event scope and SuperAdmins/Administrators/SummitAdministrators groups (summit-api OAuth2SummitEventsApiController.php:2744-2745,2806-2807); additionally the summit-room-administrators group has field-level permission to edit only occupancy (summit-api app/Permissions/permissions.yml:2-10) — that's the on-site room-monitor role.
  • The occupancy page itself live-syncs between operators over its own Ably channel OCCUPANCY:{summitId} (summit-admin src/actions/room-occupancy-actions.js:38-66) — distinct from the overflow broadcast channel below, and silently disabled if the admin app has no SIGNAGE_ABLY_API_KEY configured (src/actions/ably-actions.js:4-9).

3.2 What the server does on PUT …/overflow

SummitService::updateOverflowInfo (summit-api app/Services/Model/Imp/SummitService.php:4064-4098):

  1. Mints a unique, unguessable overflow stream keysha256(id + title + random_bytes + time), re-rolled until unique (SummitEvent.php:2033-2037).
  2. Stores overflow_streaming_url + overflow_stream_is_secure, forces occupancy = OVERFLOW, and (the §1 coupling) copies the overflow URL over the primary streaming_url (SummitEvent.php:1992-2001).
  3. Builds the overflow player URL: {marketingSiteUrl}/a/overflow-player?k={key} (SummitEvent.php:2014-2027; path/param configurable via config('overflow.*')).
  4. Publishes a PresentationOverflow entity-update message carrying that URL to the RabbitMQ fanout exchange entities-updates-broker (ProcessScheduleEntityLifeCycleEventService.php:257-292).

The Rabbit→Ably bridge is pub-api: it maps PresentationOverflow messages onto the dedicated Ably channel {summit_id}:OVERFLOW:* (pub-api api/models/abstract_pub_service.py:6, api/models/ably_pub_service.py:34), payload {entity_id, params: {overflow_url}}. Clearing overflow publishes the same message with overflow_url: "".

3.3 The digital sign (openstacksignage)

A sign is a browser pointed at the signage app with ?summit={id}&location={roomId} — those two URL params are its entire identity (openstacksignage src/store/index.js:70-101). It loads the room's published sessions from the public API (no auth at all; its only credential is a subscribe-only Ably key) and listens on {summitId}:OVERFLOW:* (src/realtime-updates.js:100-102).

When an overflow message arrives for a session on its room:

  • The sign flips to overflow mode: "THIS ROOM IS FULL" + the session title + a QR code that encodes the server-sent overflow_url verbatim (no client-side URL construction) with a play button composited in the middle and the caption "Scan to WATCH THE LIVESTREAM" (openstacksignage src/components/fntech/ocp/2025/global/ocp-2025/screen.vue:98-155,209-243).
  • Overflow state survives sign reloads via localStorage (src/realtime-updates.js:30-33, src/model/schedule.js:105-118).
  • When overflow is cleared (empty overflow_url arrives), the sign reverts to its normal schedule QR (src/workers/sync_strategies/overflow_activity_synch_stategy.js:39).

⚠️ Overflow rendering is only wired into the OCP 2025 sign templates (ocp-2025 and expo-hall-talks); older/other templates ignore it. A new show's sign template needs the overflow chrome ported.

3.4 The attendee lands on the overflow player

Scanning the QR opens https://{event-site}/a/overflow-player?k={key} — a standalone public Gatsby page that bypasses the entire /a guard stack: no login, no ticket, no badge check (event-site src/pages/a/overflow-player.js, whole file; it is not routed through src/pages/a/[...].js). This is deliberate: the QR is displayed only inside the physical venue, so possession of the unguessable key is the access control — same trust model as being in the room.

  • The page resolves the key via the public endpoint GET /api/public/v1/summits/{id}/events/all/published/overflow?k={key} (event-site src/actions/event-actions.js:112-137; summit-api route routes/public_api.php:225-231). The server returns the event basics + overflow_streaming_url + overflow_tokens (Mux JWTs, present only when overflow_stream_is_secure — summit-api SummitEventOverflowStreamingSerializer.php:34-55), and only while the event is published and still in OVERFLOW occupancy (DoctrineSummitEventRepository.php:1110-1124).
  • It renders the same VideoComponent full-screen, live + autoplay (src/pages/a/overflow-player.js:98-105,156-207).
  • The page keeps its own {summitId}:OVERFLOW:* Ably subscription: when the operator ends overflow, every open player receives the empty-URL message and cuts to "stream unavailable" in real time (overflow-player.js:36-50).

So the remembered rule — "you needed a ticket to see the overflow stream" — is not what the code does. The ticket/paid-ticket logic lives in SummitEvent::hasAccess(), which only ever gated the now-disabled virtual-experience streaming-info path (§2.3). Overflow was built key-secured and public on purpose. (If a future requirement adds ticket-gating to overflow, that's new server work, and it would break the scan-and-watch-in-seconds UX that makes overflow work in a hallway.)


4. The two realtime channel families (don't conflate them)

Channel Publisher Consumers Purpose
{summit_id}:*:* (and {summit_id}:{entity}:{id}) summit-api → RabbitMQ → pub-api → Ably event site sync worker, signage general updates Schedule/entity invalidation ("this event changed — refetch")
{summit_id}:OVERFLOW:* same pipeline, PresentationOverflow branch digital signs, open overflow players Overflow on/off, carrying the full player URL
OCCUPANCY:{summit_id} summit-admin publishes directly other summit-admin room-occupancy sessions Operator-to-operator occupancy sync (admin UX only)

The event site's main sync worker has no handler for PresentationOverflow — the overflow player subscribes separately (event-site src/workers/sync_strategies/synch_strategy_factory.js:24-45 vs src/pages/a/overflow-player.js:47-50). These are also all on the schedule-realtime Ably app, which is a different Ably application from the chat one being introduced for the attendee app (see the realtime-comms SDS).


5. Secure streams (Mux) in one place

  • "Secure" means the stream URL is a Mux playback URL and playback requires three signed JWTs (playback_token, thumbnail_token, storyboard_token), minted server-side with the summit's Mux signing key, RS256, expiry ≈ stream duration (default 6h), cached per event (summit-api StreamableEventTrait.php:32-95).
  • Ticking stream_is_secure on an event auto-provisions a Mux URL-signing key for the summit if it doesn't have one (SummitEvent.php:1868-1880).
  • Delivery paths: for overflow, tokens ride along on the public overflow lookup (overflow_tokens). For regular sessions, the live endpoint is GET …/published/tokens — currently unused by any front end (§2.3).

6. Quick answers (FAQ form)

  • Where do I link a session to its stream? Event edit form → Live Info → streaming_url (+ type, + secure flag). Never on the room.
  • Is the schedule aware of streams? No — schedule widgets never read streaming_url. Clicks route to /a/event/{id} and that page handles streaming (via the build-time bake, §2.4 — not a working live path, §2.3).
  • What exactly did VIRTUAL gate? Entry to the event site's stream-bearing routes (client-side) and the virtual check-in endpoint (server-side). Not the overflow player.
  • Who can put a room into overflow? Admin-scope users, plus the summit-room-administrators group (occupancy-field-only permission).
  • What does the QR encode? The exact {event-site}/a/overflow-player?k={sha256-key} URL the server minted — the sign adds nothing.
  • What kills an overflow stream? DELETE overflow (or stepping occupancy down) — the Ably message blanks the sign and cuts off every open player live.
  • Can someone share the overflow link outside the venue? Yes — anyone with the key URL can watch until overflow is cleared. Accepted trade-off of the venue-trust model.
  • Why does an old show's virtual player still work, then? It plays whatever streaming_url was true at that site's last Gatsby build — baked into the static JS via a build-time service-credential that (unintentionally) gets the admin-level payload (§2.4). It is not the live path working; a fresh fetch or a same-day-provisioned stream still hits the broken path (§2.3).

7. Known gotchas & loose ends (as of 2026-07-07)

  1. Primary ↔ overflow field mirroring (§1) — setting one side shadows the other; don't trust streaming_url to be "the session's own stream" on an event that has ever overflowed.
  2. /published/streaming-info disabled but still called by the event site on every session view — harmless 404 noise today, but any virtual-experience revival must reconcile this with the newer /published/tokens endpoint.
  3. Overflow chrome only exists in OCP 2025 signage templates — port it when building a new show's sign template.
  4. Leftover debugger; in the overflow player's Ably handler (event-site src/pages/a/overflow-player.js:37).
  5. Admin occupancy live-sync silently no-ops without SIGNAGE_ABLY_API_KEY in summit-admin's env.
  6. streaming_type has no admin help text — LIVE hides the player until start time, VOD shows it always (event-site src/templates/event-page.js:54-57); operators regularly guess this wrong.
  7. For the attendee app's planned "watch the stream" feature: the overflow lookup is public and consumable as-is; regular session streams need the §2.3 server work first (attendee-authorized stream URL + /published/tokens, re-wiring hasAccess) — the §2.4 build-time bake does not help here: the app has no Gatsby build step baking data for it, so it would need the live path regardless.
  8. ⚠️ Unauthenticated data exposure via the Gatsby build bake (§2.4) — every session's admin-level fields ship inside every show's public JS bundle, readable by anyone, because the build's client-credentials token gets the Admin serializer on the list endpoint (getSerializerType(), OAuth2SummitEventsApiController.php:117-132). Worth a decision from the API owner: is this accepted (build data has always been "public once shipped" for this platform) or does it need scoping down (a build-specific serializer type, or stripping non-public fields before SSR_getEvents bakes them)?

Related: [[repository-reference]] · the realtime-comms SDS (sds/ftn-attendee-native-realtime-comms.md) for the attendee app's Ably usage · the unified-stream-player SDS for the planned server-side fix · tribal-knowledge index for per-show signage notes.