---
title: "bop-front — Globus Staff Frontend"
slug: bop-front-architecture
section: Portals
status: stable
description: "The Globus staff portal, split out of `BackOfficePrototype` into `Alliance-Strategies/bop-front` and, since 2026-10-15, a **normal stateless portal**: a React client plus a backend-for-frontend (BFF) that signs staff in with Entra ID and…"
last-updated: 2026-10-07
---

# bop-front — Globus Staff Frontend

The Globus staff portal, split out of `BackOfficePrototype` into `Alliance-Strategies/bop-front` and, since 2026-10-15, a **normal stateless portal**: a React client plus a backend-for-frontend (BFF) that signs staff in with Entra ID and reaches the Globus Hub only through `@alliance-strategies/globus-sdk`. Same shape as the Applicant Portal (PPPToo) and the Host Portal; the Hub stays the authority for every decision (see [Portal Overview](/docs/portal-boundaries)).

## What bop-front Is

- `client/`: the React/Vite staff application (the former `client/` of BackOfficePrototype) and `shared/`, the browser-safe half of the shared contracts. Both directories are a verbatim mirror of the same directories in `BackOfficePrototype` at one pinned commit, recorded with a content hash in `config/client-parity.json`; the `Client parity` CI job fails on any hand edit under them, and `scripts/parity/sync-from-bop.mjs` is the only supported way to change them.
- `server/`: an Express 5 BFF that serves the built bundle, owns `/auth/login`, `/auth/callback` and `/auth/logout` and answers every `/api/*` call the client makes by relaying it to the Hub through the SDK. No database, no session store, no queue, no workers; the only process state is short in-memory caches.
- One image (`Dockerfile`: Vite build + `tsc` build of the server, Node 20, non-root uid 10001), deployed to Cloud Run pool slots with their own hostnames, each paired with the Globus pool slot of the same number.

Decision record: `docs/adr/0005-stateless-bff-portal-on-the-sdk.md` in the repo (supersedes the routing model of ADR 0001/0002; ADR 0003's API-first cross-repo protocol survives). Decided on Sam O'Regan's direction of 2026-10-15; Asana 1218317218287029, subtasks 16 to 21.

## Request Flow

| Path | Handled by | What happens |
|---|---|---|
| `/` and client routes, including `/auth/pending` | `server/src/static.ts` | `dist/index.html` (no-store, carries `<meta name="build-sha">`), hashed assets immutable |
| `/api/health`, `/api/version` | `routes/system.ts` | the portal's own health and commit (`sha`, `environment`, paired `hub`) |
| `/auth/login`, `/auth/callback`, `/auth/logout` | `routes/auth.ts`, `auth/entra.ts` | Entra authorization-code + PKCE (`openid-client` 6); success mints the session cookie |
| `/api/auth/me`, `/api/auth/user` | `routes/auth.ts` | answered from the cookie claims in the shapes the client already reads; `POST /api/auth/login` answers `ENTRA_SSO_REQUIRED` |
| every other `/api/*` | `routes/passthrough.ts` | session required (401 before any Hub call), CSRF on unsafe methods, then exactly one SDK call with `X-API-Key` (environment key) and `X-Staff-Email` (signed-in user) |

Every response the BFF sends (system, auth, relayed Hub answers, the SPA shell) carries `X-Correlation-ID`, set by `correlationIdMiddleware` (`server/src/correlation.ts`), which is registered first. The client's id is echoed when it is a short safe token (`A-Za-z0-9._-`, 1 to 128 characters); otherwise the BFF generates a fresh UUID.

The relay forwards an allow-list of request headers only (`accept`, `content-type`, `api-version`, `if-match`, `if-none-match`, `range`, `x-request-id`, `x-idempotency-key`) plus `x-correlation-id`, set to the id the BFF's own response carries so the Hub logs the same one; the browser's cookies and the CSRF header never reach the Hub. GET/HEAD go through `httpClient.getRaw` (streams JSON and binary alike), JSON writes through `httpClient.post/put/patch/delete/deleteWithBody`. Hub errors come back with their status and body untouched, except that a JSON error body that is not the Hub's error envelope (`{ error: { code, message, details, traceId } }`) is replaced with the BFF's own `HUB_UPSTREAM_ERROR` envelope at the same status (message `The Globus Hub answered <status> without an error envelope`); a Hub 401 becomes 403 so the client does not treat a key problem as a sign-out. Multipart uploads are the one documented exception (a raw `fetch` with the same headers; the SDK has no streaming upload yet). `server/src/globus/consumed-routes.ts` lists every SDK surface the BFF uses and a parity test keeps the list honest; explicit typed routes on `client.generated.*` replace the relay per API segment under subtask 21. The first typed route, `GET /api/applicants/:id`, is served by the SDK's `applicants.get` and takes precedence over the relay for its exact path; every typed-route call carries `X-Staff-Email` for the signed-in staff member. A typed route whose SDK method takes no query argument hands a request that carries a query string to the relay instead, because the typed call would drop the query string.

## Identity and Authorisation

- **Sign-in:** the Hub's own Entra app registration ("Globus - Development Environment", `a345472f-…`) with this portal's redirect URIs added per hostname. Scopes `openid profile email`, PKCE S256, state and nonce checked by `openid-client`.
- **Session:** `bopf_session`, an HS256 JWT (claims `oid`, `email`, `name`; 8 h) in an httpOnly, Secure, SameSite=Lax cookie. The OIDC transaction uses the same signer under a different subject (10 min, path `/auth`); the two are never interchangeable. No server-side session.
- **CSRF:** double-submit; the cookie is named `globus_csrf` so the client's existing `X-CSRF-Token` echo works unchanged.
- **Authorisation at the Hub (open):** the Hub authorises API-key callers on the key's scopes only; `X-Staff-Email` is audit attribution until the Hub honours it for keys holding a proposed `staff.delegate` scope (resolve the staff user, run the normal `requirePermission`). That is a BackOfficePrototype decision for Sam (Asana 1219546702094528). Until then dev and QA run on a `*` key, routes that insist on a staff session (`/api/admin/*`, `SESSION_REQUIRED`) refuse the portal, and production stays off. The BFF enforces sign-in only and must not grow its own RBAC.

## Deployment Rails and Hostnames

Sandbox branching model (see [Git Branching and Release Model](/docs/git-branching-release-model)); every rail builds the one image and runs `scripts/deploy-slot-run.sh`, which rewrites `GLOBUS_API_BASE_URL` to the paired Globus slot, deploys with `gcloud run deploy`, and proves from outside that `/api/version` on the public hostname reports the commit (falling back to the service's `run.app` URL with a warning while a domain mapping is not yet serving).

| Branch | Workflow | Where | Hostname | Paired Hub |
|---|---|---|---|---|
| `feature/<GID>-*`, `hotfix/<GID>-*` | `deploy-feature-request.yml` → `trusted-deploy-dev.yml@main` | Cloud Run `bop-front-dev-N` (dev project), 30-minute hold | `dev-N.staff.allianceabroad.com` | `dev-N.globus.allianceabroad.com` |
| `release/*` | `deploy.yml` | Cloud Run `bop-front-qa-N` (QA project), 24-hour hold | `qa-N.staff.allianceabroad.com` | `qa-N.globus.allianceabroad.com` |
| `main` | `deploy.yml` `prod` job | **Not wired.** The job explains this in its summary. | `staff.allianceabroad.com` (reserved) | `globus.allianceabroad.com` |

The `request-deployment` job in `deploy-feature-request.yml` is skipped for any branch whose name contains `-backport-`, because a backport branch's content was already deployed and tested from `main`.

Slot state lives in the Cloud Run service labels (`sandbox-owner`, `sandbox-branch`, `sandbox-reserved-until`), claimed by `scripts/allocate-slot.sh` (the sandbox template, including `REQUESTED_SLOT`). Infrastructure is in `alliance-gcp-infra` `environments/<dev|qa>/bop-front.tf`: the slot services, the runtime service account, the portal secrets, the deployer IAM, the Workload Identity provider pinned to the trusted workflows, and the domain mappings (`modules/domain-mapping`); DNS CNAMEs point the hostnames at `ghs.googlehosted.com`.

## Configuration and Secrets

`server/src/secrets.ts` fills the environment from Secret Manager at boot when `GCP_PROJECT_ID` is set: `bop-front-globus-api-key`, `bop-front-session-jwt-secret`, and the Hub's `globus-entra-tenant-id` / `-client-id` / `-client-secret`. Everything else comes from `deploy/env/<dev|qa>.yaml` (`BOP_FRONT_ENV`, `GLOBUS_API_BASE_URL` rewritten per slot, timeouts). The Hub key is an `api_keys` row named `bop-front <env> BFF` present in every slot database of that pool (per-slot Cloud SQL); never in a file.

The dev and QA rails also read the Google Maps key and the PostHog key and host from Secret Manager before the image build and pass them as the build args `VITE_GOOGLE_MAPS_API_KEY`, `VITE_POSTHOG_KEY` and `VITE_POSTHOG_HOST`, which Vite bakes into the bundle.

## Current State

| Item | State (2026-10-15) |
|---|---|
| Dev pool `bop-front-dev-1..3`, hostnames, secrets, Hub key rows | Provisioned and applied (alliance-gcp-infra #402, #404) |
| QA pool `bop-front-qa-1..3`, hostnames, secrets, Hub key rows | Provisioned and applied (alliance-gcp-infra #403) |
| BFF (`server/`) | Merged (bop-front #24); verified against `dev-1.globus` with a dev key |
| Dev rail on Cloud Run | Merged (#25, #26); effective for feature pushes once `release/teach` is promoted to `main` (#28), because the trusted workflow is pinned to `main` |
| QA rail on Cloud Run | Merged (#27); first release push in flight |
| Production | Not wired; gated on the Hub delegation decision and Sam's go (Asana 1219539765611846) |
| GKE slot rails (`k8s/`, `*-gke.sh`) | Retired (the ADR 0002 slot rails) |

## Making a Change

- Cut `feature/<16-digit Asana GID>-<slug>` from `main` or a `release/*` branch, push, read the job summary for the slot hostname; open the PR into the current `release/*`; `quality-gate` + `branch-rules` are the required checks; governance paths (workflows, gate config, ADRs, AGENTS.md, tests) need a Code Owner review (CODEOWNERS + rulesets).
- `client/` and `shared/` are not edited here: change them in `BackOfficePrototype`, then run `node scripts/parity/sync-from-bop.mjs --ref <branch>` (optionally with `--source <path to a local checkout>`) and commit `client/`, `shared/` and the manifest together. `node scripts/parity/check-parity.mjs --summary` prints the manifest and the current state without failing. Pairing rule: a bop-front `release/<x>` branch mirrors BackOfficePrototype `release/<x>` and `main` mirrors `main`; a `release -> main` promotion must carry a manifest whose commit is on BackOfficePrototype `main`, and a BackOfficePrototype feature branch is never synced. Parity does not cover the Hub routes that refuse the portal's API key (`docs/parity/key-refusing-routes.json`) or the BFF itself, which has no counterpart in BackOfficePrototype.
- The Hub contract is the SDK: a client change that needs a new Hub endpoint lands in BackOfficePrototype first (additive, API-Version gated), then in the SDK, then here. BackOfficePrototype is not modified by this programme otherwise; Hub-side needs become tickets.
- `server/**` must not import `client/**` and `client/**` must not import `server/**` (gate layer rules). Never put a Hub API key in a file.
- Local run: `npm run dev:server` (BFF on :8080 with `GLOBUS_API_BASE_URL`, `GLOBUS_API_KEY`, `SESSION_JWT_SECRET`, optional `ENTRA_*`) and `npm run dev` (Vite proxies `/api` and `/auth` to it).

## History: the Same-Host Model

From 2026-10-14 to 2026-10-15 bop-front was a static bundle served beside BackOfficePrototype's Express under the same hostnames (ADR 0001/0002/0004: `bop-front-web` Deployment inside the BOP slot namespace, slot HTTPRoute catch-all as the cut-over switch, `FRONTEND_CUTOVER_POOLS`). The QA pool was cut over on `qa-1` on 2026-10-15 and then reverted the same day: every BackOfficePrototype change of the split was taken out of BOP `release/teach` (BOP #2919) and preserved on BOP `release/bop-front`, and bop-front moved to this stateless-portal model. The BOP client refactor of that period (`client/src/endpoints/*`, the `shared/client.ts` entry, the client-no-server gate rule) exists on BOP `release/bop-front` only.

## Sources

- `Alliance-Strategies/bop-front`: `docs/adr/0005-stateless-bff-portal-on-the-sdk.md`, `server/README.md`, `server/src/**`, `scripts/allocate-slot.sh`, `scripts/deploy-slot-run.sh`, `deploy/env/*.yaml`, `.github/workflows/trusted-deploy-dev.yml`, `.github/workflows/deploy.yml`; PRs #23 to #28.
- `Alliance-Strategies/alliance-gcp-infra`: `environments/development/bop-front.tf`, `environments/qa/bop-front.tf`; PRs #402, #403, #404.
- `Alliance-Strategies/BackOfficePrototype`: `server/auth/apiKeyAuth.ts`, `server/rbac.guards.ts`, `server/middleware/actorContext.ts` (why the Hub cannot yet authorise a delegated staff identity); #2919 and branch `release/bop-front`.
- `Alliance-Strategies/Globus-SDK` 1.49.0 (`src/http/http-client.ts`, `src/generated/`).
- Asana 1218317218287029 and subtasks 16 to 21.
