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:linecitations 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-sitesrc/templates/lobby-page.js:33-35,77-100). - Schedule page — the full-schedule-widget; clicking a session navigates to
/a/event/{id}(event-sitesrc/templates/schedule-page.js:82-88). Clicking is enabled only when the theme setswidgets.schedule.allowClick: true(themesrc/content/site-settings/index.json:38-40), the user is logged in, and the show has started. The schedule never rendersstreaming_urlitself — 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-sitesrc/components/VideoComponent.js:25-101) once the session has started (orstreaming_type === "VOD") (event-sitesrc/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):
- Login —
WithAuthRouteredirects to login if unauthenticated (src/routes/WithAuthRoute.js:10-18). - Ticket + VIRTUAL badge —
WithAuthzRoute: you need a ticket for the show (userProfile.summit_ticketsnon-empty), and for stream-bearing routes (/a/event/:eventIdis inpathsRequiringVirtualBadge) one of your tickets' badge types must carry an access level namedVIRTUAL(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. - Show open + virtual check-in —
ShowOpenRouteblocks 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 viaPUT /summits/{id}/attendees/{attendee_id}/virtual-check-in, which is itself hard-gated on the attendee holding the VIRTUAL access level (summit-apiSummitAttendee.php:1059-1071,routes/api_v1.php:1643). This is the only server-side rule hard-coded to VIRTUAL. - Per-session badge check —
WithBadgeRoute: if the session's track hasallowed_access_levelsconfigured, your badge's access levels must intersect them; an empty track allow-list admits everyone (event-sitesrc/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, CHAT — Summit.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_urlfromGET /api/v1/summits/{id}/events/{event_id}/published(the single-event endpoint,getScheduledEvent) — the non-admin event serializer listsstreaming_url/streaming_typeonly in$allowed_fieldswith no mapping, so they are never emitted (summit-apiapp/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-infoon every session view (event-sitesrc/actions/event-actions.js:97-104); the server's first line isthrow new EntityNotFoundException(…)with the real implementation commented out (summit-apiOAuth2SummitEventsApiController.php:2875-2904, commit4700284f3, 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-sitesrc/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:
- 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 viaSSR_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. - 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'sapplication_typeisApplicationType_Service— true for a client-credentials caller — or the user is an admin. - 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. - 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), thenimport eventsData from "data/events.json"seeds the app's default Redux state (src/reducers/all-schedules-reducer.js:9,16:DEFAULT_STATE.allEvents = eventsData). - At runtime,
getEventById()checks that baked array first, before ever calling the live single-event endpoint (event-sitesrc/actions/event-actions.js:37-45) — finds the event there,streaming_urland all, and never touches the (broken) live path. event-page.jsonly ever readsevent.streaming_url(nevermedia_uploads, despite it being expanded in every fetch —src/actions/event-actions.js:57); once that field is populated from the bake,canRenderVideopasses and the player renders (src/templates/event-page.js:54-57).- If the asset is a plain hosted URL (not
*.mux.com), the secure-token gate (checkMuxTokens, only engagedif (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-adminsrc/utils/constants.js:239-246; server whitelist summit-apiSummitEvent.php:1170-1186). The +/- buttons step through it; a plain step isPUT …/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 callsPUT /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}/overflowwith the occupancy to fall back to (defaultEMPTY) (room-occupancy-actions.js:311-332). - Permissions: the admin endpoints require the
…/summits/write-eventscope and SuperAdmins/Administrators/SummitAdministrators groups (summit-apiOAuth2SummitEventsApiController.php:2744-2745,2806-2807); additionally thesummit-room-administratorsgroup has field-level permission to edit onlyoccupancy(summit-apiapp/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-adminsrc/actions/room-occupancy-actions.js:38-66) — distinct from the overflow broadcast channel below, and silently disabled if the admin app has noSIGNAGE_ABLY_API_KEYconfigured (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):
- Mints a unique, unguessable overflow stream key —
sha256(id + title + random_bytes + time), re-rolled until unique (SummitEvent.php:2033-2037). - Stores
overflow_streaming_url+overflow_stream_is_secure, forcesoccupancy = OVERFLOW, and (the §1 coupling) copies the overflow URL over the primarystreaming_url(SummitEvent.php:1992-2001). - Builds the overflow player URL:
{marketingSiteUrl}/a/overflow-player?k={key}(SummitEvent.php:2014-2027; path/param configurable viaconfig('overflow.*')). - Publishes a
PresentationOverflowentity-update message carrying that URL to the RabbitMQ fanout exchangeentities-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_urlverbatim (no client-side URL construction) with a play button composited in the middle and the caption "Scan to WATCH THE LIVESTREAM" (openstacksignagesrc/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_urlarrives), 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-sitesrc/actions/event-actions.js:112-137; summit-api routeroutes/public_api.php:225-231). The server returns the event basics +overflow_streaming_url+overflow_tokens(Mux JWTs, present only whenoverflow_stream_is_secure— summit-apiSummitEventOverflowStreamingSerializer.php:34-55), and only while the event is published and still inOVERFLOWoccupancy (DoctrineSummitEventRepository.php:1110-1124). - It renders the same
VideoComponentfull-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-apiStreamableEventTrait.php:32-95). - Ticking
stream_is_secureon 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 isGET …/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-administratorsgroup (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_urlwas 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)¶
- Primary ↔ overflow field mirroring (§1) — setting one side shadows the other; don't trust
streaming_urlto be "the session's own stream" on an event that has ever overflowed. /published/streaming-infodisabled 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/tokensendpoint.- Overflow chrome only exists in OCP 2025 signage templates — port it when building a new show's sign template.
- Leftover
debugger;in the overflow player's Ably handler (event-sitesrc/pages/a/overflow-player.js:37). - Admin occupancy live-sync silently no-ops without
SIGNAGE_ABLY_API_KEYin summit-admin's env. streaming_typehas no admin help text — LIVE hides the player until start time, VOD shows it always (event-sitesrc/templates/event-page.js:54-57); operators regularly guess this wrong.- 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-wiringhasAccess) — 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. - ⚠️ 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 beforeSSR_getEventsbakes 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.