Skip to content

minigames

Backend Coverage Frontend Coverage

Word-based minigames platform: an ASP.NET Core API backed by Cosmos DB (MiniGamesBackEnd/), a Next.js/React/TypeScript frontend (frontend/minigames/), and Terraform-managed Azure infrastructure (tf/). See AGENTS.md for contribution conventions, doc/roadmap.md for the platform's architecture plan, and the documentation export runbook for packaging the Markdown sources for a separate MkDocs site.

Local development

Prerequisites

  • .NET 10 SDK
  • Node 24+ and npm (see frontend/minigames/package.json)
  • Docker Desktop — only needed for containerized testing or the Cosmos DB Emulator
  • Azure CLI (az login) — only needed to point the backend at the real Cosmos DB account

Frontend browser smoke tests

The focused browser journeys use Playwright with Chromium. Install the browser once in each environment where the tests run, then execute the explicit E2E script from the repository root:

npx --prefix .\frontend\minigames playwright install chromium
npm run test:e2e --prefix .\frontend\minigames

The test harness starts its own Next.js development server and deterministic mock API. It does not require the ASP.NET Core backend, Cosmos DB, or Azure credentials. The regular npm test command continues to run the fast Vitest suite only.

Which scenario do you need?

You're changing... Run backend Run frontend Notes
Both frontend and backend Locally Locally Scenario 1 — CORS just works
Frontend only, need real/live data Deployed (not run by you) Locally, or in a local container Scenario 2 / Scenario 3 — CORS gotcha below
Backend only Locally Locally, unmodified Scenario 4

Scenario 1: full end-to-end locally

The simplest option, and the only one where CORS requires zero configuration.

Configure the shared cookie-signing key below, then start the stack using make up. It starts the Cosmos emulator, API, seed data, and frontend in sequence, and returns only after the frontend is ready. The backend runs through dotnet watch and the frontend through Next.js' development server, so both reload as you save changes. Processes run in the background and write logs to .local/backend.log and .local/frontend.log; stop the complete stack with make down.

make seed-database is safe to re-run. It creates the local Cosmos DB database and all five containers with the same names, partition keys, TTLs, and puzzle index exclusion as Terraform, then calls the local API to create (or reuse) today's Letter Golf puzzle. It starts the backend if needed. The emulator certificate must be trusted before the seed command can connect; follow the emulator documentation for your platform rather than disabling TLS validation.

On macOS, trust the .NET development certificate once before starting the local environment:

dotnet dev-certs https --trust

make backend-up exports that trusted certificate into ignored .local/ files and mounts it into the emulator, so the backend and seed tool validate TLS normally. If the emulator is already running when you upgrade to this setup, run make backend-down and then make backend-up once to recreate it with the development certificate.

The local setup uses the ARM64-compatible vnext-latest emulator image, which is currently a preview. Its Data Explorer is available at https://localhost:1234 and its readiness endpoint is http://localhost:8080/ready.

  1. Backend development environmentmake backend-up uses ASPNETCORE_ENVIRONMENT=Development, listening on http://localhost:5236 (see Properties/launchSettings.json).
  2. CORS in Development allows any loopback origin automatically (see the allowLoopbackOrigins check in Program.cs) — whatever port the frontend dev server picks works with no configuration.
  3. You still need a Cosmos DB to talk to — see Cosmos DB — local options below.
  4. Shared cookie-signing key — generate a development key, then supply the exact same base64 value to both processes. Never commit this key:
openssl rand -base64 32

Set it for the backend from MiniGamesBackEnd/ using user secrets:

dotnet user-secrets set "Cookie:SigningKey" "<generated-value>"
  1. Frontend — copy frontend/minigames/.env.example to frontend/minigames/.env.local (gitignored, never commit real values here) and set COOKIE_SIGNING_KEY to the same generated value:
NEXT_PUBLIC_API_BASE_URL=http://localhost:5236
COOKIE_SIGNING_KEY=<same-generated-value>

AZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRET/BFF_BACKEND_SCOPE and CIAM_TENANT_ID/CIAM_CLIENT_ID/CIAM_CLIENT_SECRET are only needed to exercise the internal identity-link endpoints and the sign-in (OIDC) flow, respectively — see the Configuration reference table below for where each value comes from. Everything else (anonymous gameplay) works without them.

  1. Start the local stack — from the repository root:
make up

Open http://localhost:3000. Use make down when you are finished.

Scenario 2: frontend only, against the deployed backend

Use this to test UI-only changes against real, already-seeded data without running the backend or Cosmos locally. Point .env.local at the deployed API's Container App FQDN (not api.verbolus.com — see the note below):

NEXT_PUBLIC_API_BASE_URL=https://minigame-api.happyplant-4de4219b.canadacentral.azurecontainerapps.io

This is the CORS issue. The deployed backend's CORS policy is locked down to a single production origin (Cors__AllowedOrigins__0 = https://verbolus.com, see tf/backend/container_app.tf and tf/backend/vars.tf) and does not allow loopback origins outside Development. A browser calling it from http://localhost:3000 gets blocked by CORS before the response ever reaches your frontend code — there's no status code to inspect, just a failed fetch and a CORS error in the browser console/Network tab.

There's no built-in dev-origin allowlist on the deployed backend today. In order of preference:

  1. Run the backend locally instead (Scenario 1). Development CORS allows any loopback port with zero config, and this is almost always the right answer unless you specifically need the live deployed environment (e.g. reproducing a production-only data issue).
  2. If you genuinely need the live deployed backend, temporarily add your local origin — review and revert this immediately, and get sign-off before touching the shared production Container App:
az containerapp update --name minigame-api --resource-group Alarming --set-env-vars "Cors__AllowedOrigins__1=http://localhost:3000"

Prefer doing this through a reviewed Terraform change to allowed_origins in tf/backend/vars.tf if the need is recurring rather than one-off. Never widen this to AllowAnyOrigin, and never leave a stray dev origin configured after you're done.

Note: api.verbolus.com is a CNAME to the old App Service, which is stopped — production traffic today goes straight to the Container App FQDN via NEXT_PUBLIC_API_BASE_URL. Don't point local testing at api.verbolus.com.

Scenario 3: frontend in a local Docker container, against the deployed backend

The same CORS caveat as Scenario 2 applies — the browser makes the cross-origin call regardless of whether the frontend is containerized or running via npm run dev. Next.js inlines NEXT_PUBLIC_* variables into the client bundle at build time, so it must be passed as a build arg, not a container runtime env var:

docker build --build-arg NEXT_PUBLIC_API_BASE_URL=https://minigame-api.happyplant-4de4219b.canadacentral.azurecontainerapps.io -t minigames-frontend:local frontend/minigames
docker run --rm -p 3000:3000 minigames-frontend:local

Open http://localhost:3000 and resolve any CORS block the same way as Scenario 2.

Scenario 4: backend-only changes, frontend unmodified

Run the frontend exactly as in Scenario 1 (.env.local pointed at http://localhost:5236, npm run dev) and don't touch any frontend files. This gives you a working, unmodified UI to exercise while iterating on backend code, e.g. with dotnet watch run from MiniGamesBackEnd/ for fast reload on save.

Cosmos DB — local options

Every scenario above needs the backend to reach a Cosmos DB account. Two options:

Option A: Cosmos DB Emulator

(Recommended if you don't have Azure access, or want fast/offline iteration.)

Run the Linux Cosmos DB Emulator container (check that page for the current image tag and any additional required ports — they change between emulator releases):

docker run --rm -p 8081:8081 -p 8080:8080 -p 1234:1234 \
  mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-latest --protocol https

Give it a minute or two to report ready, then configure the backend with user secrets (never commit these) from MiniGamesBackEnd/:

dotnet user-secrets set "Cosmos:AccountEndpoint" "https://localhost:8081/"
dotnet user-secrets set "Cosmos:UseEmulator" "true"

The emulator authenticates with a fixed, publicly documented key baked into Program.cs — it doesn't support Entra ID auth, which is why Cosmos:UseEmulator exists as a separate switch from the real-account path. The emulator's TLS certificate is self-signed; if the .NET/Cosmos SDK complains, trust it per Microsoft's emulator docs rather than disabling certificate validation in code.

The emulator starts empty — it does not pre-create the minigames database or containers described in doc/roadmap.md. If a query against a container that doesn't exist yet fails, that's expected until either the app or a setup script creates it.

Option B: the real Azure Cosmos account, as your own identity

Useful when you need to see real/shared data (e.g. debugging a production-only puzzle) rather than an empty emulator.

az login
dotnet user-secrets set "Cosmos:UseEmulator" "false"

(Cosmos:AccountEndpoint already defaults to the real endpoint in appsettings.json, so no override is needed.) You also need the Cosmos DB Built-in Data Contributor data-plane role on the minigames-db account — this is separate from any Azure RBAC/Owner role you may already have on the subscription (see doc/roadmap.md Phase 2, "Cosmos has two independent permission systems"). Ask whoever manages the Terraform/Azure subscription to grant it if you get an unauthorized error that otherwise looks correct.

Configuration reference

Every backend setting binds through the standard ASP.NET Core configuration providers (in increasing precedence: appsettings.jsonappsettings.Development.json → user secrets in Development → environment variables, Section__Key naming, in Container Apps). Every frontend setting is a plain environment variable, read from .env.local locally or baked in as a Docker build-arg for anything prefixed NEXT_PUBLIC_ (Next.js inlines those into the client bundle at build time — they cannot be changed at container runtime). This table is the single reference for all of it; the walkthroughs above link back here rather than repeating values.

Setting Purpose Local development Production source
Cosmos:AccountEndpoint (backend) Cosmos DB account URL Defaults to the real account in appsettings.json; override with user secrets for the emulator (see above) Cosmos__AccountEndpoint env var, from Terraform's cosmos_account_endpoint variable
Cosmos:UseEmulator (backend) Switches auth from DefaultAzureCredential to the emulator's fixed well-known key false by default; set to true via user secrets when running against the emulator Always false (not set — the default)
Cors:AllowedOrigins (backend) Exact browser origins allowed to call the API appsettings.Development.json lists localhost:3000/127.0.0.1:3000; Development also allows any loopback Cors__AllowedOrigins__0 env var = https://verbolus.com, from tf/backend/vars.tf's allowed_origins
Cookie:SigningKey (backend) / COOKIE_SIGNING_KEY (frontend) HMAC-SHA256 key that signs/verifies the userId cookie; must match on both sides Generate with openssl rand -base64 32; set via backend user secrets and frontend .env.local (never commit) Container App secret cookie-signing-key, from TF_VAR_cookie_signing_key/an untracked *.auto.tfvars — never a default
Admin:DebugKey (backend) Shared secret required in the X-Admin-Key header for admin/debug endpoints Unset by default (no key required locally); set via user secrets to exercise the header check Container App secret admin-debug-key, from TF_VAR_admin_debug_key/an untracked *.auto.tfvars
Api:EnableDocs (backend) Toggles the Scalar/OpenAPI docs UI true in appsettings.Development.json false (hardcoded in tf/backend/container_app.tf)
ApplicationInsights:ConnectionString (backend) Where OpenTelemetry traces/dependencies/exceptions export to Unset by default — telemetry fail-softs to off; set via user secrets to test locally Container App secret app-insights-connection-string, from azurerm_application_insights.www in tf/frontend/analytics.tf
AppConfig:Endpoint (backend) Azure App Configuration endpoint for feature flags Unset by default — IFeatureManager evaluates all flags as off. If configured App Configuration is unavailable at startup, the API still starts with flags off and can refresh later. AppConfig__Endpoint env var = azurerm_app_configuration.api.endpoint, auth via the API's managed identity
AZURE_CLIENT_ID (backend) Disambiguates which user-assigned managed identity DefaultAzureCredential uses Not set — local dev authenticates as your own az login identity Set to azurerm_user_assigned_identity.api.client_id
AZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRET (frontend) BFF-to-backend trust boundary for internal identity-link endpoints — lets @azure/identity's DefaultAzureCredential fall back to EnvironmentCredential locally; see doc/accounts-and-subscriptions-plan.md Unset by default; set from terraform -chdir=tf output bff_local_dev_tenant_id/bff_local_dev_client_id/bff_local_dev_client_secret (the verbolus-bff-local-dev service principal — gitignored, local dev only, never used in Container Apps) N/A — production uses the frontend Container App's dedicated www_bff managed identity, no secret involved
BFF_BACKEND_SCOPE (frontend) OAuth scope requested when acquiring a token for the internal identity-link endpoints Set from terraform -chdir=tf output backend_api_scope (tenant-scoped, e.g. api://<tenant-id>/minigame-backend-api/.default) BFF_BACKEND_SCOPE env var, from Terraform's local.backend_api_scope (tf/bff_identity.tf, wired via tf/frontend/container_app.tf)
CIAM_TENANT_ID/CIAM_CLIENT_ID/CIAM_CLIENT_SECRET (frontend) Entra External ID (customer) tenant and verbolus-web app registration used for the OIDC sign-in flow (see oidc-config.ts) Unset by default; only required to exercise sign-in (/api/auth/start, /api/auth/callback) — get real values from the verbolus-web app registration's Overview/Certificates & secrets blades, never commit the secret Container App secret ciam-client-secret and CIAM_TENANT_ID/CIAM_CLIENT_ID env vars, from var.ciam_tenant_id/var.ciam_client_id/var.ciam_client_secret in tf/frontend/container_app.tf — values supplied out-of-band, not managed by the azuread provider
AzureAd:TenantId/AzureAd:ClientId (backend) Validates bearer tokens on the internal /v1/internal/identity/* endpoints via Microsoft.Identity.Web Unset by default in Development — these endpoints' auth is skipped entirely, matching the fail-soft pattern used for AppConfig/App Insights AzureAd__TenantId/AzureAd__ClientId env vars, from tf/backend/container_app.tf (data.azuread_client_config.current.tenant_id/azuread_application.backend_api.client_id)
NEXT_PUBLIC_API_BASE_URL (frontend) Backend base URL baked into the client bundle at build time .env.localhttp://localhost:5236 Dockerfile ARG default https://api.verbolus.com
NEXT_PUBLIC_APPLICATIONINSIGHTS_CONNECTION_STRING (frontend) App Insights browser SDK connection string, baked in at build time Unset — app-insights.ts no-ops Dockerfile ARG, empty by default; must be passed explicitly with --build-arg today (see Observability, known gap)
MINIGAMES_SLACK_WEBHOOK_URL (PowerShell deploy scripts only) Posts a Slack notification when update_image.ps1 deploys N/A Set in whatever shell runs update_image.ps1; absent = warns and skips the notification

Ports used locally: backend dev server http://localhost:5236 (launchSettings.json); frontend dev server http://localhost:3000; Cosmos DB Emulator 8081 (data), 8080 (/ready readiness), 1234 (Data Explorer UI). In Container Apps, both apps listen on 8080 internally (ASPNETCORE_URLS=http://+:8080 for the backend) behind managed HTTPS ingress — you never set those container-internal ports yourself.

Building and pushing images

Each app has a script that builds its Docker image, tags it with the current commit's short hash, pushes it to that app's Azure Container Registry, and promotes it into the live Container App via az containerapp revision copy (both minigame-www and minigame-api run in single-revision mode, so this immediately takes 100% of traffic — there's no weighted/canary rollout). They prune old ACR tags down to the 5 most recent. The Bash scripts post a Slack notification; the PowerShell scripts do so when MINIGAMES_SLACK_WEBHOOK_URL is configured.

  • Frontend: Bash or PowerShell, pushes to minigamefrontend.azurecr.io/frontend and updates minigame-www. Does not pass --build-arg NEXT_PUBLIC_API_BASE_URL, so images it builds always bake in the source default (https://api.verbolus.com) — a known gap, not something to rely on if you need a different API base URL baked in (build manually per Scenario 3 instead).
  • Backend: Bash or PowerShell, pushes to minigamebackend.azurecr.io/minigamebackend and updates minigame-api. No build args needed — backend configuration (Cosmos:AccountEndpoint, Cors:AllowedOrigins, AZURE_CLIENT_ID) is all injected at container runtime via Terraform, not baked in at build time.

Each app also has a PowerShell equivalent (update_image.ps1). Prerequisites: Docker Desktop, and az login with access to push to the target ACR and update the target Container App. Run from each project's own directory (frontend/minigames/ or MiniGamesBackEnd/, the Dockerfile's directory):

./update_image.sh
# or, from PowerShell
.\update_image.ps1

For the PowerShell scripts, set MINIGAMES_SLACK_WEBHOOK_URL if the deployment notification is desired. If it is absent, the script warns and continues without posting to Slack.

This mutates the shared production Container App directly — treat it with the same care as any other production deployment, and don't run it for exploratory/local-only testing (use the scenarios above instead).

Troubleshooting

  • Frontend fetch fails with no useful error; DevTools Network tab shows a CORS error/blocked request — your origin isn't in the target backend's allowed-origins list. See Scenario 2.
  • Backend throws InvalidOperationException: Missing configuration 'Cosmos:AccountEndpoint' — check your user secrets are set on the right project (the csproj's UserSecretsId), and that appsettings.Development.json wasn't reverted.
  • Cosmos calls return 401 Unauthorized — for the emulator, confirm Cosmos:UseEmulator is true; for the real account, confirm az login succeeded and you (or the identity in use) actually hold the Data Contributor data-plane role, not just subscription Owner.
  • /health/ready reports Unhealthy locally — expected if Cosmos isn't reachable yet (emulator still starting, or missing/incorrect auth). /health/live should always pass once the process is up regardless of Cosmos state.
  • App Configuration is temporarily unavailable at startup — the configured provider is intentionally optional at process startup. The API comes up with every remote feature flag evaluating off, then attempts normal provider refreshes on ordinary application traffic. Check the structured startup diagnostics and Azure App Configuration dependency telemetry for the underlying credential, DNS, quota, or service failure. Do not remove AppConfig:Endpoint in production: its absence remains a startup error outside Development.

Observability

What's wired up

  • Backend traces/logs/dependencies: OpenTelemetry via Azure.Monitor.OpenTelemetry.AspNetCore, exported to the minigame-insights Application Insights resource (workspace-based, on the existing workspace-larmingsn15 Log Analytics workspace — see tf/frontend/analytics.tf). ASP.NET Core requests, outgoing HTTP calls, and Cosmos SDK dependency calls are captured automatically; no per-call code is needed for those. Hand-instrumented backend spans on production-wired paths include LetterGolf.DailyPuzzleGeneration and Groups.StandingsBackfill (see Telemetry.cs, LetterGolfDailyPuzzleProvider.cs, and GroupStandingsUpdater.cs). The legacy WordList.ColdStartLoad span remains available when MultiDict.cs is exercised, but the live Letter Golf generator no longer builds through MultiDict. The production shared-catalog path emits WordCatalog.Build, WordCatalog.SourceRefresh, WordCatalog.SourceRead, WordCatalog.Load, WordCatalog.Refresh, WordCatalog.ViewBuild, and LetterGolf.CatalogIndexBuild as the live Letter Golf generator loads its singleton catalog, view, and derived index. These spans intentionally record only safe language/version/count/status tags, never raw lexical content. Source versions and declared rights are inventoried in THIRD-PARTY-NOTICES.md; that inventory is not legal clearance.
  • Frontend page views/errors/AJAX: the @microsoft/applicationinsights-web browser SDK (see app-insights.ts) auto-collects page views, route changes, unhandled JS errors/promise rejections, and fetch/XHR calls — correlated end-to-end with backend traces via enableCorsCorrelation. It only initializes once a visitor has granted cookie consent (the same gate GA4 already uses — see components/app-insights-provider.tsx and components/cookie-banner.tsx), and does nothing if no connection string was baked in at build time.
  • GA4 (G-1Y5XWM30YM, hardcoded in layout.tsx) still runs alongside Application Insights for product/marketing analytics — the two are complementary, not a migration in progress.
  • Feature flags: Azure App Configuration (minigame-api-config), evaluated server-side via Microsoft.FeatureManagement and targeted by the same anonymous userId cookie every endpoint already reads (see HttpUserIdTargetingContextAccessor.cs). GET /v1/features returns the calling user's evaluated flags — the targeted audience list itself is never exposed to the client.

Where to look

  • Azure Portal → resource group Alarming → Application Insights minigame-insightsLive Metrics for real-time traffic, Transaction search / Application Map for a single request's end-to-end trace (backend spans plus correlated frontend calls), or Logs for KQL queries against the requests, dependencies, exceptions, customEvents, and traces tables.
  • Azure Portal → App Configuration minigame-api-configFeature manager for the current flags and their targeting state.

Adding a new data point

  • Backend custom span: wrap the operation in using var activity = Telemetry.ActivitySource.StartActivity("Your.OperationName"); and use activity?.SetTag("key", value) for anything worth filtering or grouping by later. Prefer bounded, low-cardinality tags such as operation kind, status, language, safe versions, and aggregate counts; do not attach raw words, denied content, entry IDs, or other user/content payloads. See the existing spans listed above for the pattern.
  • Frontend custom event: call trackEvent("your-event-name", { optionalKey: "value" }) from app-insights.ts. It logs to the console instead of sending in development, and is a no-op everywhere else until consent is granted and a connection string is baked in.
  • New feature flag: use the cross-platform canonical-user-ID operator commands in doc/feature-flag-operator-runbook.md. The historical manual process remains summarized in doc/roadmap.md.

Known gaps

  • terraform apply has run — App Insights and App Configuration exist in Azure and the backend Container App's env vars have the connection string/endpoint, and the backend and frontend images have been rebuilt/pushed and deployed to production (see doc/roadmap.md's 2026-08-15 session log entry).
  • The frontend build likely still didn't pick up its connection string automatically: neither update_image.sh nor update_image.ps1 passes --build-arg NEXT_PUBLIC_APPLICATIONINSIGHTS_CONNECTION_STRING yet (same category of gap as the existing NEXT_PUBLIC_API_BASE_URL one in Building and pushing images above) — until that script gap is fixed, confirm browser telemetry is actually reaching App Insights (e.g. Live Metrics), or build the frontend image manually with that build-arg per Scenario 3's pattern.
  • GET /v1/features is now consumed: a hidden "god-view" section in the hamburger menu (gated per-flag on PuzzleMetadata/DebugSolutions/ResetDaily) — see doc/roadmap.md's Tier 1 checklist.