Audience: anyone tasked with rewriting the UI in Vue, building a Tauri desktop app, or a mobile client. Read this before touching any code.
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:
/api/v1/* paths and shapes, as documented in OpenAPI
(/api/v1/openapi.yaml).reporter, consumer, admin, service).viewer, operator, admin).X-Acting-User-Id.users shape and oidc_role_mappings semantics.What's not stable (and you shouldn't depend on):
ui/resources/views/./app/*./internal/jobs/* surface — scheduler-only, not in OpenAPI.The full stable-vs-replaceable table is in
architecture.md.
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:
What the BFF does not own:
/api/v1/auth/users/upsert-oidc and /api/v1/auth/users/upsert-local.Pseudocode for the three critical bits in a Node/Express BFF:
// 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.
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:
When not to choose this:
Trade-offs to think through:
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 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.
A fully-featured frontend uses about 25 endpoints. Checklist (canonical shapes are in OpenAPI):
Identity
GET /api/v1/admin/mePOST /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/ipsGET /api/v1/admin/ips/countriesGET /api/v1/admin/ips/{ip}Stats
GET /api/v1/admin/stats/dashboardManual blocks / allowlist
GET / POST /api/v1/admin/manual-blocksGET / DELETE /api/v1/admin/manual-blocks/{id}GET / POST /api/v1/admin/allowlistGET / 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}/previewAudit / settings
GET /api/v1/admin/audit-logGET /api/v1/admin/jobs/statusPOST /api/v1/admin/jobs/trigger/{name}GET /api/v1/admin/configA 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.
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:
# 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.
Spin up only the api and a fresh SQLite, point your frontend dev server at it:
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.
To swap the current UI with a new one without downtime:
ui in compose with its own healthcheck.reputation.example.com) from old → new.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.
user_id, display_name, role. Anything else (their
manual-block list, their allowlist) is the api's data; ask for it
on each render./app/* paths exist because the PHP UI happens to use them. A
Vue SPA might use /dashboard, /ips, etc.; both are fine.Authorization: Bearer … and (for service tokens)
X-Acting-User-Id: …. Anything else is ignored./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}.api-overview.md — base URL, auth summary, conventions, worked curl examples.auth-flows.md — every auth path in detail; the future user-token sketch is here.architecture.md — system overview, container topology, stable-vs-replaceable surfaces./api/docs — interactive OpenAPI viewer.