Skip to content

Environments#

One gateway and one SPA manage every CommandCenter environment. Each environment keeps its own backends (WebApi, the main API, the legacy API, BMO) and its own database. A request names its environment in the URL, and the gateway sends it to that environment's backends:

/env/{environment}/api/…   →  that environment's backends
/api/…                     →  the default backends, as before

The backends stay unaware of routing: each serves one environment, and the gateway only sends it requests for that environment.

In the SPA the environment is the first segment of every page's URL, /{environment}/customers, and every API call of that page goes to /env/{environment}/…. When the catalogue is empty, the SPA's URLs and calls stay as they were (/customers, /api/…).

The pieces#

flowchart LR
    Browser["Browser (SPA)"]
    subgraph gateway ["Gateway (CommandCenter.Bff.Frontdoor)"]
        Prefix["/env/{name} → PathBase<br/>404 when not in the catalogue"]
        Rewrite["EndpointRewrite + routes<br/>(unchanged)"]
        Step["environment.access (OPA)<br/>pick the environment's destination"]
    end
    Catalogue["Platform:Environments<br/>(App Configuration, label platform)"]
    OPA["OPA"]
    subgraph prod ["Environment prod"]
        WebApiP["WebApi"]
        CcP["Main API"]
    end
    subgraph uat ["Environment uat"]
        WebApiU["WebApi"]
        CcU["Main API"]
    end

    Browser -- "/env/prod/api/…" --> Prefix --> Rewrite --> Step
    Catalogue -. "read live" .-> Prefix
    Catalogue -. "tagged destinations" .-> Step
    Step -- "environment.access" --> OPA
    Step -- "X-CC-Environment: prod<br/>X-Forwarded-Prefix: /env/prod" --> WebApiP
    Step --> CcP
    Step -.-> WebApiU
    Step -.-> CcU
Hold "Alt" / "Option" to enable pan & zoom

The catalogue#

Each environment is one entry under Platform:Environments:{name}. In App Configuration it lives at label platform, and the gateway reads it again on every refresh, so a new environment is served without a restart. Locally the entry dev is in the gateway's appsettings.Development.json.

"Platform": {
  "Environments": {
    "prod": {
      "DisplayName": "Production",
      "IsProduction": true,
      "Order": 1,
      "Destinations": {
        "next": "https://webapi-prod.internal/",
        "commandcenter": "https://api-prod.internal/",
        "legacy": "https://legacy-prod.internal/CommandCenter/",
        "bmo": "https://bmo-prod.internal:8090/"
      }
    }
  }
}
Field
name (the key) 1–32 lower-case letters, digits and hyphens, not a reserved path (see In the SPA). It is the URL segment and must equal the backends' Configuration:Environment (their App Configuration label).
DisplayName, Order What the SPA shows, and where it sorts.
IsProduction The policy may limit production to some roles (production_roles in the OPA data).
Destinations The environment's backend per logical cluster: next (WebApi), commandcenter (main API), legacy, bmo. Leave one out and that part answers 503 environment-not-served for the environment.

An entry with an invalid name or address is left out and logged (gateway EventIds 10 and 11).

What happens to a request#

sequenceDiagram
    participant SPA
    participant GW as Gateway
    participant OPA
    participant API as WebApi (prod)
    SPA->>GW: GET /env/prod/api/features, X-CSRF: 1
    GW->>GW: prod in the catalogue? (else 404 unknown-environment)
    GW->>GW: PathBase /env/prod, Path /api/features → rewrite → route api-next
    GW->>GW: X-CSRF check
    GW->>OPA: environment.access, prod, isProduction (remembered 30 s when allowed)
    OPA-->>GW: allow (else 401 / 403 / 503)
    GW->>GW: keep only the destination tagged Environment=prod
    GW->>API: GET /api/features, X-CC-Environment: prod, X-Forwarded-Prefix: /env/prod, user token with env=prod
    API->>API: prod is my environment (else 421), PathBase /env/prod
    API-->>SPA: 200, links under /env/prod/…
Hold "Alt" / "Option" to enable pan & zoom
  • Destinations. A cluster that belongs to an environment carries metadata EnvironmentDestination: <logical cluster>. The gateway adds each environment's backend to it as a destination env-{name}, tagged Environment: {name}. A request for an environment uses only that destination. A request without a prefix uses only the untagged ones, so it can never reach an environment's backend.
  • Clusters that are not per environment (chat, system stats, the SPA's own files) answer 404 not-environment-scoped under /env/…; call them without the prefix.
  • Headers. The gateway sets X-CC-Environment and drops any the caller sent. YARP sets X-Forwarded-Prefix from the prefix and replaces a caller's.
  • At the backend (Shared.Web.EnvironmentRequests, WebApi and the main API): a request whose X-CC-Environment is not the host's environment answers 421 Misdirected Request, never data from the wrong database. The prefix becomes PathBase only when it is exactly /env/{X-CC-Environment}, so URLs built with LinkGenerator (task status, downloads) keep working through the gateway. A request without the header (health checks, the bmo CLI, the gateway's un-prefixed routes) is served as before.

In the SPA#

flowchart TD
    Load([page load]) --> List["GET /bff/environments<br/>names, isProduction, allowed"]
    List --> Empty{catalogue empty?}
    Empty -- yes --> Plain["URLs and calls as before<br/>/customers, /api/…"]
    Empty -- no --> First{first URL segment<br/>an environment?}
    First -- yes --> Serve["router basename /{environment}<br/>every API call /env/{environment}/…"]
    First -- no --> Redirect["replace with /{last used, or first allowed}/same page"]
    Serve --> Switch["environment switch in the header"]
    Switch -- "full page load" --> Load
Hold "Alt" / "Option" to enable pan & zoom
  • Platform pages are not environment-prefixed. The platform-wide /config pages call /platform/api/... (see docs/configuration.md → WebApi's roles). There is one platform, whatever the page's environment; a view that shows one environment next to it (Hosts, the Key Vault inventory, the SQL Server registry) passes ?environment= with the page's own.
  • GET /bff/environments lists the catalogue as the SPA needs it: name, display name, order, isProduction, and allowed (OPA environment.access for the signed-in user). Never the backends. It is listed when signed out too, with nothing allowed, so a deep link keeps its environment through the sign-in.
  • The environment is chosen once per page load (src/services/environment.ts, called from main.tsx before the router exists) and becomes the router's basename, so every link and navigate('/…') in the app stays under it without naming it. A URL that names no environment (an old bookmark, /) is replaced by the same page under the last environment used in this browser (localStorage cc.environment), else the first one the user may use.
  • Switching is a full page load at the same page. No cached query, open stream or half-filled form of one environment can appear in another.
  • API calls: every axios instance (the legacy client, the typed clients, the BMO client, and bare axios) and every streaming fetch go through environmentUrl, which turns /api/…, /v1/… and /v2/… into /env/{environment}/…. Chat, system stats, diagnostics and keyplex (/api/chat, /api/system-stats, /api/d, /api/keyplex) and /bff/… are the same for every environment and are never prefixed.
  • Production is marked in words next to the switch, not only by colour.
  • Names the SPA cannot use are refused by the catalogue (EventId 10): api, assets, bff, env, health, platform, src, static, v1, v2. Do not name an environment after a page either (customers, config): its URL would be read as that environment.

Adding an environment#

journey
    title An operator adds the environment "uat"
    section Deploy
      Install uat's backends with Configuration__Environment=uat: 3: Operator
      Give uat's identities their Azure roles: 3: Operator
    section Register
      /config/environments, Add environment, fill in uat's backends: 5: Operator
    section Use
      Gateway serves /env/uat/… within a refresh: 5: Gateway
      Reload: uat is in the environment switch: 5: Operator
      A request for uat reaching a prod host answers 421: 5: Backend
Hold "Alt" / "Option" to enable pan & zoom

When the backends answer 421, check that the catalogue points uat at uat's hosts and that those hosts run with Configuration__Environment=uat.

The Environments page (/config/environments)#

sequenceDiagram
    participant Op as Operator
    participant SPA as /config/environments
    participant API as WebApi (this environment)
    participant OPA
    participant AC as App Configuration
    participant GW as Gateway
    Op->>SPA: Change uat, Save
    SPA->>API: PUT /api/environments/catalog/uat, the version read
    API->>API: the gateway's own rules (400 per field)
    API->>AC: uat's keys at label platform: still the version read? (else 409)
    API->>OPA: config.write, environment uat, isProduction
    API->>AC: Platform:Environments:uat = one JSON value (application/json)
    API->>AC: remove uat's hand-written child keys, if any
    API->>AC: move Settings:PlatformSentinel once
    AC-->>GW: reload within a refresh: /env/uat/… follows the new backends
    API-->>SPA: the entry as it now is
Hold "Alt" / "Option" to enable pan & zoom
  • Read from the store: every environment under Platform:Environments at label platform, with who changed it and when. An entry the gateway would ignore says why (an invalid name or address, a value stored without content type application/json). Served by the gateway compares with the list the gateway gave when the page loaded.
  • The gateway's rules (Shared.Configuration.Environments.EnvironmentDefinitions, used by both): the name as above, a display name, an order from -1000 to 1000, and at least one backend, each an absolute http/https address without user name, query or fragment.
  • One version per environment over all its keys: a save or removal made from an older read is refused (409) and the page shows the other change.
  • Stored as one JSON value, which the configuration provider flattens into the keys the gateway reads. An entry written by hand as separate keys reads the same; saving it from the page replaces those keys with the one value.
  • Production: the check carries isProduction — true when the entry is or was production — so the policy's production_restricted rule also covers editing it.
  • Remove deletes the entry only; the environment's backends and database are untouched. The environment whose backend serves the page cannot be removed from it (remove it from another environment's page), so the page never cuts off its own backend.
  • config.read to see, config.write to change; audit 3184 (added, changed, removed). The host that serves the page needs App Configuration Data Owner, as for the settings pages. The gateway's local entry (appsettings.Development.json) is not in the store and is not shown.