diff --git a/.devcontainer/compose.instance.yml b/.devcontainer/compose.instance.yml index 218dbd1..96ff4cf 100644 --- a/.devcontainer/compose.instance.yml +++ b/.devcontainer/compose.instance.yml @@ -1,6 +1,10 @@ # template-react's instance-owned additions to template-base's compose.yml # (whose dev service is named `myapp`). Merged over it by # devcontainer.json's dockerComposeFile list. +include: + # The BFF's session store; see stack/README.md. + - stack/valkey/compose.yml + services: myapp: # Not a ports:/networks: block, so .devcontainer/README.md's "Don't" @@ -14,3 +18,31 @@ services: # to the backend". extra_hosts: - "host.docker.internal:host-gateway" + # 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: + valkey: + condition: service_healthy diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ad24269..33b6285 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,11 +13,19 @@ "dockerDashComposeVersion": "v2" } }, - "forwardPorts": [5173], + "forwardPorts": [5173, 8081, 8787], "portsAttributes": { "5173": { "label": "Vite dev server", "onAutoForward": "openPreview" + }, + "8081": { + "label": "Production image (BFF; run with -p 8081:8080, the container listens on 8080)", + "onAutoForward": "silent" + }, + "8787": { + "label": "Dev BFF (Keycloak back-channel logout target)", + "onAutoForward": "silent" } }, "mounts": [ diff --git a/.devcontainer/stack/README.md b/.devcontainer/stack/README.md new file mode 100644 index 0000000..24323ee --- /dev/null +++ b/.devcontainer/stack/README.md @@ -0,0 +1,50 @@ +# stack/ + +One subdirectory per supporting service (here: the BFF's Valkey session +store). Each contains: + +- `compose.yml` — the Compose fragment for that service. +- `README.md` — what it is, connection details, and how to remove it. +- `.env` — local-dev-only default credentials, only for a service + that has some (Valkey here has none). + +Every fragment is listed in `../compose.instance.yml`'s `include:` list, so +all of them start together. A fragment's relative paths resolve against +that fragment's own directory. + +## Devcontainer stack pattern + +`../compose.instance.yml` configures the app service (`myapp`, defined in +the template-owned `../compose.yml`) and pulls every supporting service in +via its `include:` list, not via `devcontainer.json`'s `dockerComposeFile`. + +No fragment here declares a `ports:` mapping or a `networks:` block: + +- **No `networks:`** — every service reaches every other one by its service + name on the default network Compose generates for this project. +- **No `ports:`** — these services are reached only from other containers. + A host-reachable port goes in `forwardPorts`/`portsAttributes` in + `../devcontainer.json` instead. + +Every fragment's service defines a `healthcheck:`, and `myapp` lists a +matching `depends_on: : condition: service_healthy` entry, so +nothing runs against a service that is still starting. Verify a healthcheck +against the real pinned image rather than assuming `curl`/`wget` exist. + +## Do + +- Give a new service its own subdirectory with `compose.yml` + `README.md`, + a `healthcheck:`, and a `depends_on` entry on `myapp`. +- Pin an image tag to the app version only (`valkey/valkey:9.1.2-alpine`), + not the OS sub-patch. + +## Keeping the architecture doc current + +Adding or removing a stack service, or changing how one connects, makes +`../../docs/architecture.md`'s diagram stale — update it in the same change. + +## Don't + +- Add a `ports:` entry to any fragment here. +- Reuse another service's volume or hostname. +- Put real credentials in a fragment — local-dev-only defaults only. diff --git a/.devcontainer/stack/valkey/README.md b/.devcontainer/stack/valkey/README.md new file mode 100644 index 0000000..aa4b500 --- /dev/null +++ b/.devcontainer/stack/valkey/README.md @@ -0,0 +1,36 @@ +# valkey + +[Valkey](https://valkey.io/) (the Linux Foundation's BSD-3-Clause Redis +fork, same wire protocol) holds the BFF's server-side sessions and login +transactions (`bff/`, `@craftainer/bff`). + +- Compose file: `compose.yml` +- Image: `valkey/valkey:9.1.2-alpine` +- Host (from other containers only — see root README's "Don't"): `valkey` +- Port (container-internal): `6379` +- Connection URL: `redis://valkey:6379` (the scheme is the protocol name) +- Data volume: `valkey-data` (append-only persistence enabled) + +## Plaintext in the dev stack + +The BFF refuses a plaintext `redis://`/`valkey://` session-store URL for any +non-loopback host unless `SESSION_STORE_ALLOW_PLAINTEXT` is set. This +dev-only service is reached over the compose network without TLS, so +`../../compose.instance.yml` sets `SESSION_STORE_URL=redis://valkey:6379` +and `SESSION_STORE_ALLOW_PLAINTEXT=true` on `myapp`. A deployment must use +`rediss://` and not set the opt-out. + +## Do + +- Reach this service from inside the devcontainer network rather than + publishing the port: `valkey-cli -h valkey` (or `redis-cli -h valkey`). + +## Don't + +- Publish this service's port to the host, or reuse these defaults + anywhere but local dev. + +## Removing this service + +Delete this directory, remove its entry from `../../compose.instance.yml`'s +`include:` and `depends_on:`, and drop the session-store variables there. diff --git a/.devcontainer/stack/valkey/compose.yml b/.devcontainer/stack/valkey/compose.yml new file mode 100644 index 0000000..71328a2 --- /dev/null +++ b/.devcontainer/stack/valkey/compose.yml @@ -0,0 +1,17 @@ +# Stack service: Valkey, the BFF's session store, reachable only from other +# containers on the compose-generated default network (see ../README.md). +services: + valkey: + image: valkey/valkey:9.1.2-alpine + restart: unless-stopped + command: valkey-server --appendonly yes + volumes: + - valkey-data:/data + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + valkey-data: 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/.github/template-sync-manifest.yml b/.github/template-sync-manifest.yml index 361a723..0d66b94 100644 --- a/.github/template-sync-manifest.yml +++ b/.github/template-sync-manifest.yml @@ -111,8 +111,8 @@ replace: - scripts/skopeo-dir.sh - scripts/test-image-scripts.sh - # template-react's own layer on top of docker's: the runtime image (an - # nginx-served SPA) and its assets, the dev-service compose additions, the + # template-react's own layer on top of docker's: the runtime image (Node + # BFF serving the SPA), the dev-service compose additions, the # hook scripts, and the Vite/TypeScript/lint/format plumbing -- an # instance changing these is changing the build tooling, not its app. # Listed one by one where docker's hook directories are "ignore" for plain @@ -120,7 +120,6 @@ replace: # template-sync-manifest.local.yml. - Dockerfile - .devcontainer/compose.instance.yml - - scripts/docker/ - scripts/develop.d/10-corepack.sh - scripts/post-create.d/10-pnpm-install.sh - scripts/check-design-tokens.sh @@ -163,6 +162,13 @@ ignore: # sync never overwrites its code or static assets. - src/ + # The thin BFF on @craftainer/bff (P-D4): each instance owns its + # composition (routes, upstreams, config), so a sync never touches it. + - bff/ + + # The devcontainer stack (the BFF's Valkey): instance-owned composition. + - .devcontainer/stack/ + # Story fakes for this app's API and auth (preview.tsx imports its # exports), and the baselines rendered from this app's stories. - .storybook/stubs.tsx diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8c4a05f..2b81d3b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -86,6 +86,14 @@ repos: always_run: true stages: [pre-push, manual] + - id: typecheck-bff + name: tsc (bff/) + entry: pnpm run typecheck:bff + language: system + pass_filenames: false + always_run: true + stages: [pre-push, manual] + - id: vitest name: vitest run entry: pnpm exec vitest run diff --git a/.storybook/stubs.tsx b/.storybook/stubs.tsx index 420b99a..b1753d1 100644 --- a/.storybook/stubs.tsx +++ b/.storybook/stubs.tsx @@ -1,14 +1,14 @@ -// Stubs for stories: a fake signed-in/out OIDC session and a fake backend, +// Stubs for stories: a fake signed-in/out BFF session and a fake backend, // both chosen per story through `parameters` (see preview.tsx). Stories never // touch Keycloak or the real API, so every render is deterministic. import type { ReactNode } from "react"; -import { AuthContext, type AuthContextProps } from "react-oidc-context"; +import { AuthContext, type AuthValue } from "../src/auth/authContext"; import type { Hero } from "../src/api/heroes"; export type AuthState = "signedOut" | "signedIn" | "loading"; export interface ApiStub { - /** `GET /api/health/live`: "pending" never answers (the "checking" state). */ + /** `GET /api/crud/health/live`: "pending" never answers (the "checking" state). */ health?: "up" | "down" | "pending"; /** `GET` of the heroes list: rows, "pending" (loading) or an HTTP error. */ heroes?: Hero[] | "pending" | { status: number; detail: string }; @@ -58,20 +58,22 @@ export const heroFixtures: Hero[] = [ const noop = async () => undefined; -export function stubAuth(state: AuthState): AuthContextProps { +export function stubAuth(state: AuthState): AuthValue { const signedIn = state === "signedIn"; return { isLoading: state === "loading", isAuthenticated: signedIn, user: signedIn - ? ({ - access_token: "stub-token", - profile: { preferred_username: "ada.lovelace" }, - } as AuthContextProps["user"]) + ? { + sub: "stub-sub", + name: "ada.lovelace", + roles: { api: ["maintainer"] }, + expiresAt: 4102444800, + } : null, - signinRedirect: noop, - removeUser: noop, - } as unknown as AuthContextProps; + login: () => undefined, + logout: noop, + }; } export function StubAuthProvider({ @@ -102,13 +104,16 @@ export function installApiStub(api: ApiStub = {}) { window.fetch = async (input, init) => { const url = new URL(String(input), window.location.origin); const method = (init?.method ?? "GET").toUpperCase(); - if (url.pathname === "/api/health/live") { + if (url.pathname === "/api/crud/health/live") { if (health === "pending") return never(); return health === "up" ? json(200, { status: "ok" }) : json(503, { title: "down" }); } - if (url.pathname === "/api/crud/v1/heroes/v2/json" && method === "GET") { + if ( + url.pathname === "/api/crud/crud/v1/heroes/v2/json" && + method === "GET" + ) { if (heroes === "pending") return never(); if (!Array.isArray(heroes)) { return json(heroes.status, { detail: heroes.detail }); diff --git a/Dockerfile b/Dockerfile index 496d4f3..05ee5e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ # syntax=docker/dockerfile:1.28 -# Runtime image (builder → runner), the repo's top-level Dockerfile; the -# devcontainer image is `.devcontainer/Dockerfile`'s `develop` stage. +# Runtime image (builder + bff-deploy -> runner), the repo's top-level +# Dockerfile; the devcontainer image is `.devcontainer/Dockerfile`'s `develop` +# stage. # NODE_VERSION/DEBIAN_VERSION must match .devcontainer/Dockerfile's, so # the bundle is built with the same Node the devcontainer tests against -- @@ -9,14 +10,11 @@ ARG NODE_VERSION=24.22.2 ARG DEBIAN_VERSION=trixie -# renovate: datasource=docker depName=nginxinc/nginx-unprivileged -ARG NGINX_VERSION=1.30.6-alpine - ARG SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt ARG SSL_CERT_DIR=/etc/ssl/certs ######################################## -# builder — installs deps with pnpm (the pinned version comes from +# builder -- installs deps with pnpm (the pinned version comes from # package.json's own `packageManager` field, which Corepack reads) and # builds the static SPA bundle. ######################################## @@ -28,40 +26,58 @@ ENV SSL_CERT_FILE=${SSL_CERT_FILE} \ RUN corepack enable WORKDIR /workspace +# bff/package.json: bff/ is a workspace member, so the install needs it. COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY bff/package.json bff/package.json RUN pnpm install --frozen-lockfile COPY . . RUN pnpm build ######################################## -# runner — nginx-unprivileged serving the built SPA and proxying /api to -# the real backend (see docs/adrs/0002-same-origin-api-proxy.md). Its own -# docker-entrypoint.d/20-envsubst-on-templates.sh already renders -# scripts/docker/nginx.conf.template -> /etc/nginx/conf.d/default.conf from -# real container env vars on every start; docker-entrypoint.d/ here adds -# one more such script for this app's own /config.js (its client-side -# runtime config), same mechanism, one level up the stack. +# bff-deploy -- the BFF's production dependency tree only, as a standalone +# directory (`pnpm deploy`), so the runner needs neither pnpm nor the +# workspace. bff/src is Node-typed TypeScript run directly (type stripping), +# so there is nothing to compile. ######################################## -FROM nginxinc/nginx-unprivileged:${NGINX_VERSION} AS runner +FROM node:${NODE_VERSION}-${DEBIAN_VERSION}-slim AS bff-deploy +ARG SSL_CERT_FILE +ARG SSL_CERT_DIR +ENV SSL_CERT_FILE=${SSL_CERT_FILE} \ + SSL_CERT_DIR=${SSL_CERT_DIR} -# Defaults match the backend's own devcontainer (see docs/adrs/0001-...md -# and docs/adrs/0002-...md) -- correct for a local smoke-test of this -# image, not a real deployment, which must override all three. -ENV OIDC_AUTHORITY=http://localhost:8080/realms/template-fastapi \ - OIDC_CLIENT_ID=api \ - API_PROXY_TARGET=http://host.docker.internal:8000 +RUN corepack enable +WORKDIR /workspace +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY bff/package.json bff/package.json +COPY bff/src bff/src +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): 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 -COPY scripts/docker/nginx.conf.template /etc/nginx/templates/default.conf.template -COPY scripts/docker/docker-entrypoint.d/20-envsubst-app-config.sh /docker-entrypoint.d/20-envsubst-app-config.sh -# The base image already switches to its unprivileged `nginx` user -# (uid/gid 101) -- root only for this one COPY+chown, since --chown alone -# reaches the *copied files* but not the pre-existing -# /usr/share/nginx/html directory entry itself (still root-owned there), -# and this stage's own docker-entrypoint.d script needs write access on -# that directory to create config.js at container start. -USER root -COPY --from=builder --chown=nginx:nginx /workspace/dist /usr/share/nginx/html -RUN chown nginx:nginx /usr/share/nginx/html -USER nginx +ENV NODE_ENV=production \ + BFF_STATIC_DIR=/app/dist +WORKDIR /app +# The BFF needs only the node binary. npm and corepack (bundled with the base +# image, with their own vendored dependencies) are never run here, and Trivy +# reports HIGH CVEs in npm's vendored brace-expansion and undici, so remove them. +RUN rm -rf /usr/local/lib/node_modules /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack +COPY --from=bff-deploy --chown=root:root /out /app +COPY --from=builder --chown=root:root /workspace/dist /app/dist +USER node +# The required runtime env (PUBLIC_ORIGIN, OIDC_ISSUER, OIDC_CLIENT_SECRET, +# SESSION_STORE_URL) has no default; the BFF refuses to start without it. EXPOSE 8080 +CMD ["node", "src/main.ts"] 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/README.md b/bff/README.md new file mode 100644 index 0000000..e8cb679 --- /dev/null +++ b/bff/README.md @@ -0,0 +1,55 @@ +# bff/ + +The Node server in front of the SPA: a thin app around +[`@craftainer/bff`](https://github.com/craftainer/lib-bff-ts) (OIDC login, +server-side sessions in Valkey, the `/api//*` proxy, well-known +endpoints, analytics intake, security headers, static files). It is a private +pnpm workspace member (`pnpm-workspace.yaml`: `packages: [".", "bff"]`) with +its own `package.json`, so the image can ship only its production +dependencies (`pnpm --filter ./bff deploy --prod`). + +Node runs the TypeScript directly (type stripping): there is no build step. +Use erasable syntax only (no `enum`, `namespace` or parameter properties) and +`.ts` extensions on relative imports (`bff/tsconfig.json` enforces both). `bff/` is not part of the root +`tsc -b`; it has its own `tsconfig.json`. + +## Files + +- `src/main.ts`: `loadConfig(process.env)`, `buildApp`, `listen` on + `BFF_HOST:BFF_PORT` (default `0.0.0.0:8080`). +- `src/app.ts`: `buildApp(config, opts?)` calls `createBff` and registers this + app's own routes (one example, `GET /bff/ping`). +- `src/app.test.ts`: smoke test, run by the root `vitest run`. It builds the + app in process with the library's in-memory session store, so it needs + neither Valkey nor an IdP. + +## Commands (from the repo root) + +```sh +pnpm dev:bff # node --watch src/main.ts +pnpm start:bff # node src/main.ts +pnpm typecheck:bff # tsc -p bff +``` + +The server needs its configuration in the environment (`PUBLIC_ORIGIN`, +`OIDC_ISSUER`, `OIDC_CLIENT_SECRET`, `SESSION_STORE_URL`, and the optional +variables); `loadConfig` lists every problem at startup. The variables are +documented in the library's README. + +## What belongs here, and what belongs in the library + +Here: + +- App-specific routes (register them in `src/app.ts`, before `listen`). +- Values this app chooses: upstreams (`BFF_UPSTREAMS`), analytics settings, + the options passed to `createBff`. +- Wiring and tests for those. + +In `@craftainer/bff`, not here: + +- Anything about how the BFF behaves: login, sessions, CSRF, headers, the + proxy, health, well-known, analytics, static files. + +If the BFF's behaviour is wrong or missing something, fix it in `lib-bff-ts` +and release a patch version, then bump the exact version in +`bff/package.json`. Do not patch around it or copy library code into `bff/`. diff --git a/bff/package.json b/bff/package.json new file mode 100644 index 0000000..c4d9b12 --- /dev/null +++ b/bff/package.json @@ -0,0 +1,17 @@ +{ + "name": "template-react-bff", + "private": true, + "version": "0.0.0", + "type": "module", + "engines": { + "node": ">=24.22.1" + }, + "scripts": { + "dev": "node --watch src/main.ts", + "start": "node src/main.ts", + "typecheck": "tsc -p ." + }, + "dependencies": { + "@craftainer/bff": "0.2.0" + } +} diff --git a/bff/src/app.test.ts b/bff/src/app.test.ts new file mode 100644 index 0000000..0b1bdf4 --- /dev/null +++ b/bff/src/app.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment node +import { createMemorySessionStore, loadConfig } from "@craftainer/bff"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildApp } from "./app.ts"; + +// Smoke test: the real library wiring, in process, with the in-memory session +// store, so no Valkey or IdP is needed (discovery and the store connection +// are lazy, and /health/live touches neither). +const config = loadConfig({ + PUBLIC_ORIGIN: "http://localhost:8080", + OIDC_ISSUER: "http://idp.invalid/realms/test", + OIDC_ALLOW_INSECURE: "true", + OIDC_CLIENT_SECRET: "test-secret", + SESSION_STORE_URL: "redis://localhost:6379", +}); + +describe("bff", () => { + const apps: Awaited>[] = []; + afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())); + }); + + async function start() { + const app = await buildApp(config, { + sessionStore: createMemorySessionStore(), + logger: false, + }); + apps.push(app); + return app; + } + + it("answers /health/live", async () => { + const app = await start(); + const res = await app.inject({ method: "GET", url: "/health/live" }); + expect(res.statusCode).toBe(200); + }); + + it("serves the example app route", async () => { + const app = await start(); + const res = await app.inject({ method: "GET", url: "/bff/ping" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ ok: true }); + expect(res.headers["cache-control"]).toContain("no-store"); + }); + + it("answers an unknown path with the library's JSON 404", async () => { + const app = await start(); + const res = await app.inject({ method: "GET", url: "/nope" }); + expect(res.statusCode).toBe(404); + }); +}); diff --git a/bff/src/app.ts b/bff/src/app.ts new file mode 100644 index 0000000..63bcd71 --- /dev/null +++ b/bff/src/app.ts @@ -0,0 +1,26 @@ +import { createBff } from "@craftainer/bff"; +import type { + BffConfig, + CreateBffOptions, + FastifyInstance, +} from "@craftainer/bff"; + +/** + * Builds the BFF: the library's Fastify instance plus this app's own routes. + * Returns it unstarted (and without calling `ready()`), so tests can + * `inject()` into it and `main.ts` can `listen()`. + */ +export async function buildApp( + config: BffConfig, + opts?: CreateBffOptions, +): Promise { + const app = await createBff(config, opts); + + // App-specific routes go here, before listen(). Outside /bff/*, /api/*, + // /health/* and /.well-known/* a path is entirely yours; under /bff/ the + // library's CSRF rule (non-GET needs `X-CSRF: 1`) and `no-store` apply. + // This one is only an example; replace or delete it. + app.get("/bff/ping", () => ({ ok: true })); + + return app; +} diff --git a/bff/src/main.ts b/bff/src/main.ts new file mode 100644 index 0000000..91fa7a6 --- /dev/null +++ b/bff/src/main.ts @@ -0,0 +1,8 @@ +import { loadConfig } from "@craftainer/bff"; +import { buildApp } from "./app.ts"; + +// Throws a ConfigError listing every problem (never a secret value). +const config = loadConfig(process.env); +const app = await buildApp(config); + +await app.listen({ port: config.port, host: config.host }); diff --git a/bff/tsconfig.json b/bff/tsconfig.json new file mode 100644 index 0000000..b11ee56 --- /dev/null +++ b/bff/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/docs/TEMPLATE.md b/docs/TEMPLATE.md index 75d2928..20150e9 100644 --- a/docs/TEMPLATE.md +++ b/docs/TEMPLATE.md @@ -651,8 +651,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 `