Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions .devcontainer/compose.instance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 20 additions & 12 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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"]}}
8 changes: 6 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 55 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<upstream>/*`. 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
Expand All @@ -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.
Expand All @@ -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

Expand All @@ -47,48 +54,67 @@ 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
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
Expand Down
2 changes: 1 addition & 1 deletion bff/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@
"typecheck": "tsc -p ."
},
"dependencies": {
"@craftainer/bff": "0.1.0"
"@craftainer/bff": "0.2.0"
}
}
4 changes: 2 additions & 2 deletions docs/TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/adrs/0001-oidc-auth-matching-the-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion docs/adrs/0002-same-origin-api-proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

## Status

Accepted
Superseded by [0007](0007-adopt-craftainer-bff.md): the proxy is now the
BFF's allowlisted `/api/<upstream>/*` 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

Expand Down
8 changes: 8 additions & 0 deletions docs/adrs/0003-re-parent-onto-template-docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 17 additions & 8 deletions docs/adrs/0006-manifest-driven-admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Loading