Skip to content

0010 — Sermons & Music Hub ​

Status: Accepted (2026-06-18 — owner accepted during ADR review)

Date: 2026-06-17

ADO work item: AB#3156

Deciders: Kristopher Turner (platform owner)


Context ​

The platform strategy identifies the Sermons & Music Hub (Epic AB#3137) as the first major feature Epic to ship after the platform core (AB#3074) and the iOS baseline. It is also the cost-sensitive feature in the entire roadmap: any architecture that introduces video transcoding pipelines, always-on CDN origin servers, or third-party streaming fees immediately violates the governing cost constraint — free or near-free until Microsoft nonprofit status is granted, then free/cheap under Azure nonprofit credits.

Heritage Virginia's content is entirely original community-produced media (recorded sermons, worship music, audio teachings). There is no third-party rights exposure and no DRM requirement. The audience is a closed membership — access is controlled by the platform's five-role RBAC (ADR 0006); no content is publicly reachable. Content discovery is catalog-driven (series, speaker, date, tags), not recommendation-engine-driven.

The feature set covers five stories in the Epic:

  • AB#3140 — inexpensive media storage
  • AB#3141 — catalog and metadata model
  • AB#3142 — web playback / streaming
  • AB#3143 — mobile streaming and offline download
  • AB#3144 — member-only RBAC enforcement

This ADR records how the Sermons & Music Hub sits on the platform composition defined in ADR 0008: which Azure primitives store and serve media, how the catalog is modeled, how clients reach files without a public URL, and where cost-driven revisit triggers live. It is a planning/architecture decision. It introduces no code.

A separate back-burner feature (member-posted media) is explicitly out of scope here; that feature will have its own ADR if and when it is prioritised.

Decision ​

We will store sermon and music media files as objects in a storage backend selected by cost (initially Azure Blob Storage Hot tier), keep all catalog metadata (title, speaker, series, date, tags, duration, object key) in Azure SQL Serverless, and serve playback via HTTP range requests using short-lived signed URLs issued exclusively by the platform API. Member-only access is enforced entirely server-side regardless of which storage backend is active — clients never hold a permanent or public URL. Mobile offline downloads are cached in the app's private storage under the same RBAC gate. No CDN, transcoding pipeline, or third-party streaming service is introduced at this stage; those are explicit revisit triggers tied to measured bandwidth cost.

The platform API wraps all storage operations behind a StorageProvider abstraction (a thin interface with operations: putObject, deleteObject, issueSignedUrl). All five supported backends — Azure Blob (SAS), AWS S3 (presigned), Google Cloud Storage (signed URL), Cloudflare R2 (S3-compatible signed URL), MinIO (S3-compatible signed URL) — implement signed-URL delivery in the same pattern, so the concrete backend can be swapped without changing client contracts or RBAC logic. The initial concrete implementation targets Azure Blob; backend selection is deferred to cost evaluation per ADR 0024.

StorageProvider abstraction ​

The platform API exposes a StorageProvider interface for VOD storage and a LiveProvider interface for live streaming. Both are injected via the service container — no endpoint handler references a concrete implementation.

typescript
// VOD storage — initial: Azure Blob; future: Cloudflare R2
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>
}

// Live streaming — 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>
}

The issueSasUploadUrl operation returns a write-only Azure Blob SAS URL scoped to a single object key. The browser uses this URL to upload large files directly to blob storage without routing bytes through the API server. This is the primary mechanism for Path A uploads.

All storage backends used or considered (Azure Blob via SAS, AWS S3 presigned URL, Google Cloud Storage signed URL, Cloudflare R2 S3-compatible signed URL, MinIO S3-compatible signed URL) satisfy this contract. Switching backends requires only a new concrete implementation of StorageProvider; the catalog model, RBAC enforcement, and client API contract are unaffected. The interface is fixed by this ADR; the concrete backend is selected later by the cost evaluation described in ADR 0024.

Storage and metadata model &ZeroWidthSpace;

Media files (video .mp4, audio .mp3/.m4a) are uploaded by staff through a platform API endpoint and written to a dedicated storage container/bucket (media-sermons or equivalent) via the StorageProvider. The platform API is the only writer; no client or background process touches storage directly. Each uploaded file produces an Azure SQL row in the SermonMedia table with the following logical columns:

The complete Prisma schema for all four tables (Series, MediaItem, MediaReceipt, LiveEvent) is the authoritative specification and lives in the design document:

Full schema → docs/internal/design/sermons-music-hub.md § Technical Specification → Database schema

Key schema decisions recorded here:

  • Series table owns the accessTier field (MEMBERS | BAPTIZED_ONLY). All items inherit from their series. There is no per-item access tier override.
  • MediaItem.storageKey is the provider-agnostic object key. It is never returned to any client in any API response.
  • MediaItem.status follows a strict workflow: DRAFT → PUBLISHED → UNPUBLISHED → ARCHIVED. Only PUBLISHED items are returned to members. See design doc for full state machine.
  • MediaReceipt has a unique constraint on [memberId, itemId] — progress is upserted, not appended.
  • LiveEvent.cloudflareInputId and cloudflarePlaybackId are server-side only. Clients receive only the signed HLS URL returned from LiveProvider.getLivePlaybackUrl().

Playback: signed-URL delivery &ZeroWidthSpace;

When an authorized member requests playback of a catalog item, the platform API:

  1. Validates the caller's session token via Clerk (ADR 0003) and confirms the member holds a role that permits media access (ADR 0006 — all five roles include read access to member-only content; unapproved signups and anonymous callers are denied).
  2. Calls StorageProvider.issueSignedUrl(key, ttlSeconds) — configurable expiry, initially 1–4 hours — scoped to the specific object. The concrete implementation produces a SAS URL (Azure Blob), a presigned URL (S3/R2/MinIO), or a signed URL (GCS) depending on the active backend; the caller receives the same contract regardless.
  3. Returns the signed URL in the API response; the client plays the file via HTTP range requests directly from the storage backend.

Signed URLs are not stored; they expire automatically. A client cannot share a permanent link because none is issued. Member-only access is guaranteed by this API gate regardless of which storage backend is active — switching backends does not weaken the access model.

This pattern means storage serves bytes; the platform API controls access — consistent with the API-first guarantee in ADR 0008: "no direct database access from any client; no business logic in web/mobile."

Mobile offline download &ZeroWidthSpace;

The React Native / Expo mobile app (ADR 0002) may allow a member to download a media file for offline playback. The download flow:

  • The app requests a SAS URL from the API (same authorization check as streaming).
  • The app downloads the file to the app's private sandbox (Expo FileSystem API) — not the device's public media library.
  • Offline playback reads from that private cache; no permanent public URL is exposed on-device.
  • Re-download is required if the cached file is deleted or the app is uninstalled.

RBAC is enforced at download time, not at playback time. This is an accepted trade-off: an authorized member who downloads a file could share the local copy. Given the community context (faith community, closed membership, original content), this risk is accepted — DRM is not warranted.

Cost model and revisit triggers &ZeroWidthSpace;

Cost driverInitial choiceRevisit trigger
Media storageAzure Blob Hot tier (pay-per-GB; ~$0.018/GB/mo)Evaluate Cloudflare R2 or Backblaze B2 if egress cost exceeds trigger below; migrate cold/archive media to Cool tier if library exceeds ~50 GB
Bandwidth (egress)Direct Blob egress via SAS URLs (~$0.087/GB; first 5 GB/mo free)If monthly egress cost exceeds $20–30: evaluate switching to a zero-egress backend (Cloudflare R2) or adding Azure CDN/Front Door. R2 eliminates egress fees entirely; CDN adds caching in front of Blob.
Video transcodingNone — upload original file as-isEvaluate Cloudflare Stream (per-minute pricing, signed playback) only if multi-bitrate adaptive streaming becomes a stated requirement for video
Metadata queriesAzure SQL Serverless (auto-pause at idle; ~$0.000145/vCore-second)No change expected; catalog queries are infrequent and small
Storage backendAzure Blob (initial)Concrete backend decision documented in ADR 0024; swappable via StorageProvider without client-side changes

CDN, Azure Front Door, and backend migration are not introduced now. The StorageProvider abstraction ensures that a backend switch later (e.g., from Azure Blob to Cloudflare R2) requires only a new StorageProvider implementation — no changes to catalog model, RBAC, or client API.

Alternatives considered &ZeroWidthSpace;

All options below are evaluated against the StorageProvider interface. Any option that supports signed/pre-authorized URL delivery satisfies the member-only access requirement; the access model does not change with the backend.

OptionStorage costEgress costNotesDisposition
Azure Blob Storage (Hot) + SAS URLs~$0.018/GB/mo~$0.087/GB (first 5 GB free)Fits the Azure-now preference (ADR 0004); SAS URL maps directly to StorageProvider.issueSignedUrl; pairs with existing Azure SQL and Clerk stackInitial default — lowest switching cost given Azure-first posture; swap if egress becomes material
Cloudflare R2 + S3-compatible signed URLs~$0.015/GB/mo$0.00/GB (zero egress)S3-compatible API; signed URL support; egress to the internet is free. Standout option for egress-heavy streaming media. No Azure pairing costStrong contender — evaluate when Azure egress exceeds the $20–30/mo trigger; requires StorageProvider R2 implementation only
Backblaze B2~$0.006/GB/mo~$0.01/GB (10 GB/day free)Cheapest at-rest storage; S3-compatible; lower egress than AzureViable for very large cold archives; egress still charged unlike R2; evaluate alongside R2
Wasabi~$0.0068/GB/mo$0.00/GB (zero egress)S3-compatible; no egress fees; minimum 90-day storage charge appliesSimilar profile to R2; less mature signed-URL ecosystem; include in cost evaluation
Cloudflare StreamPer-minute stored (~$5/1,000 min/mo)Per-minute delivered (~$1/1,000 min)Managed video transcoding + adaptive bitrate + signed playback URLs; strong for video specificallyOut of scope for initial phase (no transcoding requirement); revisit if adaptive bitrate becomes a stated requirement
AWS S3 + presigned URLs~$0.023/GB/mo~$0.09/GBIndustry reference; presigned URL maps to StorageProvider.issueSignedUrl; no Azure affinityNo advantage over Azure Blob at this scale; included in interface design as a supported backend
Google Cloud Storage + signed URLs~$0.020/GB/mo~$0.08/GBSigned URL support; GCS maps to StorageProvider.issueSignedUrlNo advantage over Azure Blob at this scale; included in interface design as a supported backend
Azure Media ServicesIncluded in streaming unit costBundledAdaptive bitrate, CDN integration, DRM; ~$50–200+/mo minimum; always-on streaming unitsRejected — violates cost constraint; DRM not required for original community content
YouTube / Vimeo unlisted hostingZeroZeroMature players; wide device support; zero costRejected — "unlisted" is not member-only; third-party holds member behaviour data; breaks closed-community guarantee. Fallback of absolute last resort only if all object-storage costs become prohibitive and no nonprofit credit path exists
Public object URLs (no signed URL, no API gate)Same as backendSame as backendSimpler client implementationRejected — any URL recipient can access any media file permanently; directly violates member-only requirement (ADR 0006)
Self-hosted media server (Jellyfin, Plex)IaaS VM/containerInternalFeature-rich; no per-GB egressRejected — always-on compute contradicts the serverless baseline in ADR 0004; cost advantage only at very high scale

Consequences &ZeroWidthSpace;

Positive &ZeroWidthSpace;

  • Storage and access-control costs start at effectively zero for a small library (first 5 GB/mo Blob egress free; SQL Serverless auto-pauses between services).
  • No always-on infrastructure to operate — Blob and SQL Serverless scale to zero when idle.
  • The signed-URL delivery model means the platform API is the sole choke point for access decisions; the closed-community guarantee cannot be bypassed by guessing a URL — and this holds regardless of which storage backend is active.
  • The StorageProvider abstraction means the concrete backend can be migrated (e.g., Azure Blob to Cloudflare R2) without changing the catalog model, RBAC logic, or any client-facing API. Backend selection is a cost decision, not an architecture change.
  • Original-format storage simplifies upload: staff upload what they record; no transcoding step to maintain.
  • Mobile offline download is straightforward to implement with Expo FileSystem and the same signed-URL endpoint used for streaming.
  • The catalog metadata model (Azure SQL) is queryable, filterable, and extensible without touching media files.

Negative / trade-offs &ZeroWidthSpace;

  • No adaptive bitrate streaming. Members on slow connections receive whatever bitrate the original file was recorded at. HTTP range requests allow seeking and progressive download, but quality does not adapt to bandwidth. This is acceptable for a small faith community with mostly Wi-Fi-connected members; it is a known limitation. Cloudflare Stream is identified as the lowest-cost path to adaptive video if this becomes a requirement.
  • Egress costs money at scale on Azure Blob. Every play pulls bytes from the active backend. A library of 500 sermons at 500 MB each, streamed frequently, can generate meaningful egress cost on Azure Blob. Cloudflare R2 (zero egress) eliminates this risk entirely; the $20–30/mo trigger is the deliberate decision point to evaluate switching.
  • Offline copies are not RBAC-enforced post-download. A member who is later revoked can still play locally cached files until those files are deleted. For this community context, this trade-off is accepted. A future hardening option is a periodic re-authorization check on app open, which can be added without changing this ADR.
  • Staff upload UX is not defined here. A staff-facing upload flow (portal page + API endpoint) is required to populate the catalog; its design is a Feature-level story, not an architecture decision.
  • StorageProvider abstraction adds an indirection layer. The interface is thin (three operations), so the overhead is minimal, but it must be implemented for each supported backend. Initial scope is Azure Blob only; other implementations are added when a backend migration is triggered.

Risks &ZeroWidthSpace;

  • Egress cost spike on Azure Blob — a viral moment (e.g., a widely shared sermon) or a misconfigured signed-URL expiry that allows repeated large downloads could cause unexpected egress charges. Mitigation: set a short default signed-URL expiry (1–2 hours), enable Azure Cost Management budget alerts, and evaluate Cloudflare R2 migration or CDN caching if the trigger threshold is reached. Switching to R2 eliminates egress risk entirely.
  • Signed-URL leakage — a client-side log, analytics call, or browser history could expose a signed URL before it expires. Mitigation: keep expiry short; do not log signed URLs in API responses or client telemetry; scope tokens to a single object (not the whole container/bucket).
  • Original-format compatibility — if staff upload a format not supported by native browser or Expo media players (e.g., .mov, .wmv), playback will fail. Mitigation: document supported formats (.mp4 H.264 video, .mp3/.m4a audio) in the staff upload UI; reject unsupported MIME types at the API upload endpoint.
  • Catalog growth without archiving — Azure SQL Serverless has no hard row limit for this use case, but a large unstructured tag corpus could degrade search. Mitigation: define a controlled tag vocabulary in the data model before launch; revisit full-text search (Azure Cognitive Search) only if catalog size warrants it.

References &ZeroWidthSpace;


Amendment — Standalone Media App (2026-06-24) &ZeroWidthSpace;

Added by: Kristopher Turner (platform owner)

The following decisions extend ADR 0010 to cover the standalone media app — a separate App Store / Google Play product that uses the same platform backend, the same Clerk authentication, and the same RBAC enforcement as the main community hub.

Amendment Decision &ZeroWidthSpace;

The Sermons & Music Hub will be delivered through two products: (1) the Listen portal built into the main community hub, and (2) a standalone media app distributed separately on the App Store and Google Play. Both products connect to the same platform API with the same Clerk session token and are subject to identical RBAC enforcement. The standalone app is a separate Expo build target within the existing apps/mobile workspace — not a separate codebase. It contains only media browsing, streaming, and offline download; no other platform features are included.

Standalone app scope &ZeroWidthSpace;

The standalone app includes: sign-in (Clerk Apple/Google), browse published series and items, stream any item, download for offline playback, manage downloads, and progress sync. It does not include member directory, family tools, calendar, announcements, or any admin/minister tools.

Authentication &ZeroWidthSpace;

Same Clerk SDK as the main mobile app. Members sign in with the same Apple or Google account. The Clerk session token is sent on every API request. The API validates the token and applies RBAC identically to all clients.

No new API surface &ZeroWidthSpace;

The standalone app uses all existing media endpoints (GET /api/v1/media/items, GET /api/v1/media/series, GET /api/v1/media/items/:id/stream, POST /api/v1/media/items/:id/progress). No new endpoints are required to support the standalone app.

Shared file cache — iOS App Groups &ZeroWidthSpace;

On iOS, both the main community hub app and the standalone media app will declare the same App Group entitlement (group.app.heritageva.media). Downloaded media files are written to the shared App Group container rather than each app's private sandbox. A file downloaded in either app is immediately available for offline playback in the other app. The shared container counts against the user's device storage once.

Both apps must be signed with the same Apple Developer team. The App Group identifier must be declared in both apps' app.json Expo configuration and registered in the Apple Developer portal.

Shared file cache — Android &ZeroWidthSpace;

Android does not have a direct equivalent to iOS App Groups. In the initial build (v1), each app maintains its own download cache in its private internal storage. File sharing between the two Android apps is not implemented in v1. This is a known limitation; revisit if user feedback indicates it is a meaningful friction point.

Auto-sync &ZeroWidthSpace;

The standalone app checks for new content on every foreground launch (polling GET /api/v1/media/items?since=<lastSyncTimestamp>). Optional background sync is implemented via expo-background-fetch (iOS BGAppRefreshTask, Android WorkManager) to fetch catalog updates while the app is in the background. Background sync fetches metadata only — it does not auto-download files. Files are only downloaded on explicit member action.

Progress sync &ZeroWidthSpace;

The standalone app POSTs playback progress to POST /api/v1/media/items/:id/progress on the same 10-second interval as the portal. Progress is stored against the member's account and is available to both the portal and the standalone app. A member can start listening in the portal and continue in the standalone app (or vice versa) without losing their position.

App name &ZeroWidthSpace;

Working name: Heritage Media. Subject to change before App Store submission. Bundle ID: app.heritageva.media (iOS and Android).

App icon &ZeroWidthSpace;

The standalone app uses a distinct icon — same color palette and visual family as the Tree of Life logo but different enough to identify on a member's home screen. Final icon to be designed before the build phase begins.

Design reference &ZeroWidthSpace;

Full design specification including screen inventory, data flows, RBAC matrix, and HTML mockups is in docs/internal/design/sermons-music-hub.md.


Amendment — Live Streaming, Content Taxonomy, and RBAC (2026-06-24) &ZeroWidthSpace;

Added by: Kristopher Turner (platform owner)

Amendment Decision &ZeroWidthSpace;

Live streaming is a first-class feature of the Sermons & Music Hub. It is not deferred. Heritage Virginia will stream select events live — Sunday services for members traveling, conferences, weddings, and special meetings. Live streaming uses Cloudflare Stream as the ingest and delivery platform. Staff encode and push using OBS Studio (camera-based events) or Zoom's built-in RTMP streaming (Zoom meetings). Playback is delivered via signed HLS URLs through the existing API gate — no member can access a live stream without a valid session token and RBAC check. After a live event ends, Cloudflare auto-archives the recording; a media steward or minister reviews and publishes it to the VOD catalog.

Live streaming platform &ZeroWidthSpace;

Cloudflare Stream is selected over Azure Media Services (rejected — violates cost constraint at $50–200+/month minimum) and self-hosted solutions (rejected — always-on compute contradicts serverless baseline). Cloudflare Stream is pay-per-minute with zero egress fees, accepts standard RTMP ingest from OBS and Zoom, and delivers HLS via signed playback URLs consistent with the existing StorageProvider pattern.

The StorageProvider interface is extended with a LiveProvider interface for live operations:

text
LiveProvider
  createLiveInput(title, accessTier)     → { rtmpUrl, streamKey, playbackId, inputId }
  getLivePlaybackUrl(inputId, ttl)       → signed HLS URL
  stopLiveInput(inputId)                 → void
  getArchivedRecording(inputId)          → storageKey (for catalog ingest)

Encoder options &ZeroWidthSpace;

OptionBest forWhat operator does
OBS Studio (free)Camera-based eventsInstall once; update stream key per event; click Start Streaming
Zoom Custom RTMPMeetings already on ZoomPaste RTMP URL in Zoom settings once; tap "Live on Custom Service" on event day
Larix Broadcaster (phone)Simple backup onlyOpen app, paste RTMP URL, tap record

Operator guide: docs/internal/operations/live-streaming-guide.md

Content taxonomy &ZeroWidthSpace;

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

Types: Messages (sermons, teachings, testimonies), Music (worship recordings), Videos (video recordings), Live (active streams only).

Series: All items belong to a named series. Common series patterns: Sunday Morning Messages — YYYY, Wednesday Night — YYYY, Conferences (per event), Worship Music, Special Services, Guest Messages.

Access tiers: members (default — any approved member) or baptized-only (members with baptized: true on their profile). Tier is set at the series level; all items in a series inherit it. Enforced server-side at the signed URL gate alongside the RBAC role check.

RBAC for media management &ZeroWidthSpace;

RoleUploadPublish / Unpublish / ArchiveManage live events
Member
Ministry Leader✓ → DRAFT
Admin✓ → DRAFT
Minister
Media Steward

Ministry Leader and Admin uploads land in DRAFT status and are not visible to members until a Minister or Media Steward publishes them. This creates a lightweight approval queue visible in the steward management UI.

Upload paths &ZeroWidthSpace;

Two supported paths:

  1. Web portal (primary): listen.heritageva.app/steward — SAS direct upload (browser → API for short-lived SAS token → browser uploads directly to Azure Blob in chunks). Handles large video files without API server timeout risk.
  2. Blob watch folder (secondary): Staff drop files into the media-incoming/ storage container via Azure Storage Explorer. An Azure Function validates, moves the file, and creates a DRAFT catalog record from a companion .json sidecar file. Staff complete metadata in the web portal before publishing.

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