The end-user surface of a nationwide U.S. church discovery platform: an Angular 22 SSR application with a Node.js Express Backend-for-Frontend (BFF), served by a single Node process. The BFF holds the OIDC session and proxies every data call to the standalone Directory API; the browser never sees an access token directly. Anonymous SEO routes are server-side rendered; authenticated routes are client-side.
Architecture: the full end-to-end platform architecture — Churches UI + BFF, the Directory API, and the Functions data pipeline — is documented in ARCHITECTURE.md. This README covers only how to run and deploy this repo.
| Repo | Role | How Churches interacts |
|---|---|---|
| Identity | OIDC Identity Provider | OIDC authorization-code flow via openid-client in the Node BFF |
| Directory | Church directory API | BFF proxies /directory/api/** via a fetch-based proxy (src/bff/proxy.ts), attaching the user Bearer token (scope directory) when present |
| Infrastructure | Health monitoring dashboard | Polls GET /health (returns Healthy) |
See ARCHITECTURE.md for the end-to-end platform architecture (request flow through the Express/SSR stack, OIDC + proxy sequence diagrams, the Directory API, and the Functions data pipeline). In one paragraph:
A single Node process runs both the Angular 22 SSR renderer and an Express BFF. The BFF owns the
OIDC session (openid-client v6, PKCE; scopes offline_access openid profile email directory),
proxies /directory/api/** to the Directory API with the session's Bearer token (or anonymously),
and requires X-CSRF: 1 on mutating calls. Anonymous routes (/, /churches, /churches/:slug)
are server-rendered for SEO; authenticated routes (/contribute/:slug, /admin/moderation) are
client-rendered. Frontend is zoneless Angular; maps are Leaflet, browser-only. Observability: OTLP
traces/metrics → Grafana Alloy; structured logs → Elasticsearch (pino-elasticsearch).
GET /health → Healthy.
| Layer | Technology |
|---|---|
| Framework | Node.js 24 / Express 5 |
| Auth / BFF | openid-client v6 + express-session + connect-redis |
| Frontend | Angular 22 SSR (@angular/ssr) |
| Observability | OpenTelemetry → Grafana Alloy (OTLP), pino → Elasticsearch |
| Hosting | Azure App Service (Linux, Node 24) |
| Secrets | Azure Key Vault (Managed Identity) |
The full local stack needs the Identity server and the Directory API running, plus the local config:
Environment variables (.env.local, gitignored; npm start and serve:ssr load it):
PORT=4000
OidcAuthority=https://localhost:7261
DirectoryApiAddress=https://localhost:7002
ChurchesClientId=<dev client id>
ChurchesClientSecret=<dev client secret>
SessionSecret=<at-least-32-chars-dev-secret>
SessionStore=memory
SitemapBlobBaseUrl=https://crgolden.z13.web.core.windows.net/
PORT and SessionSecret are required, and so are RedisHost and RedisPort unless
SessionStore=memory; a missing one stops the process at startup. SessionStore=memory keeps sessions
in process (they don't survive a restart). To use a local Redis instance instead (e.g. if you already
run one for Manuals/Infrastructure), remove SessionStore and set RedisHost=localhost,
RedisPort=6379 and the five keepalive settings (RedisSocketTimeoutMs, RedisPingIntervalMs,
RedisReconnectStepMs, RedisReconnectMaxDelayMs, RedisReconnectJitterMs; positive integers, with the
ping interval shorter than the socket timeout); SessionStore=memory wins over any Redis settings.
Key Vault secrets required at runtime (production):
Each is wired up as an App Service setting holding a @Microsoft.KeyVault(SecretUri=...) reference, so
App Service resolves it from Key Vault at startup using the app's managed identity and hands it to the
process as an ordinary environment variable. The app has no Key Vault SDK dependency and makes no vault
calls of its own.
| Secret name | Description |
|---|---|
ChurchesClientId |
OIDC client ID |
ChurchesClientSecret |
OIDC client secret |
ElasticsearchUsername |
Elasticsearch basic auth username |
ElasticsearchPassword |
Elasticsearch basic auth password |
RedisPassword |
Redis TLS password |
SessionSecret |
Cookie signing secret (≥ 32 chars) |
src/server.ts— Express entry:/health, request logging, session,/bff/*,/directory/apiproxy, Angular SSR catch-all.src/bff/*—openid-clientauth, session (Redis / in-memory), Directory proxy, CSRF.src/environments/*— per-environment config (notably SSRallowedHosts), swapped viafileReplacements.instrumentation.mjs— OpenTelemetry sidecar (OTLP→Alloy);src/telemetry/logging.ts— pino→Elasticsearch.
npm install
npm start # ng serve — dev server with SSR via server.ts, http://localhost:56432; /bff and /directory/api go through src/proxy.conf.js (ASPNETCORE_URLS)
npm run build # SSR production build → dist/churches.client/{server,browser}
npm run build:ci # SSR build with the ci environment (allowedHosts=localhost)
npm run serve:ssr # run the full SSR + BFF: node --import ./instrumentation.mjs dist/churches.client/server/server.mjs
npm run lint # ESLint
npx vitest run # unit tests (Vitest); add --coverage for LCOV
npm run e2e # build:ci + Playwright E2E vs the real Node server + mock Directory API; /bff/* is route-mocked (self-builds)
npm run e2e:synthetic # seeded random walk of a deployed stack (WalkerBaseUrl); normally run on a scheduleSee TESTING.md for the full E2E / synthetic-walker guide and CI configuration.
src/
server.ts # Express app: session, BFF routes, SSR catch-all
bff/ # openid-client auth, session, Directory proxy, CSRF
app/ # Angular application
environments/ # per-environment config (allowedHosts, etc.)
telemetry/ # pino → Elasticsearch logging
e2e/ # TypeScript Playwright E2E + synthetic walker
instrumentation.mjs # OpenTelemetry Node SDK init (loaded via --import)
The GitHub Actions workflow (.github/workflows/main_crgolden-churches.yml) triggers on pushes to
main and pull requests.
Build job — npm ci → lint → npm run build:ci (SSR, ci config) → Vitest coverage →
Playwright E2E → SonarCloud (JS LCOV) → npm run build (production) → npm prune --omit=dev →
upload deployment artifact.
Deploy job — deploys the Node bundle to Linux Azure App Service crgolden-churches
(Production slot) via Azure OIDC. Startup command:
node --import ./instrumentation.mjs dist/churches.client/server/server.mjs
There is no post-deploy job. The deployed app is exercised by the scheduled synthetic walker
(.github/workflows/synthetic.yml), which signs in through Identity with a passkey and walks a
seeded random journey twice a day.
This repo deploys only the frontend + Node BFF; the Directory API and its SQL schema deploy from the
Directory repo (crgolden-directory).