Appearance
Environments
One Flask codebase serves two environments. The environment is resolved per request from the trusted API ingress hostname — never from a client-supplied parameter, header, Origin, or Referer.
| Environment | API host | Dashboard | Database | Purpose |
|---|---|---|---|---|
| production | api.eccdps.org | eccdps.org + training.eccdps.org | production Supabase project | Live platform |
| demo | demo-api.eccdps.org | demo.eccdps.org | separate demo Supabase project | Fictional dataset for demonstrations and development |
Unknown hosts fail closed with HTTP 503. The root liveness route (GET /) is exempt so Render health probes keep working even when they bypass the trusted ingress.
Environment configuration
Configuration lives in dps-code-api/environment.py as two immutable EnvironmentConfig objects, loaded once at import:
- Production reads legacy unprefixed variable names (
SUPABASE_URL,SUPABASE_KEY,OAUTH_SESSION_SECRET,API_KEY, …) with optionalPRODUCTION_*overrides. - Demo reads only
DEMO_*variables. An incomplete demo configuration leaves the demo environment unavailable; it never falls back to production credentials.
The API fails fast at boot if the production environment is not configured (SUPABASE_URL, SUPABASE_KEY, and OAUTH_SESSION_SECRET are required).
Environment-bound sessions
Sessions use EnvironmentSessionInterface instead of Flask's default cookie session:
- Distinct cookie names per environment:
eccdps_production_sessionandeccdps_demo_session. - Distinct signing secrets per environment.
- Every session payload carries an
environmentclaim.
A production cookie presented to the demo ingress (or vice versa) fails signature verification and the claim check. Cross-environment sessions are impossible in both directions. (Consequence: the first deploy of this interface invalidated all existing sessions once; users re-authenticated.)
Ingress assertion (optional hardening)
When an environment's INGRESS_SECRET is configured, requests to that environment must carry a valid HMAC assertion in the X-ECCDPS-Ingress header, signed over {env}.{timestamp}.{host} with ≤ 300 seconds of clock skew. The header is added by the Cloudflare edge (cloudflare/ingress_worker.js), which first strips any client-supplied environment headers. When configured, the assertion is required and never accepted from the browser.
WARNING
As documented in ENVIRONMENT_DEPLOYMENT.md, the ingress Worker is not currently attached to routes (both API hostnames are Orange-to-Orange CNAME records, which Cloudflare does not evaluate zone worker routes for). In the current production trust model, environment selection relies on server-side hostname resolution and fail-closed behavior for unknown hosts. Re-enabling signed assertions requires Worker Custom Domain bindings — see §3 of ENVIRONMENT_DEPLOYMENT.md.
Database clients
The module-level supabase name in db_access.py is a request-scoped proxy: every attribute access resolves the active request's environment client. There is no global Supabase client. Clients are created lazily, tagged with their environment, and cached per environment. Demo seed/reset tooling refuses any client not tagged demo.
Worker threads must capture the concrete client (current_supabase()) before entering the pool — flask.g is request-local and must never be read from worker threads.
Secrets
Secret values are environment variables on the hosting platform (Render for the API, Cloudflare Workers/Pages for the Workers). They are never committed, never logged, and never exposed to the browser:
- Browser-visible by design: the Supabase anon key (served to signed-in sessions via
/training/realtime-config) andVITE_DPS_API_BASE_URL. - Server-only: Supabase service-role key, JWT signing secret, session secret, API key, Discord client secret, OAuth proxy secret, ingress secrets, ER:LC relay token/server key.
See Environment Variables for the full list of names. This documentation never contains actual secret values.
Demo environment
The demo database contains only fictional agents and cases (see demo_seed.py). Because real Discord accounts cannot be looked up in the demo database, only real Discord IDs on the DEMO_ALLOWED_DISCORD_IDS allowlist may sign in; each is mapped to a fictional demo agent (real_id:demo_agent_id entries, or the demo Director by default). An empty/unset allowlist means no real account can log into the demo (fail closed).
The demo supports a full reset: POST /demo/reset (authenticated demo session) or the CLI:
bash
DEMO_SUPABASE_URL=... DEMO_SUPABASE_SERVICE_KEY=... \
DEMO_OAUTH_SESSION_SECRET=... \
python demo_seed.py --reset --yes-demoThe reset client is built strictly from DEMO_* variables and must carry the demo environment tag; it can never write to production. A daily auto-reset scheduler can be enabled with DEMO_AUTO_RESET_ENABLED; it is a daemon thread that only ever re-seeds the demo database.
External dependencies by environment
| Dependency | Production | Demo |
|---|---|---|
| Supabase project | production project | demo project (same migration chain applied) |
| Discord OAuth app | production client | demo client (separate redirect URIs) |
| BotGhost API key | API_KEY | DEMO_API_KEY |
| Dashboard origins | eccdps.org, training.eccdps.org | demo.eccdps.org |
| ER:LC relay | production relay (residential egress) | n/a or dev config |