# 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: <UI_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.
