diff --git a/.devcontainer/compose.instance.yml b/.devcontainer/compose.instance.yml index 7d28312..96ff4cf 100644 --- a/.devcontainer/compose.instance.yml +++ b/.devcontainer/compose.instance.yml @@ -18,10 +18,29 @@ services: # to the backend". extra_hosts: - "host.docker.internal:host-gateway" - # The dev BFF's session store. Plaintext is the documented dev-only - # opt-out (the BFF refuses plaintext non-loopback URLs otherwise); see - # stack/valkey/README.md. + # The dev BFF (`pnpm dev:bff`, port 8787; Vite on 5173 proxies to it). All + # values are dev-only placeholders: the realm and the `bff` client secret + # are template-fastapi's devcontainer Keycloak (its + # .devcontainer/stack/keycloak/keycloak.env, reached through the host's + # forwarded port 8080 like the API on 8000). environment: + # The browser-facing origin of the dev app (the Vite server); it derives + # redirect_uri and the Origin check. + PUBLIC_ORIGIN: http://localhost:5173 + # The `iss` Keycloak puts in tokens (its KC_HOSTNAME)... + OIDC_ISSUER: http://localhost:8080/realms/template-fastapi + # ...while this container reaches Keycloak through the host gateway + # (its realm sets KC_HOSTNAME_BACKCHANNEL_DYNAMIC, so the discovery + # document's token and JWKS endpoints use the host the BFF asked). + OIDC_DISCOVERY_URL: http://host.docker.internal:8080/realms/template-fastapi/.well-known/openid-configuration + OIDC_CLIENT_SECRET: dev-only-bff-client-secret + # Plain-http IdP: development only (refused when NODE_ENV=production). + OIDC_ALLOW_INSECURE: "true" + BFF_PORT: "8787" + BFF_UPSTREAMS: '{"crud":{"url":"http://host.docker.internal:8000","audience":"api","methods":["GET","POST","PUT","PATCH","DELETE"]}}' + # The BFF's session store. Plaintext is the documented dev-only opt-out + # (the BFF refuses plaintext non-loopback URLs otherwise); see + # stack/valkey/README.md. SESSION_STORE_URL: redis://valkey:6379 SESSION_STORE_ALLOW_PLAINTEXT: "true" depends_on: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4daf307..33b6285 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,7 +20,7 @@ "onAutoForward": "openPreview" }, "8081": { - "label": "Production image (BFF)", + "label": "Production image (BFF; run with -p 8081:8080, the container listens on 8080)", "onAutoForward": "silent" }, "8787": { diff --git a/.dockerignore b/.dockerignore index 177001b..c06dac9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,6 +17,10 @@ test-results README.md CLAUDE.md +# The BFF's tests never run in the image; the bff-deploy stage copies bff/src +# into the runner. +bff/src/**/*.test.ts + # Node (app runtime): the builder stage runs its own `pnpm install` / # `pnpm build`; without these, `COPY . .` (after that install) would overwrite # the container's node_modules with the host's, which may not match its diff --git a/.env.example b/.env.example index 668de81..e03143a 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,23 @@ # Copy to .env and adjust for your setup; see README.md's "Connecting to -# the backend" and docs/adrs/0002-same-origin-api-proxy.md. +# the backend" and docs/adrs/0007-adopt-craftainer-bff.md. +# +# Only Vite reads this file (vite.config.ts). The dev BFF reads real +# environment variables instead: in the devcontainer they come from +# .devcontainer/compose.instance.yml. -# Where the Vite dev server proxies /api to. Defaults (in vite.config.ts) -# to http://host.docker.internal:8000, reaching the sibling template-fastapi -# repo's own devcontainer via its forwarded port 8000. Override if you run -# the backend directly on the host instead (http://localhost:8000) or on a -# different port. -# VITE_API_PROXY_TARGET=http://localhost:8000 +# Where the Vite dev server proxies /bff, /api and /.well-known to: the dev +# BFF (`pnpm dev:bff`). Defaults to http://localhost:8787. The BFF itself +# reaches the backend through BFF_UPSTREAMS. +# VITE_BFF_PROXY_TARGET=http://localhost:8787 -# OIDC settings (src/auth/config.ts) -- defaults match the backend's own -# devcontainer Keycloak realm/client, so these are only needed to point at -# a different realm. -# VITE_OIDC_AUTHORITY=http://localhost:8080/realms/template-fastapi -# VITE_OIDC_CLIENT_ID=api +# The dev BFF's environment, when you run `pnpm dev:bff` outside the +# devcontainer (placeholders; the secret is template-fastapi's dev-only one). +# Export these in your shell; Vite does not read them. +# PUBLIC_ORIGIN=http://localhost:5173 +# OIDC_ISSUER=http://localhost:8080/realms/template-fastapi +# OIDC_CLIENT_SECRET=dev-only-bff-client-secret +# OIDC_ALLOW_INSECURE=true +# SESSION_STORE_URL=redis://localhost:6379 +# SESSION_STORE_ALLOW_PLAINTEXT=true +# BFF_PORT=8787 +# BFF_UPSTREAMS={"crud":{"url":"http://localhost:8000","audience":"api","methods":["GET","POST","PUT","PATCH","DELETE"]}} diff --git a/Dockerfile b/Dockerfile index fd4c03b..7602dee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,8 +56,12 @@ RUN pnpm --filter ./bff deploy --prod /out # runner -- Node, unprivileged (`node`, uid 1000), digest-pinned. Runs the # BFF on 8080: it serves the built SPA (BFF_STATIC_DIR), the /bff/* auth # routes and the reverse proxy to the API upstreams. Renovate updates the -# tag and digest together; keep the tag equal to -# NODE_VERSION-DEBIAN_VERSION-slim (the digest is the multi-arch index's). +# tag and digest together. Keep the tag equal to +# NODE_VERSION-DEBIAN_VERSION-slim (the digest is the multi-arch index's): a +# digest wins over the tag when Docker pulls, so the tag cannot be derived from +# the ARGs here; when the ARGs change, change this line with them (the +# builder's node binary is what the tests ran against). No script checks this +# yet. ######################################## # renovate: datasource=docker depName=node FROM node:24.22.1-trixie-slim@sha256:89c5244bc22a9bc3287d89ebc7e49b9cb5bb84cd40e6175ac3421d5a9f68b95a AS runner diff --git a/README.md b/README.md index 032922b..c5376ba 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ The React frontend for [`template-fastapi`](https://github.com/craftainer/template-fastapi): Vite + React + TypeScript + MUI, authenticating against that backend's -own Keycloak realm and talking to it exclusively through a same-origin -`/api` proxy. Itself an instance of +own Keycloak realm through a Node backend-for-frontend (`bff/`, built on +[`@craftainer/bff`](https://github.com/craftainer/lib-bff-ts)) that keeps the +tokens server-side and proxies `/api//*`. Itself an instance of [`template-docker`](https://github.com/craftainer/template-docker) (the shared image layer, itself built on [`template-base`](https://github.com/craftainer/template-base)) — see @@ -19,10 +20,9 @@ See [`docs/adrs/`](docs/adrs/) for the reasoning behind this app's own decisions: - [`0001-oidc-auth-matching-the-backend.md`](docs/adrs/0001-oidc-auth-matching-the-backend.md) — - auth via `react-oidc-context` against the backend's existing realm. + superseded by 0007 (browser-side OIDC). - [`0002-same-origin-api-proxy.md`](docs/adrs/0002-same-origin-api-proxy.md) — - the `/api` proxy (dev and prod), cross-repo dev networking, and the - production image's runtime-configurable settings. + superseded by 0007 (the nginx `/api` proxy and runtime `config.js`). - [`0003-re-parent-onto-template-docker.md`](docs/adrs/0003-re-parent-onto-template-docker.md) — the image layer from `template-docker`, the `Dockerfile` layout, `image.mk`, and the UI dependency set. @@ -37,6 +37,13 @@ decisions: - [`0006-manifest-driven-admin.md`](docs/adrs/0006-manifest-driven-admin.md) — the admin built on `@craftainer/crud-sdk` and `@craftainer/crud-views`, from the backend's CRUD manifest, and its end-to-end test. +- [`0007-adopt-craftainer-bff.md`](docs/adrs/0007-adopt-craftainer-bff.md) — + the BFF: cookie sessions, no tokens in the browser, the Node runner image. +- [`0008-spa-analytics-and-consent.md`](docs/adrs/0008-spa-analytics-and-consent.md) — + page views and events through the BFF, implied consent, GPC/DNT. + +Requirements: [`FR-0001`](docs/frs/FR-0001-sign-in-through-the-bff.md); +the services and how they connect: [`docs/architecture.md`](docs/architecture.md). ## Getting started @@ -47,8 +54,10 @@ via `scripts/post-create.d/10-pnpm-install.sh`, the dependencies automatically. Then: ```sh -pnpm dev # Vite dev server, http://localhost:5173 +pnpm dev:bff # the dev BFF on :8787 (needs Keycloak, the backend and Valkey) +pnpm dev # Vite dev server, http://localhost:5173 (proxies to the BFF) pnpm typecheck # tsc -b +pnpm typecheck:bff # tsc -p bff pnpm lint # oxlint pnpm format # prettier --check . pnpm test # vitest run @@ -56,39 +65,56 @@ pnpm build # production bundle -> dist/ pnpm storybook # component workshop, http://localhost:6006 tests/visual/run.sh # visual regression + axe over the stories (Docker) tests/visual/run.sh update # rewrite the screenshot baselines -tests/e2e/run.sh # admin journey against a running template-fastapi (Docker) +tests/e2e/lane.sh # real-stack e2e: Keycloak, Valkey, BFF and API (Docker) ``` ## Connecting to the backend -This app never talks to the backend directly from the browser — every -request goes through a same-origin `/api` proxy (`vite.config.ts` in -dev, `scripts/docker/nginx.conf.template` in the built image), so the backend -never needs CORS configured for this app. See -[`docs/adrs/0002-same-origin-api-proxy.md`](docs/adrs/0002-same-origin-api-proxy.md) -for the full reasoning; in short: +The browser never talks to the backend, or to Keycloak with a token: it +talks to the BFF on its own origin (`/bff/*` for sign-in and session, +`/api/crud/*` for the backend), so the backend needs no CORS and the page +holds no token. See +[`docs/adrs/0007-adopt-craftainer-bff.md`](docs/adrs/0007-adopt-craftainer-bff.md); +in short: - **Both repos' devcontainers open side by side** (the common case): no - configuration needed. `VITE_API_PROXY_TARGET` defaults to - `http://host.docker.internal:8000`, reaching `template-fastapi`'s own - devcontainer via its forwarded port 8000. -- **Backend running directly on the host** instead of in its own - devcontainer: copy `.env.example` to `.env` and set - `VITE_API_PROXY_TARGET=http://localhost:8000`. -- **Sign-in**: `VITE_OIDC_AUTHORITY`/`VITE_OIDC_CLIENT_ID` (also in - `.env.example`) default to the backend's own devcontainer Keycloak - realm/client — no configuration needed for the same common case above. - -The built production image takes the equivalent settings as real -container env vars instead (`API_PROXY_TARGET`, `OIDC_AUTHORITY`, -`OIDC_CLIENT_ID`) — see the ADR's "Runtime-configurable production -image" section. + configuration needed. `.devcontainer/compose.instance.yml` gives the dev + BFF (`pnpm dev:bff`, port 8787) its environment: the backend at + `http://host.docker.internal:8000` (`BFF_UPSTREAMS`), template-fastapi's + Keycloak realm at port 8080, its dev-only `bff` client secret, and the + Valkey service. `pnpm dev` then serves the SPA on 5173 and proxies + `/bff`, `/api` and `/.well-known` to the BFF. +- **Backend running directly on the host**: export the variables in + `.env.example` (`BFF_UPSTREAMS` with `http://localhost:8000`). +- **Another BFF address for Vite**: `VITE_BFF_PROXY_TARGET` in `.env`. + +### Runtime configuration + +The BFF reads real environment variables (there is no `config.js` and no +build-time OIDC setting). It refuses to start without: + +| Variable | Meaning | +| -------------------- | ------------------------------------------------------------------------------------------------------- | +| `PUBLIC_ORIGIN` | Browser-facing origin, e.g. `https://app.example.com`; derives the redirect URI and the Origin check. | +| `OIDC_ISSUER` | The expected `iss` of the IdP's tokens (the realm URL). | +| `OIDC_CLIENT_SECRET` | Secret of the confidential `bff` client. | +| `SESSION_STORE_URL` | Valkey/Redis URL; `rediss://` outside local dev (plaintext needs `SESSION_STORE_ALLOW_PLAINTEXT=true`). | + +Also commonly set: `BFF_UPSTREAMS` (JSON allowlist, `crud` by default +mounted at `/api/crud/*`), `OIDC_DISCOVERY_URL` (when the BFF reaches the IdP +at another host than `iss`), `ANALYTICS_*` and `SECURITY_TXT_*`. Every +variable is documented in the +[`@craftainer/bff` README](https://github.com/craftainer/lib-bff-ts#configuration). ## Production image and releases The runtime image is the top-level `Dockerfile`'s `runner` stage -(`docker build --target runner .`); `.devcontainer/Dockerfile` only builds -the devcontainer. `release.yml` drives the template-docker-owned +(`docker build --target runner .`): a digest-pinned Node image running +`bff/` as the unprivileged `node` user on port 8080, serving the built SPA. +Run it with the required variables above, publishing container port 8080 on +host port 8081 (`-p 8081:8080`, with `PUBLIC_ORIGIN=http://localhost:8081`), +because 8080 is Keycloak in the devcontainer. `/health/live` needs neither Valkey nor the +IdP. `.devcontainer/Dockerfile` only builds the devcontainer. `release.yml` drives the template-docker-owned `Makefile` (tuned by this repo's `image.mk`), which builds it natively for `amd64` and `arm64`, smoke-tests each image, and, when the `OCI_REGISTRY` repository variable is set (e.g. `ghcr.io`), copies the tested images to diff --git a/bff/package.json b/bff/package.json index 93419c4..c4d9b12 100644 --- a/bff/package.json +++ b/bff/package.json @@ -12,6 +12,6 @@ "typecheck": "tsc -p ." }, "dependencies": { - "@craftainer/bff": "0.1.0" + "@craftainer/bff": "0.2.0" } } diff --git a/docs/TEMPLATE.md b/docs/TEMPLATE.md index 91e56e4..13b9529 100644 --- a/docs/TEMPLATE.md +++ b/docs/TEMPLATE.md @@ -436,8 +436,8 @@ removes the container and the private `image-smoke-*` tag it created Optional. It serves a runtime image that needs the group-0 user and writable directories; an instance whose `runner` stage doesn't call it -(e.g. `template-react`'s nginx runner, whose base image already has its own -non-root user) simply never runs it. Both files stay in the tree, since +(e.g. `template-react`'s Node runner, whose base image already has its own +non-root `node` user) simply never runs it. Both files stay in the tree, since they are template-owned, and nothing checks or requires them. `scripts/runner-setup.sh` creates the user (UID `APP_UID`, group 0), makes diff --git a/docs/adrs/0001-oidc-auth-matching-the-backend.md b/docs/adrs/0001-oidc-auth-matching-the-backend.md index 971974b..3072f22 100644 --- a/docs/adrs/0001-oidc-auth-matching-the-backend.md +++ b/docs/adrs/0001-oidc-auth-matching-the-backend.md @@ -2,7 +2,9 @@ ## Status -Accepted +Superseded by [0007](0007-adopt-craftainer-bff.md): sign-in now happens in +the BFF, and the SPA holds no tokens. The backend-realm reasoning below +still describes why the realm is `template-fastapi`'s. ## Context diff --git a/docs/adrs/0002-same-origin-api-proxy.md b/docs/adrs/0002-same-origin-api-proxy.md index f263d31..cd87f9f 100644 --- a/docs/adrs/0002-same-origin-api-proxy.md +++ b/docs/adrs/0002-same-origin-api-proxy.md @@ -2,7 +2,10 @@ ## Status -Accepted +Superseded by [0007](0007-adopt-craftainer-bff.md): the proxy is now the +BFF's allowlisted `/api//*` proxy (dev and production), and there +is no nginx or runtime `config.js` any more. The same-origin-instead-of-CORS +reasoning still holds. ## Context diff --git a/docs/adrs/0003-re-parent-onto-template-docker.md b/docs/adrs/0003-re-parent-onto-template-docker.md index 0c33689..f8aebe4 100644 --- a/docs/adrs/0003-re-parent-onto-template-docker.md +++ b/docs/adrs/0003-re-parent-onto-template-docker.md @@ -6,6 +6,14 @@ Accepted Note (2026-10-26, LIC1): the project licence identifier is now `GPL-3.0-only`, not `GPL-3.0-or-later`; the `OCI_LICENSE` default and the `licenses` label follow it. The `GPL-3.0-or-later` mentions below record what was decided at the time. +Note (2026-11, BF4/BF5a, ADR 0007): the runner is no longer nginx. It is +`node:${NODE_VERSION}-${DEBIAN_VERSION}-slim` (digest-pinned, user `node`) +running `bff/` on 8080, so the nginx base, `scripts/docker/` (nginx template and +envsubst entrypoint) and `-e API_PROXY_TARGET` in `IMAGE_SMOKE_RUN_ARGS` are +gone; the smoke test hits `/health/live`. The runner still does not use +`runner-setup.sh`. The `IMAGE_SMOKE_*` table below records what was decided at +the time. + ## Context template-react was an instance of `template-base` that carried its own diff --git a/docs/adrs/0006-manifest-driven-admin.md b/docs/adrs/0006-manifest-driven-admin.md index 499b761..df16675 100644 --- a/docs/adrs/0006-manifest-driven-admin.md +++ b/docs/adrs/0006-manifest-driven-admin.md @@ -4,6 +4,14 @@ Accepted +Note (2026-11, ADR 0007): the browser no longer holds an OIDC session. The +token provider and `signinSilent()` in "Client wiring" are replaced by cookie +sessions with the BFF (`credentials: "include"`, `X-CSRF: 1`, `cacheScope` from +`/bff/user`'s `sub`), the `/api` prefix is the BFF's `/api/crud` upstream, and +production no longer uses nginx (the "adds no such header" remark is moot; the +BFF sets its own headers). The query-string navigation here is what ADR 0008's +page views read. + ## Context `HeroesPage` was written for one resource: it hardcoded the Hero columns and @@ -105,11 +113,11 @@ Four facts about the libraries and the app shaped the wiring: `src/pages/AdminPage.test.tsx`). Stories cover the admin's states against a fake backend built from a captured real manifest (`src/pages/adminStub.ts`), so the real SDK and views run in them. - `tests/e2e/` runs one journey in a real browser against a running - template-fastapi and its Keycloak: sign in, create, conditional edit, a 412 - after an out-of-band change, archive and the trash. It uses the same pinned - Playwright image as the visual suite, serving the built bundle with - `vite preview`. + `tests/e2e/` runs one journey in a real browser against the BFF + ([ADR 0007](0007-adopt-craftainer-bff.md)), which serves the built bundle in + front of a running template-fastapi and its Keycloak: sign in, create, + conditional edit, a 412 after an out-of-band change, archive and the trash. + It uses the same pinned Playwright image as the visual suite. - **Removed.** `HeroesPage`, `src/api/crud.ts` and the hand-written client tests. `src/api/heroes.ts` keeps only the `Hero` type, because `.storybook/stubs.tsx` (template-owned) imports it. @@ -125,9 +133,10 @@ Four facts about the libraries and the app shaped the wiring: - The e2e journey covers inline edit (conditional save, and a refusal after an outside change); jsdom cannot render the DataGrid's rows, so the unit tests cover `rowEtagGetter` and the stories do not edit cells. -- The e2e needs a running template-fastapi (MODE=dev, Postgres and Keycloak) - and Docker for the Playwright image; it is not part of `prek` and runs in the - verification lane (`tests/e2e/README.md`). +- The e2e needs template-fastapi (MODE=dev, with Postgres and Keycloak), a + Valkey and Docker for the Playwright image; `tests/e2e/lane.sh` brings them up. + It is not part of `prek` and runs in the verification lane + (`tests/e2e/README.md`). - Without a router, a record or view is not a path (`/heroes/1`); links are query strings. Adding a router later means replacing `useUrlParams` and passing the router's adapter to the lists. diff --git a/docs/adrs/0007-adopt-craftainer-bff.md b/docs/adrs/0007-adopt-craftainer-bff.md new file mode 100644 index 0000000..e8d16c1 --- /dev/null +++ b/docs/adrs/0007-adopt-craftainer-bff.md @@ -0,0 +1,104 @@ +# 0007. Adopt `@craftainer/bff`: cookie sessions behind a Node server, no tokens in the browser + +## Status + +Accepted. Supersedes [0001](0001-oidc-auth-matching-the-backend.md) and +[0002](0002-same-origin-api-proxy.md). + +## Context + +Until now the browser ran the OIDC Authorization Code + PKCE flow itself +(`react-oidc-context`), held the access token in JavaScript and sent it as a +bearer token through an nginx `/api` proxy whose settings were rendered at +container start by `envsubst` into `config.js` (ADRs 0001 and 0002). Three +things made that a poor base for a template: + +- Tokens reachable from JavaScript are readable by any script that runs in + the page (OWASP A02/A07; the current OAuth browser-based-apps guidance and + RFC 10017 on the BFF pattern recommend keeping them out of the browser). +- The nginx image could not hold a session, check `Origin`, serve + `/.well-known/*`, or receive analytics events, and the `envsubst` config + path was a second configuration mechanism next to Vite's. +- The same server-side pieces are wanted by every UI template, so they belong + in a library (`@craftainer/bff`, repository `craftainer/lib-bff-ts`). + +## Decision + +We will run a Node server in front of the SPA, built from the library, and +the SPA holds no tokens: + +- **Library plus a thin `bff/`.** `bff/` is a private pnpm workspace member + that calls `loadConfig(process.env)` and `createBff`, adds this app's own + routes and listens (`bff/README.md`). Behaviour (login, sessions, CSRF, + headers, proxy, well-known, analytics, static files) stays in the library; a + defect there is fixed in the library and released, not patched in `bff/`. + The version is pinned exactly in `bff/package.json`. +- **Login.** The BFF is the confidential OIDC client (`bff`) of + template-fastapi's Keycloak realm. The browser holds one `__Host-Http-` + session cookie (`Secure; HttpOnly; SameSite=Strict`); sessions, refresh + tokens and login transactions live in Valkey. `PUBLIC_ORIGIN`, never the + `Host` header, derives `redirect_uri` and the Origin check. +- **SPA contract.** `GET /bff/user` (401 means "navigate to `/bff/login`"), + `GET /bff/login?returnTo=`, `POST /bff/logout`, `GET /bff/config`. Every + `/api/*` request and every non-GET `/bff/*` request carries `X-CSRF: 1`. + Roles from `/bff/user` are for the UI only; the backend still authorises. +- **API access.** `BFF_UPSTREAMS` is an allowlist; the default instance has + one upstream, `crud`, mounted at `/api/crud/*` (prefix stripped). The BFF adds + `Authorization: Bearer`, refreshing the token when needed. The browser never + sees it, and inbound `Authorization`/`Cookie` headers are never forwarded. + This keeps ADR 0002's reason: same origin, so no CORS on the backend. +- **Dev.** Vite (5173) proxies `/bff`, `/api` and `/.well-known` to the dev BFF + (8787, `pnpm dev:bff`), whose environment comes from + `.devcontainer/compose.instance.yml`. The BFF reaches the sibling backend + (port 8000) and Keycloak (port 8080) through `host.docker.internal`, as + ADR 0002 set up. Valkey is a stack service (`.devcontainer/stack/valkey/`). +- **Image.** `Dockerfile` builds the SPA and `pnpm --filter ./bff deploy +--prod`, then runs `node src/main.ts` (type stripping, no build step) as + the unprivileged `node` user on a digest-pinned `node:*-slim` image, port 8080. npm, npx and corepack are removed from the runner. The smoke test is + `/health/live`, which needs neither Valkey nor the IdP. +- **Configuration.** Required at runtime: `PUBLIC_ORIGIN`, `OIDC_ISSUER`, + `OIDC_CLIENT_SECRET`, `SESSION_STORE_URL` (`rediss://` outside the dev + stack). `loadConfig` lists every problem at startup. There is no + `config.js`, no `VITE_OIDC_*` and no `envsubst`. + +## Consequences + +- No access or refresh token is ever reachable from script, and sessions can + be ended server-side (logout, expiry, back-channel logout). +- A deployment now needs Valkey (or a compatible store) and a client secret, + and its IdP must register `/bff/callback` and the back-channel + logout URL. Losing Valkey signs everyone out. +- The BFF is one more process to patch: library releases arrive through + Renovate as exact-version bumps. +- Rate limits are in memory, per replica, until a Valkey-backed limiter is + shown to work. The visitor-token for analytics has the same replica limit. +- Static serving and the security headers move from nginx into the library, + so the CSP is the library's; an app that needs a different one changes it + through the library. +- Moving between hosts or origins changes `PUBLIC_ORIGIN` and the realm's + redirect URIs together. Image runs on a developer machine use host port 8081 + (`8081:8080`), because 8080 is Keycloak. + +## Amendment: the CSP style nonce (BF5c) + +The library's fixed `style-src 'self'` blocked Emotion's runtime `