Skip to content

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.

EnvironmentAPI hostDashboardDatabasePurpose
productionapi.eccdps.orgeccdps.org + training.eccdps.orgproduction Supabase projectLive platform
demodemo-api.eccdps.orgdemo.eccdps.orgseparate demo Supabase projectFictional 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 optional PRODUCTION_* 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_session and eccdps_demo_session.
  • Distinct signing secrets per environment.
  • Every session payload carries an environment claim.

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) and VITE_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-demo

The 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

DependencyProductionDemo
Supabase projectproduction projectdemo project (same migration chain applied)
Discord OAuth appproduction clientdemo client (separate redirect URIs)
BotGhost API keyAPI_KEYDEMO_API_KEY
Dashboard originseccdps.org, training.eccdps.orgdemo.eccdps.org
ER:LC relayproduction relay (residential egress)n/a or dev config