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:
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
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/…
- Destinations. A cluster that belongs to an environment carries metadata
EnvironmentDestination: <logical cluster>. The gateway adds each environment's backend to it as a destinationenv-{name}, taggedEnvironment: {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-scopedunder/env/…; call them without the prefix. - Headers. The gateway sets
X-CC-Environmentand drops any the caller sent. YARP setsX-Forwarded-Prefixfrom the prefix and replaces a caller's. - At the backend (
Shared.Web.EnvironmentRequests, WebApi and the main API): a request whoseX-CC-Environmentis not the host's environment answers 421 Misdirected Request, never data from the wrong database. The prefix becomesPathBaseonly when it is exactly/env/{X-CC-Environment}, so URLs built withLinkGenerator(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
- Platform pages are not environment-prefixed. The platform-wide
/configpages call/platform/api/...(seedocs/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/environmentslists the catalogue as the SPA needs it: name, display name, order,isProduction, andallowed(OPAenvironment.accessfor 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 frommain.tsxbefore the router exists) and becomes the router'sbasename, so every link andnavigate('/…')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 (localStoragecc.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 streamingfetchgo throughenvironmentUrl, 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
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
- Read from the store: every environment under
Platform:Environmentsat labelplatform, with who changed it and when. An entry the gateway would ignore says why (an invalid name or address, a value stored without content typeapplication/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 absolutehttp/httpsaddress 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'sproduction_restrictedrule 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.readto see,config.writeto 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.