---
title: "bop-front — Globus Staff Frontend"
slug: bop-front-architecture
section: Portals
status: stable
description: "The Globus staff frontend split out of `BackOfficePrototype` into its own repository, `Alliance-Strategies/bop-front`, on the sandbox branching model."
last-updated: 2026-10-07
---

# bop-front — Globus Staff Frontend

The Globus staff frontend split out of `BackOfficePrototype` into its own repository, `Alliance-Strategies/bop-front`, on the sandbox branching model. Same hostnames, same API, same pool slots; a different deployable.

## What bop-front Is

`bop-front` holds the React/Vite client that staff use at `*.globus.allianceabroad.com` (`client/`) and the browser-safe half of the shared contracts (`shared/`, the `shared/client.ts` entry without the drizzle tables). It builds to a static bundle served by a small container (`serve -s dist`) whose `index.html` carries `<meta name="build-sha" content="<commit>">`. It has no server, no database and no identity of its own: every request to `/api/*` still goes to the BackOfficePrototype Hub (Express), which also owns Entra sign-in (`/auth/login`, `/auth/callback`, `/auth/logout`).

The split is the first step of Asana 1218317218287029 ("split frontend from backend in globus"). The engine stays where it is; the typed contract between the two is the Globus SDK's generated services (`@alliance-strategies/globus-sdk` 1.49.0, `client.generated.*`, one method per Hub endpoint the client calls) and the client's `client/src/endpoints/*` path module, which replaced every `/api/...` string literal in the client.

Design decisions are recorded as ADRs in the repo (`docs/adr/0001` same host and hostnames, `0002` frontend inside the BOP slot, `0003` cross-repo change protocol, `0004` cut-over and rollback). Every ADR was decided on Sam O'Regan's general instruction and is reversible until production is cut over.

## Ownership Split with BackOfficePrototype

One owner per Kubernetes workload. BackOfficePrototype creates the slot shape; bop-front deploys the frontend into it.

| Object (per pool slot namespace `globus-<dev|qa>-N`) | Owner | Where |
|---|---|---|
| Namespace and its lease labels (`sandbox-owner`, `sandbox-branch`, `sandbox-reserved-until`, `sandbox-ticket`) | BackOfficePrototype | `k8s/pool/<pool>/slot-N`, `scripts/allocate-slot-gke.sh` |
| `globus-app` Deployment and Service (the Hub), ExternalSecrets, NetworkPolicies, HealthCheckPolicy | BackOfficePrototype | `k8s/pool/<pool>/base` |
| `bop-front-web` Service (`:80 -> 8080`, selector `component: frontend`) | BackOfficePrototype | `k8s/pool/<pool>/base/bop-front-web.yaml` |
| HTTPRoute `globus-app` (explicit server paths to `globus-app`; the `/` catch-all's backend is the cut-over switch) | BackOfficePrototype, one field patched by both rails | `k8s/pool/<pool>/base/httproute.yaml`, `scripts/lib/frontend-route.sh` |
| `bop-front-web` Deployment | bop-front | `k8s/bop-front-web.yaml`, applied server-side as field manager `bop-front` |

The label contract both sides rely on: pods carry `app=globus`, `component=frontend`, `environment=<development|qa>`, `globus-pool=<dev|qa>`. BOP's `globus-app` Service selects `component: api`, so API traffic can never reach a frontend pod.

Why BOP never applies the Deployment: BOP's slot deploy re-applies its whole overlay with `--force-conflicts` on every run; a frontend Deployment in BOP's base would have its image and replica count reset each time.

## Deployment Rails

bop-front follows the sandbox branching model (see the Git Branching and Release Model page) with one difference: it has no pool slots of its own. A frontend deploy lands inside a BackOfficePrototype slot namespace and rides the same hostname.

| Branch | Rail | Where the frontend goes |
|---|---|---|
| `feature/<16-digit GID>-*` | `deploy-feature-request.yml` -> `trusted-deploy-dev.yml@main` (dev WIF pool `github-actions-bop-front`, deployer `bop-front-deployer-dev`) | The dev slot BOP's branch with the same Asana GID holds (co-location by ticket), else a slot this branch already holds, else a free one. 30-minute hold. |
| `release/*` | `deploy.yml` (QA WIF, deployer `bop-front-deployer-qa`) | The QA slot BOP's own `release/*` branch holds (release pairs with release, most recently renewed). 24-hour hold. |
| `hotfix/<16-digit GID>-*` | dev slot on push; QA slot on manual dispatch (`deploy-hotfix-qa-request.yml` -> `trusted-deploy-qa.yml@main`, 2-hour hold) | as above |
| `main` | `deploy.yml` `prod` job | **Not wired.** The job explains this in its summary. Production needs BOP's blue-green to become artefact-aware and a prod identity/registry for this repo (ADR 0004 step 4). |

Each deploy: build the image with `VITE_GIT_SHA`, push to Artifact Registry `bop-front`, allocate or pair a slot, render and apply the Deployment, wait for the rollout, then verify from inside the cluster with a probe pod (the Connect Gateway refuses `kubectl exec` from the runner) that the BOP-owned Service serves this commit's build-sha. The job summary names the slot host and the `/api/version` of the Hub build beside the frontend.

A slot whose BOP namespace does not exist is reported, never created: a frontend without a Hub beside it has no API.

## Per-Hostname Cut-over

Until a hostname is cut over, BOP's Express serves the SPA from its own `dist/public`, and bop-front's pod in that slot is reachable only inside the cluster. The cut-over is a routing change, not a code deploy.

Mechanism (ADR 0004 amendment, 2026-10-15):

1. BOP's slot HTTPRoute lists every server-owned path explicitly: `/api` (prefix), `/auth/login`, `/auth/callback`, `/auth/logout` (exact; these are root routes in `server/entraAuth.ts`, not under `/api`), `/agent` (prefix). The last rule is the `/` catch-all. Gateway API matching is longest-prefix first, so the explicit rules win regardless of order. `/auth/pending` is a client route and reaches the frontend through the catch-all; no carve-out exists.
2. The backend of that catch-all rule is the switch: `globus-app` (committed in the manifest, the rollback state) or `bop-front-web`.
3. `scripts/lib/frontend-route.sh`, byte-identical in both repos and run by both deploys (BOP right after its server-side apply, which resets the field; bop-front after its rollout), derives the backend from three inputs and patches that one field when it differs: the pool is listed in `FRONTEND_CUTOVER_POOLS` in the deploy workflows of **both** repos, the namespace has a Ready `bop-front-web` Deployment, and the route already carries the explicit `/api` rule (never swap on the old single-rule shape, or `/api` would go to the static server). Otherwise the backend is set back to `globus-app`, so a hostname never routes to an absent frontend.
4. When the host is routed to it, bop-front's deploy probes the public hostname for its own build-sha (strict, up to ten minutes while the GKE Gateway programs the load balancer) and reports `/api/version` beside it. BOP's reusable post-deploy smoke reports the served build-sha on every run and drives its screens through whatever frontend the host serves.

Rollback for a pool is to remove it from `FRONTEND_CUTOVER_POOLS` in both repos and redeploy any slot, or patch the catch-all back by hand; Express resumes serving the SPA immediately. The committed manifest keeps `globus-app` as the default until BOP subtask 11/11 retires Express's SPA serving, which may only happen after production has held a full release on bop-front.

## Cut-over State

| Pool / hostname | `FRONTEND_CUTOVER_POOLS` | State |
|---|---|---|
| QA pool (`qa-N.globus.allianceabroad.com`) | `qa` in both repos | Cut over 2026-10-15 (BOP #2911, bop-front #21). Any QA slot with a Ready `bop-front-web` serves bop-front; `qa-1` was the first, verified from outside (`/api/health` 200, `/auth/login` 302 to the identity provider, `/`, deep links and `/assets/*` from bop-front). A slot without a frontend keeps Express. |
| Dev pool (`dev-N.globus.allianceabroad.com`) | empty in both repos | Not cut over. Dev slots deploy from BOP branches cut from `main`, which does not carry the `bop-front-web` Service until BOP's next release-to-main promotion. |
| Production (`globus.allianceabroad.com`) | n/a | Not cut over and no bop-front production rail exists. ADR 0004 step 4 needs a human "go" on Asana 1219515997206219. |

## Making a Change Across the Two Repos

ADR 0003 is the protocol. The short version:

- API first, frontend second. A frontend that references an endpoint the live Hub lacks fails loudly in the dual-sha smoke.
- Use the same 16-digit Asana GID in both branch names; the dev rails co-locate the two deploys in one slot by that ticket.
- The shared contract is the Globus SDK and the `client/src/endpoints/*` module; the BOP client's ESLint rule forbids new `/api/...` literals outside that module, and the SDK's contract parity test fails when a client path has no typed method or allow-list reason.
- Paths in both repos that touch the route, the helper, the workflows, the ADRs or the quality gate are protected: `governance-approved` label from a maintainer plus a Code Owner review.
- The Vertex Reviewer does not receive bop-front's webhook events yet (the GitHub App installation does not cover the repo); its verdict is requested by hand with the eligibility agent's `/run/recheck`.

## How to Tell Which Frontend a Host Serves

- `GET https://<host>/` with `Accept: text/html`: a `<meta name="build-sha" content="<40 hex>">` means bop-front (that commit); no meta means Express-served SPA.
- `GET https://<host>/api/version` is always the Hub commit.
- The environment index board (`qa.globus.allianceabroad.com`, `development.globus.allianceabroad.com`) shows `ui <sha>` next to the API commit for cut-over hosts.
- BOP's deploy summary line "Frontend served on this host by" and bop-front's "Host serves the SPA from" say which backend the catch-all points at.

## Sources

- `Alliance-Strategies/bop-front`: `README.md`, `k8s/README.md`, `docs/adr/0001` to `0004`, `scripts/deploy-slot-gke.sh`, `scripts/allocate-slot-gke.sh`, `scripts/lib/frontend-route.sh`.
- `Alliance-Strategies/BackOfficePrototype` (`release/teach`): `k8s/pool/<pool>/base/httproute.yaml`, `k8s/pool/<pool>/base/bop-front-web.yaml`, `scripts/lib/frontend-route.sh`, `.github/workflows/deploy-release-qa.yml`, `.github/workflows/post-deploy-smoke.yml`; PRs #2877, #2883, #2911, #2914.
- `Alliance-Strategies/Globus-SDK` 1.49.0 (`contract/`, `src/generated/`), `Alliance-Strategies/environment-index` #28.
- Asana 1218317218287029 and its subtasks (1/6 to 15/15).
