Appearance
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:
| Product | What it is | Who uses it |
|---|---|---|
Listen portal (listen.heritageva.app) | The media section of the main community hub web app | Members on desktop, tablet, and phone via browser |
| Heritage Media app | A separate App Store / Play Store app — player only | Members 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 ​
| Type | What it is | Examples |
|---|---|---|
| Messages | Spoken word — sermons, teachings, testimonies | Sunday service messages, Wednesday night teaching, guest speakers |
| Music | Worship recordings | Sunday worship sets, special music |
| Videos | Video recordings | Conference sessions, instructional recordings |
| Live | Active live stream — visible only when an event is in progress | Sunday 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:
| Series | Type | Notes |
|---|---|---|
| Sunday Morning Messages — YYYY | Messages | Weekly; new series each calendar year |
| Wednesday Night — YYYY | Messages | Weekly |
| Conferences | Messages or Videos | Occasional; named per event |
| Worship Music | Music | Ongoing |
| Special Services | Messages or Videos | Weddings, ordinations, special events |
| Guest Messages | Messages | One-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.
| Tier | Who can access | How enforced |
|---|---|---|
| Members (default) | Any approved member | Standard RBAC role check |
| Baptized members only | Members with baptized: true on their profile | RBAC 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 ​
| Screen | Path | Access |
|---|---|---|
| Listen Home | / (listen portal root) | member+ |
| Series Detail | /series/:id | member+ |
| Now Playing (full player overlay) | Overlay on any page | member+ |
| Downloads | /downloads | member+ |
| Media Steward — Content Management | /steward | media_steward / ministry_leader+ |
Listen Home ​
The entry point for the listen portal. Three regions:
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.
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.
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 ​
| Screen | Notes |
|---|---|
| Sign-In | Clerk Apple/Google sign-in — identical flow to main app |
| Home | Now playing (if in progress), latest hero, series grid |
| Series Browser | All series, filterable by type |
| Episode List | Episodes within a series |
| Full Player | Full-screen player — identical feature set to portal player |
| Downloads | Downloaded episodes, storage meter, delete controls |
RBAC in Context ​
| Role | Stream | Download | Upload | Publish/Unpublish | Archive |
|---|---|---|---|---|---|
| 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 oneDownload (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) → downloadedProgress 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 positionProgress 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:
BGAppRefreshTaskviaexpo-background-fetch. The OS schedules background fetch at its discretion (typically every 15–60 minutes). The task callsGET /api/v1/media/items?since=<lastSyncTimestamp>and stores new item metadata locally. - Android:
WorkManagerviaexpo-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) ​
The goal ​
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 ​
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 ​
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:
- 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.
- Shared external storage — both apps write to a named folder in the user's external storage (e.g.,
Downloads/HeritageVirginia/). RequiresWRITE_EXTERNAL_STORAGEpermission 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 ​
| Platform | Shared cache | Mechanism |
|---|---|---|
| iOS | Yes | App Groups shared container (both apps declare same group ID) |
| Android | No (v1) | Each app has its own cache; revisit based on user feedback |
Delivery Mode 3 — Live Streaming ​
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 ​
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 ​
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 ​
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 ​
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 ​
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 ​
| Role | Stream | Download | Upload | Publish / Unpublish / Archive | Manage 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 ​
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 ​
| Cost driver | Solution | Cost |
|---|---|---|
| VOD storage | Azure Blob Storage Hot tier | ~$0.018/GB/month |
| VOD egress | Azure Blob SAS URLs | ~$0.087/GB (first 5 GB/month free) |
| Live streaming | Cloudflare Stream | ~$5/1,000 min stored + $1/1,000 min delivered |
| Live egress | Cloudflare Stream | $0.00 (zero egress) |
| Metadata | Azure 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 ​
Design mockups for all screens are in packages/ui/design-system/. They are the authoritative visual reference for the build phase.
Listen portal (web):
- listen-home.html — Home, 3 viewports
- listen-series.html — Series detail, 3 viewports
- listen-player.html — Mini player + full player, 3 viewports
- listen-downloads.html — Downloads, 3 viewports
- listen-steward.html — Media steward tools, 3 viewports
Standalone app (mobile):
- standalone-listen-home.html — App home, phone viewport
- standalone-listen-player.html — Full player, phone viewport
- standalone-listen-downloads.html — Downloads, phone viewport
- standalone-listen-series.html — Series browser, phone viewport
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 ​
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 ​
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 ​
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 ​
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 ​
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.
| Method | Path | Who can call | Notes |
|---|---|---|---|
GET | /series | member+ | List all series. Filter by ?type=MESSAGES. Returns: id, name, type, accessTier, artworkUrl (signed), episodeCount. Never returns storageKey. |
GET | /series/:id | member+ | Single series + episode list. Episodes in descending date order. Access tier check applied. |
GET | /items | member+ | List items. Filter by ?type=, ?seriesId=, ?since= (ISO timestamp for sync). Returns PUBLISHED items only. |
GET | /items/:id | member+ | Single item metadata + myReceipt (member's progress). No storageKey. |
GET | /items/:id/stream | member+ | 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/progress | member+ | Body: { progressSecs: number, completed: boolean }. Upserts MediaReceipt. Returns 204. |
POST | /upload/prepare | media_steward, minister, admin, ministry_leader | Body: { 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/status | media_steward, minister | Body: { status: 'PUBLISHED' | 'UNPUBLISHED' | 'ARCHIVED' }. Sets status, sets publishedBy + publishedAt on PUBLISHED. Returns 200. |
GET | /draft | media_steward, minister | Returns all DRAFT items awaiting approval, ordered by createdAt asc. Includes uploader name. |
GET | /live | member+ | 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 | /live | media_steward, minister | Body: { title, accessTier, scheduledAt? }. Calls LiveProvider.createLiveInput(). Creates LiveEvent with status=SCHEDULED. Returns { eventId, rtmpUrl, streamKey }. |
PATCH | /live/:id/start | media_steward, minister | Sets LiveEvent status=LIVE, startedAt=now. |
PATCH | /live/:id/stop | media_steward, minister | Sets LiveEvent status=ENDED, endedAt=now. Calls LiveProvider.stopLiveInput(). |
POST | /live/:id/publish | media_steward, minister | Archives the Cloudflare recording as a new MediaItem (DRAFT), links to LiveEvent.archivedItemId. Returns the new itemId. |
RBAC enforcement pattern ​
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 → proceedThe 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 ​
New infrastructure resources required before the first deployment of this feature:
| Resource | Type | Purpose | Bicep module |
|---|---|---|---|
media-sermons | Azure Blob container (private) | Stores all published VOD media files | infrastructure/modules/storage.bicep |
media-incoming | Azure Blob container (private) | Watch folder for batch upload path B | infrastructure/modules/storage.bicep |
media-watch-trigger | Azure Function (blob-triggered) | Fires on new blob in media-incoming/, validates, moves, creates DRAFT record | infrastructure/modules/functions.bicep |
AZURE_STORAGE_ACCOUNT_NAME | Key Vault secret | Storage account name for StorageProvider | kv-heritageva-prod-eus |
AZURE_STORAGE_ACCOUNT_KEY | Key Vault secret | Storage account key for SAS generation | kv-heritageva-prod-eus |
CLOUDFLARE_STREAM_API_TOKEN | Key Vault secret | Cloudflare Stream API token for LiveProvider | kv-heritageva-prod-eus |
CLOUDFLARE_ACCOUNT_ID | Key Vault secret | Cloudflare account ID | kv-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) ​
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) ​
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"
}