Skip to content

System architecture ​

Audience: Platform team (internal only)

Status: Current — reflects all accepted ADRs 0001–0024.

ADO tracking: Platform Epic AB#3074; Architecture ADRs AB#3073, AB#3154, AB#3155.

Source of truth: ADRs 0001–0024. Where anything in this document conflicts with an ADR, the ADR wins.


Diagram: A draw.io architecture diagram (system-architecture.drawio + system-architecture.png) is required per HCS documentation standards and must be created separately.


Overview ​

Heritage Community Hub is a closed, member-only community management platform for Heritage Virginia in Nickelsville, VA. "Closed" means every account — adult or child — must be approved by a minister before gaining access to any community content. There is no self-registration path that grants community access.

The platform serves four groups of users:

User groupAccount typeHow they access the platform
Adult members (Member, Spouse)Social login via Clerk (Apple/Google)Web app (apps/web) and mobile app (apps/mobile)
ChildrenParent-managed credential (username + PIN/password, no email, no social login)Restricted sections of the web app; mobile access in later phases
Ministry leaders and adminsSocial login via ClerkWeb app; elevated role permissions via server-side RBAC
Minister / administratorSocial login via ClerkAdmin portal sections inside the web app; approval workflows

Messaging in the platform is one-way broadcast only — no user-to-user reply threads. Content flows through an authoring and approval workflow before reaching any transport channel.

The platform is API-first: all business logic, data access, and authorization decisions live in the backend API (apps/api). Web and mobile clients consume the platform exclusively through a typed SDK (packages/api-client). No client holds business logic, talks to the database directly, or makes authorization decisions of record.


Monorepo structure ​

All application code lives in a single Git repository organized into three layers (ADR 0001). The package manager is pnpm (v9+) with pnpm-workspace.yaml declaring workspace members. Turborepo orchestrates the task graph — build, test, lint, dev — with local caching enabled and affected-only CI via path-based change filtering.

text
heritage-community-hub/
├── apps/
│   ├── api/             # Node.js backend — containerized (Docker)
│   ├── web/             # React + TypeScript web client (Vite)
│   └── mobile/          # React Native + Expo (iOS and Android)
├── packages/
│   ├── shared-types/    # domain types and API contracts (single source of truth)
│   ├── api-client/      # typed SDK — the only sanctioned way a surface calls the API
│   ├── shared-utils/    # validation, formatting, RBAC helpers
│   ├── shared-config/   # shared constants
│   └── ui/              # design tokens and shared design system (Storybook)
├── infrastructure/      # Bicep IaC (dev / test / prod environments)
├── database/            # Prisma schema, migrations, seeds
└── docs/                # ADRs, architecture guides (internal); member-facing in-app docs

apps/* and packages/* are planned; they will be scaffolded during Phase 1 (ADO AB#3074). The workspace structure and Turborepo task graph are already in place.

Workspace conventions ​

  • packages/shared-types is the single definition of every API request and response type. Web, mobile, and API all import from the same package — no type drift between surfaces.
  • packages/api-client is the only sanctioned entry point from a surface to the API. Direct HTTP calls from apps/web or apps/mobile to the backend, or any direct database access from a client, are prohibited.
  • Provider SDK imports (@azure/storage-blob, @azure/keyvault-secrets, DB driver) are permitted only inside their adapter module in apps/api/src/adapters/. No SDK import may appear in a route handler, service class, or domain entity.
  • Feature code starts in a surface-local folder (apps/<surface>/src/features/<feature>/). A slice is promoted to packages/features/* only when it is genuinely shared across both web and mobile surfaces.

Runtime architecture &ZeroWidthSpace;

API layer &ZeroWidthSpace;

The backend API (apps/api) is a containerized Node.js service (Express or Fastify) deployed on Azure Container Apps. Containerizing the API means the same Docker image runs identically in local development, in continuous integration (CI), and in production — and on any Open Container Initiative (OCI)-compliant host (ADR 0024).

The API is the single source of truth for all business logic, data access, and authorization. It owns seven bounded responsibilities (ADR 0008):

  1. Request framework — input validation, error handling, CORS, rate limiting, and the OWASP API security baseline.
  2. Identity and access — Clerk JWT verification, sub-to-Users row mapping, and server-side RBAC enforcement.
  3. Member and family domainUsers table, FamilyGroups, approval workflow, member profiles, and child sub-accounts.
  4. Shared platform services — notification transport (POST /notifications/send), audit logging (AuditLog table), and media storage primitives.
  5. Shared package layer — consumed at build time from packages/*; the API does not own these packages but enforces their use.
  6. Core web shell support — serves auth-gated API routes the web shell depends on (account, profile, role lookups).
  7. Operational and security baseline — all secrets via Key Vault, structured logging to Application Insights, cost guardrails.

Request flow (authenticated endpoint):

text
Client (web / mobile)

  │  HTTPS + Clerk JWT in Authorization header

apps/api  ─── middleware: ClerkJWT.verify() → extract sub claim
              middleware: load Users row by sub → attach role + status to request
              middleware: RBAC guard (role check for this route)
              route handler: business logic using domain services
              domain service: calls repository (ORM) → Postgres
              domain service: calls shared services (notifications, audit, storage) as needed

  │  JSON response

Client

The role read from Users (not from the JWT) is authoritative. A client can carry any claims in its token; the server ignores role claims from the client and reads the database.

Web client &ZeroWidthSpace;

apps/web is a React and TypeScript single-page application (SPA), bundled with Vite, and hosted on Azure Static Web Apps (SWA) Free tier. Clerk's React SDK handles session management on the web; the SPA delegates all business logic to the API via packages/api-client.

The web shell provides: authentication-gated routing, navigation, layout, and account and profile screens. Feature UIs (Sermons & Music Hub, Calendar, Announcements, and so on) mount inside the shell. Feature code begins in apps/web/src/features/<feature>/ and is promoted to shared packages only when genuinely reused on mobile.

The web client never reads directly from the database, never evaluates authorization decisions of record, and never imports provider SDKs. It may hint the UI (for example, hide an admin menu) using a role value returned from the API, but every protected action is re-enforced server-side on submission.

Mobile clients &ZeroWidthSpace;

apps/mobile is a React Native and Expo managed-workflow application (ADR 0002). It shares packages/shared-types, packages/api-client, and packages/shared-utils from the monorepo.

Delivery order: iOS first (Phase 2), then Android (Phase 5). Both are built and submitted via Expo Application Services (EAS) Build and Submit, which produces iOS archives and Android application bundles without requiring macOS for Android builds.

Key constraints:

  • Authentication on mobile requires native builds (not Expo Go) because Sign in with Apple uses expo-apple-authentication — a native module that browser-based OAuth redirects cannot satisfy.
  • All API calls go through packages/api-client; the mobile client has no direct database access.
  • Push notification tokens (APNs for iOS, Firebase Cloud Messaging (FCM) for Android) are managed through Expo's push notification service. The API stores the Expo push token against the Users row and calls Expo's push API through the push adapter in the notification transport.

The Android native Google Sign-In path via Clerk's Expo SDK was not verified in Phase 0 research. This must be confirmed before mobile scaffolding begins in Phase 4 (ADO AB#3074).

Database &ZeroWidthSpace;

The platform database is PostgreSQL, running on Azure Database for PostgreSQL Flexible Server as the active managed host (ADR 0024, superseding the earlier Azure SQL Serverless choice in ADR 0004).

The database is accessed through Prisma with provider = "postgresql". The Prisma schema (database/schema.prisma) is the canonical definition of the data model. No raw dialect-specific SQL appears in service or route code; queries are expressed through Prisma's query API or through a repository layer. This is the portability guarantee: the identical Postgres dialect and Prisma schema run unchanged on AWS RDS for PostgreSQL, GCP Cloud SQL for PostgreSQL, Supabase, or a self-hosted PostgreSQL server. Relocating the database requires a connection-string update, not a query rewrite.

The burstable B1ms tier on Flexible Server covers low-traffic usage and is cost-comparable to the Azure SQL Serverless free tier for a community of ~200 members. Microsoft Nonprofits Azure credits will cover production costs once the grant is approved.

Authentication &ZeroWidthSpace;

Authentication is split by account type (ADR 0003, ADR 0007):

Adult members authenticate through Clerk using Apple Sign-In or Google Sign-In. Clerk acts as the OpenID Connect (OIDC) provider: it handles the Apple/Google token exchange, session management, and JSON Web Token (JWT) issuance. The platform never stores an Apple or Google refresh token.

On first sign-in, the API creates a Users row with credential_type = 'social', role = 'visitor', and status = 'pending_approval'. A minister approves the account — the API updates Users.role and Users.status. On every subsequent API call, the server reads Users.role from the database; the JWT sub claim is the only trusted client identity claim.

Children have no email address, no social login, and no Clerk account. They authenticate with a parent-assigned username and PIN or password stored directly in the platform's Users table. The PIN or password is hashed with Argon2id and verified server-side. The COPPA (Children's Online Privacy Protection Act) boundary is clean: no child personal information is sent to Clerk.

Infrastructure operators and CI/CD automation use Microsoft Entra (Azure Active Directory). No community member ever holds an Entra role. This is the two-plane separation defined in ADR 0006. CI/CD (GitHub Actions) authenticates to Azure via a user-assigned managed identity (id-heritageva-github-actions) with OIDC workload identity federation — no stored client secrets (ADR 0029). The Container App uses a separate system-assigned managed identity for Key Vault access at runtime.

Storage &ZeroWidthSpace;

File and media storage is accessed through a StorageProvider interface that exposes upload(), delete(), and signedUrl() operations (ADR 0024, aligned with ADR 0010). The active provider behind the interface is Azure Blob Storage. An S3-compatible object store (AWS S3, Cloudflare R2, GCP Cloud Storage) can be substituted by replacing the Azure adapter module; no route handler or service class changes.

Clients never receive a permanent public URL for any stored file. The API issues short-lived signed URLs for each download or playback request, enforcing RBAC on every file access regardless of which storage backend is active.

All @azure/storage-blob SDK imports are confined to apps/api/src/adapters/storage-azure.ts. This boundary is enforced at code review and will be formalized with an import/no-restricted-paths ESLint rule once the adapter directories are established.

Notifications &ZeroWidthSpace;

The platform provides a single notification transport as a shared API service (ADR 0013). Features call POST /notifications/send; the transport fans out to all of a member's active channels simultaneously.

Four channels:

ChannelProviderCostNotes
SMSTwilio (behind SmsProvider adapter)~$17/month at congregation scaleAdult members only; requires A2P 10DLC registration before activation
Mobile pushExpo Push API → APNs / FCMFree (Expo free tier)Requires Expo push token on file; graceful fallback on stale token
EmailSendGrid free tier (behind EmailProvider adapter)Free at low volumeAll adult members; provider-neutral adapter
In-appPostgres Notifications tableCovered by DB budgetAlways-available baseline; cannot be suppressed

Fan-out is parallel. A failure on one channel does not block delivery on others. Each adapter logs a delivery attempt — success or error code — to Application Insights through the standard observability model (ADR 0005).

Members may suppress non-urgent SMS (priority 0) through their profile preferences. Urgent notifications (priority 2 — weather or safety) always send regardless of member preference. Children receive in-app notifications only; they have no email and no phone number of their own.

Provider SDK imports (Twilio, SendGrid) are confined to their respective adapter modules. Rotating a provider — for example, swapping SendGrid for a different email provider — requires only an adapter update; no feature endpoint or route handler changes.


Provider abstraction pattern &ZeroWidthSpace;

The platform adopts a portable-by-design posture for the four cloud-provider lock-in seams: compute, database, storage, and secrets (ADR 0024). The goal is not multi-cloud operation — Azure is the active and only provider — but ensuring that relocating to another provider is an adapter swap rather than a partial rewrite of business logic.

SeamInterfaceActive Azure implementationPortable alternatives
ComputeOCI container (Docker)Azure Container AppsAWS Fargate/ECS, GCP Cloud Run, self-hosted
DatabasePrisma + repository layer (no raw dialect SQL in business logic)Azure DB for PostgreSQL Flexible ServerAWS RDS, GCP Cloud SQL, Supabase, self-hosted Postgres
StorageStorageProvider interface (upload, delete, signedUrl)Azure Blob Storage adapterAWS S3, Cloudflare R2, GCP Cloud Storage, any S3-compatible store
SecretsSecretsProvider interface (getSecret(name))Azure Key Vault adapterAWS Secrets Manager, GCP Secret Manager, environment-variable fallback

The rule enforced at code review (and by ESLint import/no-restricted-paths once formalized): provider SDK imports are permitted only inside their adapter module. No Azure SDK type, import, or async pattern may appear in a route handler, service class, or domain entity.

Auth (Clerk), CI/CD (GitHub Actions), and the shared packages (pure TypeScript) carry no provider coupling by design; they require no abstraction layer.


Observability &ZeroWidthSpace;

The platform implements a four-layer observability model (ADR 0005). All tooling choices in layers 2 and 3 are for the Azure-native path.

Layer 1 — Application audit log: an AuditLog table in Postgres records every security-relevant event: sign-in, sign-out, session duration, approval actions, and content publication. This layer ships in Phase 2 regardless of any tooling decision and is the minister's record of who accessed the platform and when.

Layer 2 — Application telemetry: the API, web client, and mobile client are instrumented to capture errors, performance traces, and custom events. The Azure-native choice is Azure Application Insights (~5 GB/month free tier). For the mobile client (apps/mobile), Application Insights has no official React Native SDK; Sentry or the OpenTelemetry React Native SDK is used instead. Telemetry calls in application code are wrapped behind a thin facade in packages/shared-utils/telemetry so the underlying SDK is swappable.

Layer 3 — Infrastructure monitoring and alerts: resource health, availability, and cost alerts via Azure Monitor and Log Analytics (~5 GB/month free; 7-day default retention on the free tier). Alert action groups are configured for cost overrun and service health events.

Layer 4 — Auth-provider logs: the Clerk dashboard provides session event logs, sign-in attempts, and suspicious-activity signals for the adult authentication flow.


Security boundaries &ZeroWidthSpace;

Closed community gate. No content, no directory information, and no community data is reachable without an active, minister-approved account. The approval workflow (ADR 0007) gates every account type — new members, spouse additions, and content publication — through a minister review step before any access or visibility is granted.

COPPA boundary. Child sub-accounts carry no email address, no social-login credential, and no personal information beyond a parent-assigned username. No child data is sent to any third-party service including Clerk. The managing parent (an approved adult member) provides the required verifiable parental consent at child account creation.

RBAC at the API layer. Six canonical Plane-2 roles are enforced server-side in every protected API route (ADR 0006). The six roles are:

RoleSlugScope
Community leader / adminadminFull system access; member approval authority; all configuration
Ministry leaderministry_leaderManages their specific ministry area; limited admin
Small group leadergroup_leaderManages their assigned small group
MembermemberStandard access; family management (with approval)
Visitor / prospectivevisitorApproval-gated; minimal read access only
Communications authorcomms_authorDrafts Announcements for assigned audiences and submits to the approval queue; cannot approve, reject, or publish — including their own drafts; scoped to Announcements only (ADR 0023)

No client sends a role claim that the server trusts. The sub claim from the Clerk JWT identifies the user; the server reads Users.role from the database for every authorization check.

Plane 1 — Infrastructure access (operators and CI/CD) uses Azure RBAC and Microsoft Entra with least-privilege managed identities. This plane is entirely separate from community member authentication and authorization; the two planes never mix (ADR 0006).

Content moderation. The one-way broadcast model (no user-to-user messages or reply threads) collapses inbound moderation: no member can publish content without it passing through the authoring and approval workflow (ADR 0012, ADR 0023). The comms_author role can draft and submit; an approver with appropriate role must approve before any notification transport call is made.

Secret management. All provider credentials — Clerk API key, Twilio API key, SendGrid API key, database connection string — are stored in Azure Key Vault (kv-hcs-vault-01) and injected at container startup through the SecretsProvider adapter. No secrets are committed to the repository.


Architecture diagram note &ZeroWidthSpace;

Diagram: A draw.io architecture diagram (docs/internal/architecture/system-architecture.drawio + docs/internal/architecture/system-architecture.png) is required per HCS documentation standards and must be created separately. The diagram should illustrate the composition map from ADR 0008: surfaces → api-client SDK → apps/api platform core → cloud baseline, with the four provider abstraction seams (ADR 0024) annotated.


References &ZeroWidthSpace;

ADRDecision
ADR 0001Monorepo — pnpm workspaces + Turborepo
ADR 0002Mobile — React Native + Expo managed workflow, EAS Build
ADR 0003Authentication — Clerk for adults, parent-managed for children
ADR 0004Cloud/hosting stack — GitHub Actions CI/CD, Azure SWA, Azure Blob, Key Vault (compute and DB superseded by ADR 0024)
ADR 0005Observability — four-layer model, Application Insights, Clerk dashboard
ADR 0006RBAC — two planes; six canonical Plane-2 roles; server-side enforcement
ADR 0007Account and family-group identity; approval workflow; COPPA boundary
ADR 0008Platform composition — seven responsibilities; API-first contract
ADR 0013Notification transport — SMS (Twilio), push (Expo), email (SendGrid), in-app
ADR 0024Cloud portability — four provider abstraction seams; Postgres; Azure Container Apps

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