Skip to content

Sermons & Music Hub — Design Document ​

Status: Accepted — design finalized 2026-06-24 ADR: ADR 0010 — Sermons & Music HubADO Epic: AB#3137 Related ADRs: ADR 0006 (RBAC), ADR 0008 (platform composition), ADR 0024 (provider abstraction)


Purpose and Scope ​

The Sermons & Music Hub makes Heritage Virginia's original community-produced media — recorded sermons, worship music, audio teachings — available to members across all platforms. Content is member-only and catalog-driven. There is no recommendation engine, no public access, and no third-party content.

The hub is delivered through two products that share the same platform backend, the same authentication, and the same RBAC enforcement:

ProductWhat it isWho uses it
Listen portal (listen.heritageva.app)The media section of the main community hub web appMembers on desktop, tablet, and phone via browser
Heritage Media appA separate App Store / Play Store app — player onlyMembers who want a dedicated listening experience

The standalone app is not a different service. It connects to the same API, uses the same Clerk sign-in (Apple or Google), and is subject to the same RBAC check on every content request. It simply does not include member directory, family tools, calendar, announcements, or any administrative features. It is a player.

App name: Heritage Media (working name — to be confirmed before App Store submission; may change). App icon: Distinct from the main community hub icon but in the same visual family as the Tree of Life — same color palette and style, different enough to tell them apart on a home screen. Final icon to be designed before the build phase begins.


Content Taxonomy ​

All media is organized by three dimensions: type, series, and access tier.

Content types ​

TypeWhat it isExamples
MessagesSpoken word — sermons, teachings, testimoniesSunday service messages, Wednesday night teaching, guest speakers
MusicWorship recordingsSunday worship sets, special music
VideosVideo recordingsConference sessions, instructional recordings
LiveActive live stream — visible only when an event is in progressSunday services for traveling members, conferences, weddings

The listen portal home and the standalone app home organize content by type first. A member selects the type they want (Messages, Music, Videos, Live) and then browses series and items within that type. Types are not mixed in the default view.

Series ​

All items belong to a series. A series is a named, typed group of related items. Standalone one-off items are placed in an ad hoc series. Ministry leadership defines series; the media steward creates them through the steward upload UI.

Common series patterns at Heritage Virginia:

SeriesTypeNotes
Sunday Morning Messages — YYYYMessagesWeekly; new series each calendar year
Wednesday Night — YYYYMessagesWeekly
ConferencesMessages or VideosOccasional; named per event
Worship MusicMusicOngoing
Special ServicesMessages or VideosWeddings, ordinations, special events
Guest MessagesMessagesOne-off outside speakers

Series have: name, type, description, artwork, and access tier.

Access tiers ​

Some content at Heritage Virginia is restricted to baptized members only — closed meetings, certain testimony nights, baptism-class content, or member-only special services. This is not a new role — it uses the existing baptized: true profile field set by admins during member onboarding.

TierWho can accessHow enforced
Members (default)Any approved memberStandard RBAC role check
Baptized members onlyMembers with baptized: true on their profileRBAC role check + baptized field check at signed URL gate

Access tier is set at the series level. Every item in the series inherits the series tier. The API enforces the tier on every signed URL request — no permanent URLs are issued, so there is no way to bypass this check after the fact.


Delivery Mode 1 — Listen Portal ​

What it is ​

A portal within the main community hub, accessible at listen.heritageva.app (or the /listen path when accessed from the main shell). The portal is part of the main Expo web / PWA app. Members who have both products installed on their phone will experience the portal as a section of the community hub, not a separate app.

Screen inventory ​

ScreenPathAccess
Listen Home/ (listen portal root)member+
Series Detail/series/:idmember+
Now Playing (full player overlay)Overlay on any pagemember+
Downloads/downloadsmember+
Media Steward — Content Management/stewardmedia_steward / ministry_leader+

Listen Home ​

The entry point for the listen portal. Three regions:

  1. Hero / Latest — The most recently published item (sermon or music). Full-width card with artwork placeholder, title, speaker, series name, duration, and a prominent play button. If the member has progress on this item, shows a progress bar.

  2. Series — A horizontal carousel of series cards. Each card shows the series artwork (or colored placeholder), series title, type (Sermons / Music / Teachings), and episode count. Tapping navigates to Series Detail.

  3. Recent — A chronological list of recently published items across all series. Each row: play button icon, title, series name, duration, publication date. Rows are tappable (opens full player) with an explicit play button.

A mini player bar docks to the bottom of the portal (above the tab bar on mobile) whenever something is playing — it persists across all pages within the listen portal.

Series Detail ​

Shows all episodes within one series.

  • Series header: Artwork, title, description, type badge, total episode count, date range.
  • Episode list: Each episode row shows: episode number, title, duration, publication date, play button, download button (with state: not downloaded / downloading / downloaded / delete). Tapping the row opens the full player.

Now Playing — Full Player ​

A full-screen overlay that appears when the member taps the mini player bar or a play button. Contents:

  • Artwork — Series or item artwork (large, centered). Falls back to a styled placeholder using series color and initials.
  • Metadata — Title, speaker, series name, scripture reference (if present).
  • Scrubber — Playback position with elapsed and remaining time. Draggable.
  • Controls — Skip back 15 seconds, play/pause, skip forward 30 seconds.
  • Chapter navigation — If the item has chapters defined (see ADR 0010 schema), a chapter list below the controls. Tapping a chapter jumps to that offset.
  • Playback speed — 0.75×, 1×, 1.25×, 1.5×, 2×. Persists within the session.
  • Download button — Initiates a download to the app's private sandbox. Shows state: download, downloading (progress), downloaded, delete.
  • Close — Returns to the underlying page; mini player remains visible.

Downloads ​

A management screen for locally cached media files.

  • Storage indicator — Total storage used by downloaded files (e.g., "2.4 GB downloaded").
  • Downloads list — Each row: item title, series name, file size, download date, play button, delete button.
  • Empty state — Shown when no files are downloaded. Prompts member to download from the player.

Media Steward — Content Management ​

Only visible to members with media_steward or ministry_leader role. Two sub-sections:

Upload new item

  • Title, speaker (free text), series (dropdown — existing series or "New Series"), item type (Sermon / Music / Teaching / Testimony), scripture reference (optional), date recorded, file upload (MP4, MP3, M4A — validated at the API).
  • Submit → API ingest endpoint (POST /api/v1/media/items/:id/ingest) → status shows PROCESSING → auto-refreshes to PUBLISHED when ready (or requires manual publish action).

Content list

  • All items in the catalog, filterable by status (PROCESSING / PUBLISHED / UNPUBLISHED / ARCHIVED) and type.
  • Actions per row: Publish, Unpublish, Archive.
  • Series list with edit capability (title, description, artwork).

Delivery Mode 2 — Standalone Media App ​

What it is ​

A separate app installed from the App Store (iOS) and Google Play (Android). It is built from the same Expo codebase as the main community hub mobile app, but compiled as a distinct build target with its own bundle ID, app name, and icon.

App Store / Play Store name: TBD — to be confirmed with ministry leadership before build begins. Working name: Heritage Virginia Media.

The standalone app contains only:

  • Sign-in (Clerk — same Apple/Google accounts as the main platform)
  • Browse all published series and items
  • Stream any item
  • Download items for offline playback
  • Manage downloads
  • Playback progress sync back to the platform API

The standalone app does not include: member directory, family tools, community calendar, announcements, admin tools, or minister tools.

Screen inventory ​

ScreenNotes
Sign-InClerk Apple/Google sign-in — identical flow to main app
HomeNow playing (if in progress), latest hero, series grid
Series BrowserAll series, filterable by type
Episode ListEpisodes within a series
Full PlayerFull-screen player — identical feature set to portal player
DownloadsDownloaded episodes, storage meter, delete controls

RBAC in Context ​

RoleStreamDownloadUploadPublish/UnpublishArchive
Visitor / unapproved
Member
Ministry Leader
Media Steward
Minister
Admin

RBAC is enforced entirely server-side on every request. The standalone app has no relaxed access — it uses the same API, same Clerk session token, and the same RBAC check on every signed URL request.


Data Flows ​

Streaming (play in browser or app) ​

Member taps Play
  → App calls GET /api/v1/media/items/:id/stream
  → API validates Clerk session token
  → API checks member role (member+ required)
  → API calls StorageProvider.issueSignedUrl(key, ttl=3600)
  → API returns signed URL (never stored, never logged)
  → App/browser streams via HTTP range requests directly from storage
  → Signed URL expires after TTL; next play request issues a fresh one

Download (save for offline) ​

Member taps Download
  → App calls GET /api/v1/media/items/:id/stream (same endpoint as streaming)
  → API validates and issues signed URL
  → App downloads file to private sandbox:
      iOS:     Expo FileSystem app-private directory OR App Group shared container (see Shared File Cache below)
      Android: Expo FileSystem app-private directory
  → App records local file path in AsyncStorage keyed by item ID
  → Download state transitions: queued → downloading (% progress) → downloaded

Progress Sync ​

Member plays an item (portal or standalone app)
  → App POSTs to /api/v1/media/items/:id/progress every 10 seconds while playing
      body: { progressSecs: number, completed: boolean }
  → API stores progress against member's account (MediaReceipt row)
  → Next time member opens either app (portal or standalone), progress is fetched
      with the item metadata (myReceipt field) and playback resumes from last position

Progress is synced from both the portal and the standalone app. A member can start listening in the portal and continue in the standalone app (or vice versa) and the position follows them.

Auto-Sync (standalone app) ​

The standalone app checks for new content on every app open (foreground launch). This is called foreground polling and requires no background permissions.

For proactive background sync (so new sermons appear before the member opens the app):

  • iOS: BGAppRefreshTask via expo-background-fetch. The OS schedules background fetch at its discretion (typically every 15–60 minutes). The task calls GET /api/v1/media/items?since=<lastSyncTimestamp> and stores new item metadata locally.
  • Android: WorkManager via expo-background-fetch. Similar behavior.

Background sync only fetches metadata (catalog updates). It does not auto-download files — downloading is always an explicit member action to avoid unexpected storage and data usage.

A badge or "New" indicator appears on the series or item when new content has been fetched since the member last opened the app.


Shared File Cache (iOS App Groups) &ZeroWidthSpace;

The goal &ZeroWidthSpace;

If a member has both the main Heritage Virginia community hub app and the standalone media app installed on the same iPhone, downloaded sermon files should only need to be stored once. A file downloaded in one app is immediately available for offline playback in the other.

How it works on iOS &ZeroWidthSpace;

Apple's App Groups entitlement allows two or more apps from the same developer to share a file container outside each app's private sandbox. Both apps declare the same App Group identifier (e.g., group.app.heritageva.media) in their Xcode entitlements and code signing configuration. When writing a downloaded file, both apps write to the shared container path rather than the private sandbox:

// Shared container path (both apps use this)
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.app.heritageva.media")

When one app checks whether a file is cached, it reads from this shared path. If the file was downloaded by either app, both apps find it there.

Requirements:

  • Both apps must be signed with the same Apple Developer team.
  • Both apps must declare the same App Group identifier in their entitlements.
  • The shared container counts against the user's device storage once, not twice.

Android &ZeroWidthSpace;

Android does not have a direct equivalent to iOS App Groups. Each app has its own private internal storage, and sharing files between apps requires either:

  1. FileProvider / ContentProvider — one app exposes files to the other via a declared content URI. Complex to implement, requires the receiving app to know the provider URI of the other.
  2. Shared external storage — both apps write to a named folder in the user's external storage (e.g., Downloads/HeritageVirginia/). Requires WRITE_EXTERNAL_STORAGE permission on older Android; on Android 10+ this is scoped. Less secure.

Decision for Android (v1): Each app maintains its own download cache. No file sharing between the community hub app and the standalone media app on Android in the initial build. A member who downloads in the main app and also has the standalone app installed will need to download again in the standalone app. This is a known limitation. Revisit if user feedback indicates it is a meaningful friction point.

Summary &ZeroWidthSpace;

PlatformShared cacheMechanism
iOSYesApp Groups shared container (both apps declare same group ID)
AndroidNo (v1)Each app has its own cache; revisit based on user feedback

Delivery Mode 3 — Live Streaming &ZeroWidthSpace;

Heritage Virginia streams select events live: Sunday services for members traveling, conferences, special meetings, and weddings. Live streams are member-only — the same RBAC gate applies as for all recorded content.

Platform: Cloudflare Stream &ZeroWidthSpace;

Live streaming uses Cloudflare Stream as the ingest and delivery platform. Cloudflare Stream accepts an RTMP feed from any standard encoder, delivers HLS playback via signed URLs (member-only), and automatically archives the recording when the stream ends. The archived recording can then be published to the catalog as a normal VOD item.

Why Cloudflare Stream:

  • Zero egress fees — no cost per GB delivered to members
  • Pay per minute stored and streamed — no always-on cost when not streaming
  • RTMP ingest compatible with OBS Studio and Zoom's built-in stream feature
  • Signed playback URLs — same member-only enforcement pattern as VOD
  • Auto-archive — live recording becomes a VOD clip with no extra steps

Cost: Approximately $1–5/month for occasional events (2–4 hours/month, small audience).

Encoder options &ZeroWidthSpace;

Two supported paths for pushing a live feed to Cloudflare Stream:

Option 1 — OBS Studio (primary, camera-based events)

OBS Studio is free, open-source desktop software that runs on the church PC or a volunteer's laptop. It captures any video source (USB camera, webcam, HDMI capture card) and pushes RTMP to Cloudflare Stream.

  • Operator installs OBS once, configures the Cloudflare RTMP URL and stream key once
  • On event day: open OBS, click Start Streaming
  • Supports multiple sources: camera, slides, screen, microphone
  • Best for: Sunday services, conferences, events with a dedicated camera setup

See the Live Streaming Tech Guide for step-by-step setup instructions.

Option 2 — Zoom → Custom RTMP (secondary, Zoom-based meetings)

Zoom includes a built-in "Live Stream to Custom Streaming Service" feature. When enabled, the Zoom meeting simultaneously pushes its video feed to Cloudflare Stream while the meeting continues normally for Zoom participants. Heritage members watching in the Heritage Media app see the live stream without joining Zoom.

  • Operator pastes the Cloudflare RTMP URL and stream key into Zoom settings once
  • On event day: start the Zoom meeting, tap "More" → "Live Stream" → "Custom Streaming Service"
  • No extra software or hardware required
  • Best for: meetings already running on Zoom, remote speaker events, online-first gatherings

Live event data flow &ZeroWidthSpace;

Media steward or minister creates a live event in the steward UI
  → API calls Cloudflare Stream API to create a live input
  → API stores LiveEvent row: { cloudflareInputId, playbackId, title, scheduledAt, accessTier }
  → Steward UI displays RTMP URL + stream key for the operator

Operator starts OBS or enables Zoom RTMP → feed arrives at Cloudflare
  → LiveEvent status → LIVE
  → "Live Now" banner appears on listen portal home and Heritage Media app home

Member taps Live Now
  → App calls GET /api/v1/media/live
  → API validates member session + RBAC + access tier
  → API returns signed Cloudflare playback URL (short TTL)
  → App plays HLS stream

Event ends (operator stops OBS / disables Zoom stream)
  → Cloudflare auto-archives recording
  → Media steward or minister sees archived recording in steward UI as DRAFT
  → Steward reviews and publishes to catalog (or discards)

Live player UI &ZeroWidthSpace;

The live player is the full player in live mode with these differences from VOD:

  • No scrubber (cannot seek live content)
  • No playback speed control
  • No download button
  • "LIVE" badge in the top corner
  • When the stream ends: "Stream has ended" message; recording available after publish

Live event access tiers &ZeroWidthSpace;

Live events support the same access tiers as VOD content: Members (default) or Baptized members only. The access tier is set when the media steward or minister creates the live event.


RBAC &ZeroWidthSpace;

RoleStreamDownloadUploadPublish / Unpublish / ArchiveManage live events
Visitor / unapproved
Member
Ministry Leader✓ (→ DRAFT)
Admin✓ (→ DRAFT)
Minister
Media Steward

Ministry Leader and Admin uploads go to DRAFT status. Content is not visible to members until a Media Steward or Minister clicks Publish.

RBAC is enforced entirely server-side on every request — signed URL, stream request, and live event access all pass through the same API gate.


Upload paths &ZeroWidthSpace;

Two supported paths for getting recorded media into the catalog:

Path A — Web portal upload (primary) Media steward, minister, admin, or ministry leader goes to listen.heritageva.app/steward, fills in the metadata form (title, series, speaker, date, type, access tier), and uploads the file. The browser uploads the file directly to Azure Blob Storage using a short-lived SAS URL issued by the API (the file does not pass through the API server, avoiding timeout issues with large video files). After upload the API creates the catalog record.

Path B — Blob watch folder (secondary, for batch or technical users) Staff with Azure Storage Explorer installed drop files into the media-incoming/ container on the storage account. An Azure Function (blob-triggered) validates the MIME type, reads a companion .json sidecar file for metadata, moves the file to the media-sermons/ container, and creates a DRAFT catalog record. The media steward or minister then reviews and completes the record in the web portal before publishing. Sidecar format: { "title": "...", "series": "...", "speaker": "...", "date": "2026-06-29", "type": "Messages", "accessTier": "members" }.


Storage and Cost Model &ZeroWidthSpace;

Cost driverSolutionCost
VOD storageAzure Blob Storage Hot tier~$0.018/GB/month
VOD egressAzure Blob SAS URLs~$0.087/GB (first 5 GB/month free)
Live streamingCloudflare Stream~$5/1,000 min stored + $1/1,000 min delivered
Live egressCloudflare Stream$0.00 (zero egress)
MetadataAzure SQL Serverless~$0/month at idle

Revisit trigger: If monthly VOD egress exceeds $20–30, migrate VOD storage from Azure Blob to Cloudflare R2 (zero egress). The StorageProvider abstraction makes this a backend swap with no client or RBAC changes required.


HTML Mockup Index &ZeroWidthSpace;

Design mockups for all screens are in packages/ui/design-system/. They are the authoritative visual reference for the build phase.

Listen portal (web):

Standalone app (mobile):


Design decisions in this document supersede any stub implementations in apps/web/src/pages/SermonsPage.tsx, SermonDetailPage.tsx, and apps/mobile/src/screens/SermonsScreen.tsx. Those files will be replaced with the full screen implementations described here.


Technical Specification &ZeroWidthSpace;

This section is the authoritative technical blueprint for the build phase. Every API endpoint, database table, interface, and infrastructure component is specified here. Implementation must match this spec exactly.


Database schema &ZeroWidthSpace;

All new tables are added via Prisma migrations. The storageKey and cloudflareInputId fields are never returned to clients raw — they exist server-side only.

prisma
model Series {
  id          String      @id @default(uuid())
  name        String
  type        MediaType   // Messages | Music | Videos
  accessTier  AccessTier  @default(MEMBERS)
  description String?
  artworkKey  String?     // storage key for series artwork image
  createdBy   String      // FK → User.id
  createdAt   DateTime    @default(now())
  items       MediaItem[]
}

model MediaItem {
  id           String      @id @default(uuid())
  title        String
  speaker      String?
  seriesId     String      // FK → Series.id
  series       Series      @relation(fields: [seriesId], references: [id])
  itemType     MediaType   // Messages | Music | Videos
  date         DateTime
  tags         String?     // comma-separated
  durationSec  Int?
  mediaType    String      // "audio" | "video"
  storageKey   String      // provider-agnostic object key; never returned to clients
  status       MediaStatus @default(DRAFT)
  uploadedBy   String      // FK → User.id
  publishedBy  String?     // FK → User.id — set on publish
  publishedAt  DateTime?
  createdAt    DateTime    @default(now())
  receipts     MediaReceipt[]
}

model MediaReceipt {
  id           String    @id @default(uuid())
  memberId     String    // FK → User.id
  itemId       String    // FK → MediaItem.id
  progressSecs Int       @default(0)
  completed    Boolean   @default(false)
  updatedAt    DateTime  @updatedAt

  @@unique([memberId, itemId])
}

model LiveEvent {
  id                  String          @id @default(uuid())
  title               String
  cloudflareInputId   String          // Cloudflare live input ID; never returned to clients
  cloudflarePlaybackId String         // used to construct signed HLS URL
  status              LiveEventStatus @default(SCHEDULED)
  accessTier          AccessTier      @default(MEMBERS)
  scheduledAt         DateTime?
  startedAt           DateTime?
  endedAt             DateTime?
  archivedItemId      String?         // FK → MediaItem.id — set after archive + publish
  createdBy           String          // FK → User.id
  createdAt           DateTime        @default(now())
}

enum MediaType {
  MESSAGES
  MUSIC
  VIDEOS
}

enum AccessTier {
  MEMBERS
  BAPTIZED_ONLY
}

enum MediaStatus {
  DRAFT
  PROCESSING
  PUBLISHED
  UNPUBLISHED
  ARCHIVED
}

enum LiveEventStatus {
  SCHEDULED
  LIVE
  ENDED
  ARCHIVED
}

MediaItem status workflow &ZeroWidthSpace;

DRAFT       → PUBLISHED    (minister or media_steward clicks Publish)
DRAFT       → ARCHIVED     (minister or media_steward discards without publishing)
PUBLISHED   → UNPUBLISHED  (minister or media_steward hides from members)
UNPUBLISHED → PUBLISHED    (re-publish)
PUBLISHED   → ARCHIVED     (permanent; removes from member-visible catalog)
PROCESSING  → PUBLISHED    (future: automated transcoding pipeline completes)

Only PUBLISHED items are visible to members. DRAFT, UNPUBLISHED, and ARCHIVED items are visible only to minister and media_steward in the steward UI. PROCESSING is reserved for a future transcoding pipeline.


Provider interfaces &ZeroWidthSpace;

typescript
// VOD storage — implemented for Azure Blob (initial), Cloudflare R2 (future)
interface StorageProvider {
  putObject(key: string, stream: Readable, contentType: string): Promise<void>
  deleteObject(key: string): Promise<void>
  issueSignedUrl(key: string, ttlSeconds: number): Promise<string>
  issueSasUploadUrl(key: string, ttlSeconds: number): Promise<string>  // SAS write-only for direct browser upload
}

// Live streaming — implemented for Cloudflare Stream
interface LiveProvider {
  createLiveInput(title: string): Promise<{
    inputId: string
    rtmpUrl: string
    streamKey: string
    playbackId: string
  }>
  getLivePlaybackUrl(playbackId: string, ttlSeconds: number): Promise<string>
  stopLiveInput(inputId: string): Promise<void>
  getArchivedVideoKey(inputId: string): Promise<string>  // returns storage key after Cloudflare archives recording
}

The concrete implementations live in apps/api/src/adapters/storage/ and apps/api/src/adapters/live/. Both are injected via the service container — no endpoint handler touches a provider directly.


API endpoints &ZeroWidthSpace;

All endpoints are under /api/v1/media. Every request requires a valid Clerk session token in the Authorization: Bearer <token> header. RBAC and access tier are enforced server-side on every request.

MethodPathWho can callNotes
GET/seriesmember+List all series. Filter by ?type=MESSAGES. Returns: id, name, type, accessTier, artworkUrl (signed), episodeCount. Never returns storageKey.
GET/series/:idmember+Single series + episode list. Episodes in descending date order. Access tier check applied.
GET/itemsmember+List items. Filter by ?type=, ?seriesId=, ?since= (ISO timestamp for sync). Returns PUBLISHED items only.
GET/items/:idmember+Single item metadata + myReceipt (member's progress). No storageKey.
GET/items/:id/streammember+Issues a signed playback URL. Performs: Clerk auth → RBAC role check → access tier check (MEMBERS vs BAPTIZED_ONLY) → StorageProvider.issueSignedUrl(key, 3600). Returns { url, expiresAt }.
POST/items/:id/progressmember+Body: { progressSecs: number, completed: boolean }. Upserts MediaReceipt. Returns 204.
POST/upload/preparemedia_steward, minister, admin, ministry_leaderBody: { filename, contentType, seriesId, title, speaker, date, itemType, accessTier }. Validates contentType (MP4/MP3/M4A only). Creates MediaItem with status=DRAFT, returns { itemId, sasUploadUrl, sasExpiresAt }. SAS URL is write-only to media-sermons/<itemId>/<filename>.
PATCH/items/:id/statusmedia_steward, ministerBody: { status: 'PUBLISHED' | 'UNPUBLISHED' | 'ARCHIVED' }. Sets status, sets publishedBy + publishedAt on PUBLISHED. Returns 200.
GET/draftmedia_steward, ministerReturns all DRAFT items awaiting approval, ordered by createdAt asc. Includes uploader name.
GET/livemember+Returns the active live event if any (status=LIVE). Returns null if no active event. Includes signed playback URL (TTL 3600s) if active. Applies access tier check.
POST/livemedia_steward, ministerBody: { title, accessTier, scheduledAt? }. Calls LiveProvider.createLiveInput(). Creates LiveEvent with status=SCHEDULED. Returns { eventId, rtmpUrl, streamKey }.
PATCH/live/:id/startmedia_steward, ministerSets LiveEvent status=LIVE, startedAt=now.
PATCH/live/:id/stopmedia_steward, ministerSets LiveEvent status=ENDED, endedAt=now. Calls LiveProvider.stopLiveInput().
POST/live/:id/publishmedia_steward, ministerArchives the Cloudflare recording as a new MediaItem (DRAFT), links to LiveEvent.archivedItemId. Returns the new itemId.

RBAC enforcement pattern &ZeroWidthSpace;

Every media endpoint applies this check chain before issuing a signed URL or returning content:

1. Validate Clerk session token → get userId
2. Load User.role from database
3. Check role permits the action (upload, publish, stream — per RBAC table)
4. If item/series accessTier === BAPTIZED_ONLY:
     Load User.baptized field
     If baptized !== true → return 403
5. If all checks pass → proceed

The baptized field is set by admin on the member's profile. No new role is needed — it is a boolean field on the existing User model.


Infrastructure requirements &ZeroWidthSpace;

New infrastructure resources required before the first deployment of this feature:

ResourceTypePurposeBicep module
media-sermonsAzure Blob container (private)Stores all published VOD media filesinfrastructure/modules/storage.bicep
media-incomingAzure Blob container (private)Watch folder for batch upload path Binfrastructure/modules/storage.bicep
media-watch-triggerAzure Function (blob-triggered)Fires on new blob in media-incoming/, validates, moves, creates DRAFT recordinfrastructure/modules/functions.bicep
AZURE_STORAGE_ACCOUNT_NAMEKey Vault secretStorage account name for StorageProviderkv-heritageva-prod-eus
AZURE_STORAGE_ACCOUNT_KEYKey Vault secretStorage account key for SAS generationkv-heritageva-prod-eus
CLOUDFLARE_STREAM_API_TOKENKey Vault secretCloudflare Stream API token for LiveProviderkv-heritageva-prod-eus
CLOUDFLARE_ACCOUNT_IDKey Vault secretCloudflare account IDkv-heritageva-prod-eus

All secrets are read into the Container App as environment variables via secretref in the Bicep deployment. No secret is hardcoded anywhere.


SAS direct upload flow (Path A) &ZeroWidthSpace;

Client (browser)                API                        Azure Blob
     |                           |                             |
     |  POST /upload/prepare     |                             |
     |  { filename, contentType, |                             |
     |    seriesId, title, ... } |                             |
     |-------------------------->|                             |
     |                           | validate MIME type          |
     |                           | create MediaItem (DRAFT)    |
     |                           | StorageProvider             |
     |                           | .issueSasUploadUrl(key,300) |
     |                           |---------------------------->|
     |                           |<-- SAS write-only URL ------|
     |<-- { itemId, sasUrl } ----|                             |
     |                           |                             |
     |  PUT <sasUrl>             |                             |
     |  (file bytes, chunked)    |                             |
     |------------------------------------------>|             |
     |<-- 201 Created -----------------------------|             |
     |                           |                             |
     | (media_steward/minister   |                             |
     |  sees DRAFT in queue,     |                             |
     |  clicks Publish)          |                             |
     |  PATCH /items/:id/status  |                             |
     |  { status: "PUBLISHED" }  |                             |
     |-------------------------->|                             |
     |<-- 200 OK -----------------|                             |

Blob watch folder flow (Path B) &ZeroWidthSpace;

Staff drops file + sidecar into media-incoming/ container
  → Azure Event Grid fires BlobCreated event
  → Azure Function (media-watch-trigger) activates

Function logic:
  1. Read <filename>.json sidecar (title, seriesId, speaker, date, itemType, accessTier)
  2. Validate MIME type of the media file (MP4/MP3/M4A only)
  3. If invalid: move file to media-incoming-rejected/ + log error
  4. If valid:
       a. Generate destination key: media-sermons/<uuid>/<filename>
       b. Copy blob from media-incoming/ to media-sermons/
       c. Delete source blob from media-incoming/
       d. Delete sidecar file
       e. POST to internal API: create MediaItem (DRAFT) with metadata from sidecar
  5. Media steward or minister sees DRAFT in approval queue → publishes

Dead-letter: Any function failure moves the file to media-incoming-failed/ and logs
the error to Application Insights. No silent drops.

Sidecar JSON format (file must be named <mediafilename>.json in the same container):

json
{
  "title": "Sunday Morning Message",
  "seriesId": "<uuid of existing series>",
  "speaker": "Elder Johnson",
  "date": "2026-06-29",
  "itemType": "MESSAGES",
  "accessTier": "MEMBERS"
}

Heritage Community Hub — Internal. Access restricted via Cloudflare Access + Entra ID.