Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
72ad0aa
feat(auth): move the SPA to BFF sessions (I2) and drop oidc-client-ts…
crazyracer98 Oct 30, 2026
67a8bd8
Merge wip/BF1: SPA on the BFF session (BF1)
crazyracer98 Oct 30, 2026
4274521
feat(deps): add @craftainer/bff and bff run/typecheck scripts (BF2)
crazyracer98 Nov 1, 2026
545a94c
feat(bff): thin bff/ workspace member on @craftainer/bff 0.1.0 (BF3)
crazyracer98 Nov 1, 2026
2db36db
feat(image): Node BFF runtime image, Valkey dev stack, bff/ sync tier…
crazyracer98 Nov 1, 2026
99bbb24
feat(analytics): track(), page views from the UrlAdapter, consent hoo…
crazyracer98 Nov 1, 2026
808cb2a
fix(image): drop bundled npm and corepack from the runner (BF4)
crazyracer98 Nov 1, 2026
1510280
Merge remote-tracking branch 'origin/wip/AN5' into wip/bff
crazyracer98 Nov 1, 2026
8d6ba01
fix(analytics): stable page-view defaults, consent GET cannot overwri…
crazyracer98 Nov 1, 2026
5630d86
feat(bff): wire page views, drop the config.js path, fix BF4 nits, wr…
crazyracer98 Nov 1, 2026
8fcd75b
test(e2e): drive the BFF through a real Keycloak login, Valkey and AP…
crazyracer98 Nov 1, 2026
fdf9343
test(e2e): drive the BFF through a real Keycloak login, Valkey and AP…
crazyracer98 Nov 1, 2026
3b02f46
fix(analytics): report the admin's default resource when the URL name…
crazyracer98 Nov 1, 2026
8d52ee8
test(admin): the default resource is published once, with no loop or …
crazyracer98 Nov 1, 2026
a7182a7
feat(csp): Vite html.cspNonce and an Emotion cache with the BFF's per…
crazyracer98 Nov 1, 2026
ab54c0e
feat(bff): adopt @craftainer/bff 0.2.0 and crud-sdk 0.8.0; the admin …
crazyracer98 Nov 4, 2026
13e0900
test(e2e): expect no-store on index.html, which carries a per-request…
crazyracer98 Nov 4, 2026
bfcaad5
test(e2e): wait for the SPA's hero-list load before deleting the sess…
crazyracer98 Nov 4, 2026
617367f
fix(auth): safeReturnTo rejects a path that normalises to protocol-re…
crazyracer98 Nov 4, 2026
53e0be8
Merge origin/main into wip/bff (template-sync, vite 8.2.4, vitest 5.0.3)
crazyracer98 Nov 4, 2026
d5cf353
fix(deps): regenerate the lockfile from main's so source-map-js 1.2.2…
crazyracer98 Nov 4, 2026
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
32 changes: 32 additions & 0 deletions .devcontainer/compose.instance.yml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
10 changes: 9 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
50 changes: 50 additions & 0 deletions .devcontainer/stack/README.md
Original file line number Diff line number Diff line change
@@ -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.
- `<service>.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: <service>: 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.
36 changes: 36 additions & 0 deletions .devcontainer/stack/valkey/README.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions .devcontainer/stack/valkey/compose.yml
Original file line number Diff line number Diff line change
@@ -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:
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"]}}
12 changes: 9 additions & 3 deletions .github/template-sync-manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,16 +111,15 @@ 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
# docker instances -- an instance's own extra hook scripts go in its
# 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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 18 additions & 13 deletions .storybook/stubs.tsx
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 });
Expand Down
Loading
Loading