minigames¶
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:
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.
- Backend development environment —
make backend-upusesASPNETCORE_ENVIRONMENT=Development, listening onhttp://localhost:5236(see Properties/launchSettings.json). - CORS in Development allows any loopback origin automatically (see the
allowLoopbackOriginscheck in Program.cs) — whatever port the frontend dev server picks works with no configuration. - You still need a Cosmos DB to talk to — see Cosmos DB — local options below.
- Shared cookie-signing key — generate a development key, then supply the exact same base64 value to both processes. Never commit this key:
Set it for the backend from MiniGamesBackEnd/ using user secrets:
- Frontend — copy
frontend/minigames/.env.exampletofrontend/minigames/.env.local(gitignored, never commit real values here) and setCOOKIE_SIGNING_KEYto the 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.
- Start the local stack — from the repository root:
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:
- 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).
- 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.
(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.json → appsettings.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.local → http://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/frontendand updatesminigame-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/minigamebackendand updatesminigame-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):
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'sUserSecretsId), and thatappsettings.Development.jsonwasn't reverted. - Cosmos calls return 401 Unauthorized — for the emulator, confirm
Cosmos:UseEmulatoristrue; for the real account, confirmaz loginsucceeded and you (or the identity in use) actually hold the Data Contributor data-plane role, not just subscription Owner. /health/readyreports Unhealthy locally — expected if Cosmos isn't reachable yet (emulator still starting, or missing/incorrect auth)./health/liveshould 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:Endpointin 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 theminigame-insightsApplication Insights resource (workspace-based, on the existingworkspace-larmingsn15Log Analytics workspace — seetf/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 includeLetterGolf.DailyPuzzleGenerationandGroups.StandingsBackfill(see Telemetry.cs,LetterGolfDailyPuzzleProvider.cs, andGroupStandingsUpdater.cs). The legacyWordList.ColdStartLoadspan remains available when MultiDict.cs is exercised, but the live Letter Golf generator no longer builds throughMultiDict. The production shared-catalog path emitsWordCatalog.Build,WordCatalog.SourceRefresh,WordCatalog.SourceRead,WordCatalog.Load,WordCatalog.Refresh,WordCatalog.ViewBuild, andLetterGolf.CatalogIndexBuildas 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-webbrowser 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 viaenableCorsCorrelation. It only initializes once a visitor has granted cookie consent (the same gate GA4 already uses — seecomponents/app-insights-provider.tsxandcomponents/cookie-banner.tsx), and does nothing if no connection string was baked in at build time. - GA4 (
G-1Y5XWM30YM, hardcoded inlayout.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 viaMicrosoft.FeatureManagementand targeted by the same anonymoususerIdcookie every endpoint already reads (seeHttpUserIdTargetingContextAccessor.cs).GET /v1/featuresreturns 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 Insightsminigame-insights→ Live 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 therequests,dependencies,exceptions,customEvents, andtracestables. - Azure Portal → App Configuration
minigame-api-config→ Feature 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 useactivity?.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" })fromapp-insights.ts. It logs to the console instead of sending indevelopment, 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 applyhas 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 (seedoc/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.shnorupdate_image.ps1passes--build-arg NEXT_PUBLIC_APPLICATIONINSIGHTS_CONNECTION_STRINGyet (same category of gap as the existingNEXT_PUBLIC_API_BASE_URLone 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/featuresis now consumed: a hidden "god-view" section in the hamburger menu (gated per-flag onPuzzleMetadata/DebugSolutions/ResetDaily) — seedoc/roadmap.md's Tier 1 checklist.