The public webinar system lives at /webinars and /webinars/[slug]. It covers the full
lifecycle: upcoming registration, live join, and — for past webinars — an email-gated
on-demand recording hub with a premium Mux-first player.
MongoDB (webinars collection)
│ edited via apps/workspace /webinars module
▼
lib/services/webinar.service.ts
│ public projections strip server-only secrets
▼
app/webinars/ → directory listing
app/webinars/[slug]/ → public detail page
app/webinars/[slug]/join → live join hub (token-gated, status = live/upcoming)
app/webinars/[slug]/watch → recording hub (token-gated, status = past)
Two separate gated hubs exist — one for joining a live event, one for watching a recording — both following the same token-validation pattern.
Webinar type — types/webinar.ts#Key fields relevant to the recording flow:
| Field | Type | Notes |
|---|---|---|
mux_playback_id | string | null | Mux video ID — drives the premium player |
youtube_url | string | null | Public YouTube embed URL |
twitch_url | string | null | Twitch channel/video URL |
x_url | string | null | X (Twitter) broadcast URL |
linkedin_live_url | string | null | LinkedIn event page — rendered as "Also on" link, not embedded |
live_webinar_link | string | null | Server-only. Zoom join URL for live events. Never serialized to public components. |
recording_download_url | string | null | Server-only. Zoom HD direct-download link. Never serialized to public components. Served only through the tokenized proxy endpoint. |
status | WebinarStatus | Computed: draft / upcoming / live / past / cancelled |
registration_source | "join" | "recording" | null | Attribution on each webinar_registrations row |
PublicWebinar — the client-safe projection#PublicWebinar is a strict Omit of Webinar that drops every server-only secret before
the type ever reaches a component or API response:
// types/webinar.ts
export type PublicWebinar = Omit<
Webinar,
"status" | "created_by" | "live_webinar_link" | "recording_download_url"
> & { status: WebinarStatus };
recording_download_url is intentionally co-omitted with live_webinar_link. The raw Zoom
URL must never appear in page HTML, client props, or any public API response.
lib/schemas/webinar.ts#recording_download_url: z
.string()
.url("Must be a valid URL")
.nullable()
.optional()
.or(z.literal("")),
Mirrors the live_webinar_link validator exactly. Empty string is normalized to null in
the admin form's pre-save step.
lib/services/webinar.service.ts#| Method | Returns secrets? | Used by |
|---|---|---|
getPublishedWebinars() | No | Directory listing, nav |
getWebinarBySlug(slug) | No | Public detail page |
getWebinarBySlugWithPrivate(slug) | Yes | Recording request route, watch hub server component, download proxy |
Public queries exclude recording_download_url (and live_webinar_link) at the projection
level — never filtered client-side.
For past webinars the detail page (/webinars/[slug]) shows a teaser — a thumbnail with
play affordance, or the YouTube embed if present (retained for SEO value). The full recording,
HD download, and premium player are only accessible from the gated watch hub.
/webinars/[slug] ← public (teaser + "Get the recording" form)
/webinars/[slug]/watch ← gated (full recording hub, requires token)
Visitors who have not requested the recording see the teaser and the form. There is no way
to reach WatchHubView without a valid token issued via the request flow.
A visitor submits the "Get the recording" form on the detail page. This is the full path from form submission to the gated hub.
Visitor on /webinars/[slug] (status === "past")
│ submits "Get the recording" form
│ fields: full_name, email, company (+ optional phone, country_code, tracking)
▼
POST /api/webinars/[slug]/recording/request
│
│ 1. Validate webinar exists AND computed status === "past" → 400 if not past
│ 2. create-or-reuse webinar_registrations row:
│ • getRegistrationByWebinarAndEmail(webinar_id, email)
│ → if found: reuse existing token (no duplicate row, no duplicate email)
│ → if not: createRegistration({ ..., registration_source: "recording" })
│ 3. silent subscription upsert:
│ upsertConfirmedSubscription({
│ email, full_name, company,
│ source: "webinar_recording", source_slug: slug
│ })
│ → "already subscribed" is swallowed silently, never surfaced to user
│ 4. send "Your recording is ready" email (buildRecordingReadyEmail)
│ → primary CTA: "Watch the recording →" (watchUrl)
│ → secondary: "View webinar details →" (webinarUrl)
│ → includes unsubscribe footer
│ 5. return { success: true, mode: "new" | "returning", watchUrl }
▼
Email delivers tokenized link → /webinars/[slug]/watch?t=<unsubscribe_token>
▼
Gated watch hub (see below)
Key behaviors:
mode: "returning" in the response signals this.upsertConfirmedSubscription
never fail the request.watchUrl so the client can offer "Open your recording now"
immediately; the emailed link remains the canonical, durable path."past", the endpoint returns 400 — the live registration
route (/api/webinars/register) continues to handle upcoming/live events and already
correctly rejects past webinars.lib/email/webinar-emails.ts#buildRecordingReadyEmail({ name, webinarTitle, watchUrl, webinarUrl, unsubscribeUrl })
Follows the dark-theme inline-CSS pattern of buildRegistrationConfirmationEmail. Primary
CTA button links to watchUrl; a secondary text link points to webinarUrl. Unsubscribe
footer included on every send.
/webinars/[slug]/watch?t=<token>#A force-dynamic server component that mirrors the live join hub pattern
(app/webinars/[slug]/join/page.tsx).
Every page load runs the following checks in order. Any failure redirects to the recovery gate.
1. token present in query string?
No → recovery gate (missing_token)
2. getRegistrationByToken(token)
null → recovery gate (invalid_token)
3. registration.webinar_slug === slug?
No → recovery gate (wrong_webinar)
4. getWebinarBySlugWithPrivate(slug)
null → recovery gate (webinar_not_found)
5. computed status === "past"?
draft / upcoming / live → redirect to /webinars/[slug] (recording not yet available)
cancelled → recovery gate (cancelled)
past → proceed ✓
6. render WatchHubView with:
• webinar (PublicWebinar fields only — no recording_download_url)
• registrant name + email
• hasDownload: boolean (true if recording_download_url is non-empty)
• downloadUrl: "/api/webinars/[slug]/recording/download?t=<token>"
• token (for client-side re-use)
The raw recording_download_url is never passed to the client. The server component
computes a boolean hasDownload and constructs the proxy URL; the client never sees the
Zoom link.
/webinars/[slug]/watch/invalid#Mirrors join/invalid. Renders a friendly message and offers a re-request form that posts
to POST /api/webinars/[slug]/recording/request. The page never reveals the recording
source or the registrant's identity to an unverified visitor.
WatchHubView layout#┌──────────────────────────────────────────────────────────┐
│ Matters.AI logo Watching as <name> · verified │
├──────────────────────────────────────────────────────────┤
│ ╭┄┄ ambient glow (Mux/direct only) ┄┄╮ │
│ ┌────────────────────────────────────────────────┐ │
│ │ HERO PLAYER (Mux | iframe) │ │
│ │ ⏯ ──────●────── 00:12 / 47:30 [cinema][PiP][⛶] │ │
│ └────────────────────────────────────────────────┘ │
│ Source: ● Mux (HD) ○ YouTube ○ Twitch ○ X │
│ [ ⬇ Download HD ] · Also on: LinkedIn event ↗ │
├──────────────────────────────────────────────────────────┤
│ <title> · why-attend · what-you'll-learn · agenda · speakers │
└──────────────────────────────────────────────────────────┘
The available tab list is built from whichever fields are present on the webinar record:
| Priority | Field | Label | Player type |
|---|---|---|---|
| 1 (default) | mux_playback_id | Mux (HD) | MuxPlayerOfficial / VideoPlayerWithAutoPiP |
| 2 | youtube_url | YouTube | Responsive iframe |
| 3 | twitch_url | Twitch | Responsive iframe |
| 4 | x_url | X | Responsive iframe |
| — | linkedin_live_url | "Also on: LinkedIn event ↗" | External link only (not a tab) |
The default active tab is always the first available, preferring Mux. If only YouTube is present, YouTube is the hero and ambient light is automatically disabled (iframe tabs block cross-origin pixel reads).
Reuses VideoPlayerWithAutoPiP / MuxPlayerOfficial:
mux_playback_id| Mode | Description |
|---|---|
| In-page (default) | Player sits in the normal page layout |
| Cinema | Page dims to near-black; player widens to max width; ambient glow intensifies. Esc or toggle exits. |
| PiP | Native Picture-in-Picture. Auto-triggers when player scrolls out of view. Desktop only. |
| Fullscreen | Native browser fullscreen |
Cinema and PiP are only available on the Mux tab. Iframe tabs (YouTube, Twitch, X) support native fullscreen only.
A <canvas> element sits behind the player. On each animation frame (throttled), it samples
the playing <video> element's pixels (downscaled), blurs and scales the result to produce a
color bloom behind the player frame.
requestAnimationFrame, paused when the video is paused or off-screenprefers-reduced-motion<video> element is accessed via the player ref / mediaElement
property from @mux/mux-player-react. Degrades gracefully if unavailable (ambient light
silently off; everything else works normally)GET /api/webinars/[slug]/recording/download?t=<token>#The "Download HD" button on the watch hub hits this endpoint. It re-validates the token on every request and 307-redirects to the Zoom URL. The raw URL is never in the page HTML.
1. getRegistrationByToken(t)
null, or registration.webinar_slug !== slug → 403
2. getWebinarBySlugWithPrivate(slug)
null, or status !== "past" → 403
3. recording_download_url empty / null → 403
4. success → 307 redirect to recording_download_url
(Zoom serves the bytes directly)
All failure paths return 403 — never 404. This prevents existence leaks: a caller with a
bad token cannot determine whether a download URL exists.
The 307 redirect is the default. There is a clear extension point to switch to a full
streaming proxy (server-side fetch of the Zoom file, piped through the response) if hiding
the Zoom domain becomes a requirement — the interface is identical to callers.
app/webinars/[slug]/client-page.tsx#For past webinars the page no longer renders the full recording ungated. Instead it shows:
youtube_url is set (YouTube embed is kept for SEO value even in gated mode).SubscribeForm past variant, now posting to
POST /api/webinars/[slug]/recording/request instead of the old register route.The old live registration route (app/api/webinars/register/route.ts) is unchanged and
continues to reject past webinars with 400. The recording path is a separate endpoint with
different semantics.
| Concern | Mitigation |
|---|---|
| Raw Zoom URL leaking | recording_download_url omitted from PublicWebinar; never passed to client components; served only through the tokenized proxy |
| Token guessability | crypto.randomUUID() — same unguessable token as the live join flow |
| Gated content caching | Watch hub route is force-dynamic; no ISR or CDN caching of personalized content |
| Download abuse | Token re-validated on every download request; 403 on any failure |
| Existence leaks | Download proxy always returns 403 (not 404) for any failure condition |
| Unverified access to registrant identity | Recovery gate never reveals recording source or registrant name to unverified visitors |
| Logging | logger throughout (no console.log); custom exceptions (ValidationError, etc.) from @/lib/errors/exceptions |
| Bot / abuse on the request endpoint | Risk-based step-up CAPTCHA: invisible reCAPTCHA v3 runs on every submit; on a low score the endpoint returns { challengeRequired: true } (before any DB/email side-effect) and the form reveals a manual v2 checkbox. Verified via lib/security/recaptcha.ts (verifyRecaptchaToken → verifyRecaptchaV2Token). Gracefully degrades to invisible-v3-only when RECAPTCHA_V2_SECRET_KEY / NEXT_PUBLIC_RECAPTCHA_V2_SITE_KEY are unset. |
After a webinar has ended, the recording becomes available in two steps in the workspace webinar form:
mux_playback_id or youtube_url)#In the workspace webinar edit page, under Video / Playback:
abcXYZ123). Drives the
premium player with ambient light, cinema mode, PiP, and scrub storyboard.https://www.youtube.com/watch?v=... or embed URL. Used as
fallback if no Mux ID is set, and retained on the public detail page for SEO.You can set both — Mux will be the default tab in the watch hub, YouTube will be the second.
recording_download_url)#In the workspace webinar edit page, under Publish Settings (grouped with the existing video/link fields), fill in Recording download (HD):
null on save.Save the form. The recording hub is now live. Any visitor who submits the "Get the recording"
form on the detail page will receive an email with a tokenized link to
/webinars/[slug]/watch?t=<token>.
| File | Purpose |
|---|---|
app/webinars/page.tsx | Webinar directory listing |
app/webinars/[slug]/page.tsx | Public detail page (server) |
app/webinars/[slug]/client-page.tsx | Public detail page (client) — teaser + "Get the recording" form for past webinars |
app/webinars/[slug]/components/SubscribeForm.tsx | Registration / recording-request form |
app/webinars/[slug]/join/page.tsx | Live join hub (pattern reference for watch hub) |
app/webinars/[slug]/join/invalid/page.tsx | Live join recovery gate (pattern reference) |
app/webinars/[slug]/watch/page.tsx | Gated watch hub (server component, force-dynamic) |
app/webinars/[slug]/watch/invalid/page.tsx | Recording recovery gate |
app/webinars/[slug]/watch/WatchHubView.tsx | Client component — source tabs, hero player, view modes, ambient light |
app/api/webinars/[slug]/recording/request/route.ts | POST — recording request, token creation, email |
app/api/webinars/[slug]/recording/download/route.ts | GET — token-validated, proxied 307 redirect to Zoom |
app/api/webinars/register/route.ts | Existing live registration route (unchanged; still rejects past webinars) |
| File | Purpose |
|---|---|
types/webinar.ts | Webinar, PublicWebinar, WebinarRegistration types |
lib/schemas/webinar.ts | Zod schemas including recording_download_url |
lib/services/webinar.service.ts | getWebinarBySlugWithPrivate, getRegistrationByToken, getRegistrationByWebinarAndEmail, createRegistration |
lib/email/webinar-emails.ts | buildRecordingReadyEmail + existing email builders |
components/ui/VideoPlayerWithAutoPiP.tsx | Reused in watch hub for Mux tab |
components/ui/mux-player-official.tsx | Mux player primitive |
| File | Purpose |
|---|---|
apps/workspace/app/(workspace)/webinars/components/WebinarFormPage.tsx | Webinar edit form — includes the recording_download_url field under Publish Settings |
| Need | What is reused |
|---|---|
| Token validation | getRegistrationByToken, getRegistrationByWebinarAndEmail, getWebinarBySlugWithPrivate, createRegistration |
| Page/flow pattern | join/page.tsx, join/invalid recovery gate |
| Players | VideoPlayerWithAutoPiP, MuxPlayerOfficial |
sendEmail, buildRegistrationConfirmationEmail (template pattern) | |
| Subscription | upsertConfirmedSubscription (silent upsert) |
| Detail content sections | Existing components in app/webinars/[slug]/ (why-attend, agenda, speakers) |
| Form | SubscribeForm past variant |
/videos gallery (different surface; Mux playback pattern reference)