# Building a new frontend > Audience: anyone tasked with rewriting the UI in Vue, building a Tauri > desktop app, or a mobile client. Read this before touching any code. ## Read this first The api's contract is **stable**. The PHP+Twig UI is **replaceable**. You can throw out the entire `ui/` directory and replace it with a Next.js app, a Vue SPA, a Tauri desktop, or a SwiftUI client without the api caring at all — provided the new frontend respects the authentication model. What's stable, in order of importance: 1. **`/api/v1/*` paths and shapes**, as documented in OpenAPI (`/api/v1/openapi.yaml`). 2. **Token kinds** (`reporter`, `consumer`, `admin`, `service`). 3. **RBAC roles** (`viewer`, `operator`, `admin`). 4. **The impersonation pattern**: service token + `X-Acting-User-Id`. 5. **The `users` shape and `oidc_role_mappings` semantics**. What's **not** stable (and you shouldn't depend on): - Template names under `ui/resources/views/`. - UI route paths under `/app/*`. - Internal class names, file layout, migrations of UI-only state. - The `/internal/jobs/*` surface — scheduler-only, not in OpenAPI. The full stable-vs-replaceable table is in [`architecture.md`](./architecture.md#stable-surfaces-vs-replaceable-parts). ## Three integration patterns ### (a) BFF replacement — drop-in for the current PHP UI Easiest path. A new server process holds the service token, manages browser sessions, and forwards each request to the api with the two-header pattern. Works for SSR-style frontends (Next.js, Nuxt, Rails, Django, Phoenix). What the BFF owns: - The OIDC redirect / callback flow (PKCE). - Browser sessions (any backend; the current PHP UI uses file-backed, but Redis or a signed cookie store works). - A local-admin form (optional). - HTML rendering or hydration of an SPA shell. - CSRF for its own forms. What the BFF does **not** own: - Business logic. Scoring, RBAC, decay all live in the api. - Database access. The BFF holds zero persistent state of its own. - The decision of "who is this user". The api answers that via `/api/v1/auth/users/upsert-oidc` and `/api/v1/auth/users/upsert-local`. Pseudocode for the three critical bits in a Node/Express BFF: ```js // 1. Validate an OIDC ID token; upsert via the api. app.get('/oidc/callback', async (req, res) => { const tokens = await exchangeCode(req.query.code, req.session.pkceVerifier); const id = await validateIdToken(tokens.id_token, oidcConfig); const user = await fetch(`${API_BASE}/api/v1/auth/users/upsert-oidc`, { method: 'POST', headers: { 'Authorization': `Bearer ${UI_SERVICE_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ subject: id.sub, email: id.email, display_name: id.name, groups: id.groups ?? [], }), }).then(r => r.json()); if (user.role === null) { return res.redirect('/no-access'); } req.session.regenerate(); // defeat fixation req.session.user_id = user.id; req.session.role = user.role; res.redirect('/app/dashboard'); }); // 2. The two-header pattern, applied to every admin call. async function callApi(req, path, init = {}) { const headers = new Headers(init.headers); headers.set('Authorization', `Bearer ${UI_SERVICE_TOKEN}`); if (req.session.user_id) { headers.set('X-Acting-User-Id', String(req.session.user_id)); } return fetch(`${API_BASE}${path}`, { ...init, headers }); } // 3. A typical handler: BFF is a pure proxy with HTML rendering on top. app.get('/app/ips', async (req, res) => { const data = await callApi(req, `/api/v1/admin/ips?${req.query}`) .then(r => r.json()); res.render('ips', { user: req.session, data }); }); ``` That's the pattern in 30 lines. The current PHP UI is the same shape, just longer because it does form rendering and CSRF too. ### (b) SPA + thin BFF Browser SPA (Vue / React / Svelte) for rendering; a thin BFF for auth only. The BFF mints short-lived signed cookies the SPA presents on each request to the api. The SPA itself never sees the service token. When to choose this: - You want the snappy navigation and component reuse of an SPA. - Your auth is OIDC and you don't want to put OIDC logic in the browser. - You're OK with one server process whose only job is auth. When **not** to choose this: - You want a thinner stack — pattern (a) does the same job with one process and one render path. Trade-offs to think through: - The BFF still needs CSRF / cross-site protections for the cookie. - Bearer-style headers between SPA and BFF are easier than cookies if you'll terminate at a single hostname. - The SPA can call the api directly, with the BFF's signed token acting as a translation layer that converts cookie → service token + impersonation header. ### (c) Direct API access (native / mobile / SPA without BFF) This is what you'd want for a desktop client (Tauri, Electron) or a mobile app where there's no server. The user authenticates against the api directly, and the api issues a **user-bound token** that the client carries. **This needs api work first.** The user-token issuance flow doesn't exist today. See [`auth-flows.md`](./auth-flows.md#future-direct-user-tokens) for the sketched extension point — the recommended shape is a new `/api/v1/auth/oauth/*` group that mints `kind=user` tokens after an OIDC pass-through or device-code flow. Don't try to give the SPA a service token. The service token can impersonate any user; if it leaks to a browser or a native binary, your audit trail becomes worthless and an attacker has full admin without ever touching a real account. ## Minimum API surface A fully-featured frontend uses about 25 endpoints. Checklist (canonical shapes are in OpenAPI): **Identity** - `GET /api/v1/admin/me` - `POST /api/v1/auth/users/upsert-oidc` (BFF) - `POST /api/v1/auth/users/upsert-local` (BFF, only if local admin enabled) **IPs** - `GET /api/v1/admin/ips` - `GET /api/v1/admin/ips/countries` - `GET /api/v1/admin/ips/{ip}` **Stats** - `GET /api/v1/admin/stats/dashboard` **Manual blocks / allowlist** - `GET / POST /api/v1/admin/manual-blocks` - `GET / DELETE /api/v1/admin/manual-blocks/{id}` - `GET / POST /api/v1/admin/allowlist` - `GET / DELETE /api/v1/admin/allowlist/{id}` **Reporters / consumers / tokens** - `GET / POST /api/v1/admin/reporters` (and `/{id}` PATCH/DELETE) - `GET / POST /api/v1/admin/consumers` (and `/{id}` PATCH/DELETE) - `GET / POST /api/v1/admin/tokens` (and `/{id}` DELETE) **Categories / policies** - `GET / POST / PATCH / DELETE /api/v1/admin/categories[/{id}]` - `GET / POST / PATCH / DELETE /api/v1/admin/policies[/{id}]` - `GET /api/v1/admin/policies/{id}/preview` **Audit / settings** - `GET /api/v1/admin/audit-log` - `GET /api/v1/admin/jobs/status` - `POST /api/v1/admin/jobs/trigger/{name}` - `GET /api/v1/admin/config` A simpler "viewer-only" frontend can skip every write endpoint. The api enforces RBAC server-side, so a frontend that hides the buttons is doing cosmetic work — the api will reject unauthorized calls regardless. ## CORS For BFF patterns (a) — server-to-server calls — CORS doesn't apply. For SPA patterns (b) and (c) — browser-direct calls to the api — configure the api with the right `UI_ORIGIN`: ```dotenv # api .env UI_ORIGIN=https://reputation.example.com ``` The api emits `Access-Control-Allow-Origin: `, `Access-Control-Allow-Credentials: true`, and includes `X-Acting-User-Id` in the allow-list. Multiple origins aren't supported in `v1`; if you need that, deploy multiple api replicas with different `UI_ORIGIN` values, or front them with a reverse proxy that adjusts the header. ## Local development Spin up only the api and a fresh SQLite, point your frontend dev server at it: ```bash docker compose up -d migrate api # api is now reachable at http://localhost:8081 # Generate an admin token to bypass the BFF locally: docker compose exec -T api php bin/console auth:create-token \ --kind=admin --role=admin --quiet # Use this in your frontend's dev server as IRDB_API_TOKEN. # Run your frontend dev server: API_BASE_URL=http://localhost:8081 npm run dev ``` For SPA-with-BFF patterns, run the BFF as a separate process. For direct-API patterns (when that's built), point the OIDC redirect URI at your dev frontend's URL. ## Migration path To swap the current UI with a new one **without downtime**: 1. Build the new container image; expose it on a different port. 2. Stand it up alongside `ui` in compose with its own healthcheck. 3. At the reverse proxy / DNS level, switch the hostname (`reputation.example.com`) from old → new. 4. Once the new UI is verified, scale the old one to zero or remove it from compose. The api doesn't notice. Sessions belong to whichever UI the user hits, so users will be asked to log in again on the new UI — that's the only externally-visible side-effect of the swap. ## What NOT to do - **Don't replicate business logic in the frontend.** Scoring, decay, RBAC checks, manual-block CIDR containment — all of that lives in the api. The frontend just renders what the api returns and forwards what the user types. - **Don't store user data in the frontend's storage.** The session knows `user_id`, `display_name`, `role`. Anything else (their manual-block list, their allowlist) is the api's data; ask for it on each render. - **Don't bypass the service-token pattern by giving the SPA the service token directly.** Pattern (c) needs the user-token flow built first; pattern (b) needs a BFF that mints short-lived cookies. Both are above zero work — but skipping that work means the audit trail no longer attributes actions to humans. - **Don't rely on UI route paths or template names.** The current `/app/*` paths exist because the PHP UI happens to use them. A Vue SPA might use `/dashboard`, `/ips`, etc.; both are fine. - **Don't add custom auth headers.** The api accepts exactly `Authorization: Bearer …` and (for service tokens) `X-Acting-User-Id: …`. Anything else is ignored. - **Don't try to talk to `/internal/jobs/*` from the frontend.** Those routes are RFC1918 + loopback bound and authenticated with the internal job token. They will return `404` from any external-looking IP. The public path for triggering a job is `POST /api/v1/admin/jobs/trigger/{name}`. ## See also - [`api-overview.md`](./api-overview.md) — base URL, auth summary, conventions, worked curl examples. - [`auth-flows.md`](./auth-flows.md) — every auth path in detail; the future user-token sketch is here. - [`architecture.md`](./architecture.md) — system overview, container topology, stable-vs-replaceable surfaces. - [`/api/docs`](http://localhost:8081/api/docs) — interactive OpenAPI viewer.