diff --git a/demo-client/README.adoc b/demo-client/README.adoc
index f172a5fd..720dbd74 100644
--- a/demo-client/README.adoc
+++ b/demo-client/README.adoc
@@ -8,8 +8,9 @@ drives it in a real browser against the running `integration-tests` stack.
The SPA is served *by the gateway itself* as a public asset, so it is same-origin with the reserved
`/auth` paths and exercises the genuine browser-facing contract. The suite runs the same specs
-against *both* session modes -- `server` on port 10443 and `cookie` on port 10445 -- so "the two
-variants are browser-observably identical" is an executable assertion rather than a claim.
+against *both* session modes, which turns "the two variants are browser-observably identical" into an
+executable assertion rather than a claim -- see
+link:doc/playwright-suite.adoc#_why_the_demo_exists[Why the Demo Exists].
This module ships no Java and produces no artifact. Nothing here reaches the production image or the
native executable.
@@ -25,84 +26,63 @@ toolchain into `target/` and installs the Chromium build Playwright drives.
== Running It
One command does everything -- toolchain install, lint, browser install, three-container bring-up,
-both Playwright projects, teardown:
+both Playwright projects, and teardown on a successful run:
[source,bash]
----
-python3 .plan/execute-script.py plan-marshall:build-maven:maven \
- run --command-args "verify -Pe2e-demo -pl demo-client"
+./mvnw verify -Pe2e-demo -pl demo-client
----
-When iterating on specs, hold the stack up and re-run only the tests:
+A *failing* suite deliberately leaves the containers up: the npm goal aborts the build before Maven
+reaches its teardown phase, so the stack you need for diagnosis is still there. Stop it with
+`demo-client/scripts/stop-dev-environment.sh`, or with a `clean` build under the same profile
+(`./mvnw clean -Pe2e-demo -pl demo-client`) -- the `pre-clean` teardown lives inside `e2e-demo`, so a
+plain `clean` does not run it. See
+link:doc/playwright-suite.adoc#_running_the_suite_locally[Running the Suite Locally].
+
+When iterating on specs, hold the stack up and re-run only the tests. `playwright.config.js` takes
+its three addresses from the environment and fails loudly when any is unset; the `e2e-demo` profile
+supplies them from `demo-client/pom.xml`, so a standalone run has to export them itself:
[source,bash]
----
demo-client/scripts/start-dev-environment.sh
+
+# Mirrors the demo.baseUrl.server, demo.baseUrl.cookie and demo.keycloak.url properties in
+# demo-client/pom.xml, which remain the single source of these addresses.
+export PLAYWRIGHT_BASE_URL_SERVER=https://localhost:10443
+export PLAYWRIGHT_BASE_URL_COOKIE=https://localhost:10445
+export KEYCLOAK_HOST_URL=https://localhost:1443
+
cd demo-client && npm run test
demo-client/scripts/stop-dev-environment.sh
----
-`npm run test`, never `npx`: `npm run` resolves the binary from `node_modules/.bin` only, so a
-missing binary is a hard failure rather than a silent registry download. The build makes the same
-choice, for the same reason.
-
-Without `-Pe2e-demo` this module is a no-op: a default reactor build downloads no Node, runs no npm
-command, starts no container and produces no artifact.
+`npm run test`, never `npx` -- and no Maven npm execution runs outside `-Pe2e-demo`: a default
+reactor build downloads no Node, runs no npm command, starts no container and produces no artifact.
+The direct `npm run test` above is the one npm invocation outside the profile, and it is a deliberate
+developer opt-in rather than part of any build. Both are load-bearing choices rather than style, and
+the reasoning is in link:doc/playwright-suite.adoc#_the_opt_in_mechanism[The Opt-In Mechanism].
Once the stack is up, the application is at `https://localhost:10443/assets/demo/index.html`
(server-session mode) and `https://localhost:10445/assets/demo/index.html` (cookie mode). Sign in as
-`integration-user` / `integration-password`. Note the explicit filename -- the gateway serves no
-directory index, so `/assets/demo/` returns `404`.
-
-== Where the Output Lands
-
-Everything generated sits under `target/` and is git-ignored:
-
-[cols="1,2"]
-|===
-| Path | Contents
-
-| `target/test-results/`
-| Playwright JSON and JUnit results. There is no HTML reporter, so no report server is spawned.
-
-| `target/screenshots/{project}/`
-| The documentation screenshots -- `anonymous`, `authenticated-default-view`,
- `full-allowlisted-view`, `claim-denied`, `logged-out` -- one parallel set per session mode.
-
-| `target/node/`, `target/node_modules/`
-| The pinned Node toolchain and the installed packages.
-|===
-
-== Layout
-
-[cols="1,2"]
-|===
-| Path | What it is
-
-| `src/main/resources/spa/`
-| The SPA: `index.html`, `app.js`, `app.css`, `landing.html`. No framework, no bundler, no build
- step -- the served files are the authored files, bind-mounted into the gateway containers.
-
-| `tests/`, `fixtures/`, `utils/`
-| The Playwright suite, its shared fixtures, and the Keycloak login helper.
-
-| `playwright.config.js`
-| Two projects, `session-server` and `session-cookie`, differing only in `baseURL`.
+`integration-user` / `integration-password`. Note the explicit filename -- the gateway serves
+link:doc/integration-sample.adoc#_no_directory_index[no directory index].
-| `scripts/`
-| The trimmed bring-up and teardown of exactly three containers.
-|===
+Everything generated sits under `target/` and is git-ignored.
== Further Reading
Both layers are authoritative; this file is only the front door.
-* link:../doc/user/demo-client.adoc[Demo Client -- the BFF Integration Sample] -- the *integrator*
+* link:doc/integration-sample.adoc[Demo Client -- the BFF Integration Sample] -- the *integrator*
layer: the browser-facing contract, the four identity-disclosure states, the same-origin
`returnUrl` rule, and the `gateway.yaml` a deployment needs to serve its own bundle this way.
-* link:../doc/development/demo-client.adoc[Demo Client and the Playwright End-to-End Suite] -- the
- *contributor* layer: the module layout, the opt-in build mechanism, the Chromium host-resolver
- mapping and why it is required, and the standing prohibitions this module carries.
+* link:doc/playwright-suite.adoc[Demo Client and the Playwright End-to-End Suite] -- the
+ *contributor* layer: the link:doc/playwright-suite.adoc#_module_layout[module layout], where a run
+ link:doc/playwright-suite.adoc#_running_the_suite_locally[leaves its results and screenshots], the
+ opt-in build mechanism, the Chromium host-resolver mapping and why it is required, and the standing
+ prohibitions this module carries.
Read the contributor document before changing the suite. Several of its constraints look arbitrary
and are not.
diff --git a/doc/user/demo-client.adoc b/demo-client/doc/integration-sample.adoc
similarity index 88%
rename from doc/user/demo-client.adoc
rename to demo-client/doc/integration-sample.adoc
index 5c986360..67414e89 100644
--- a/doc/user/demo-client.adoc
+++ b/demo-client/doc/integration-sample.adoc
@@ -9,14 +9,14 @@ four identity-disclosure states an application can reach, the redirect rules the
your behalf, and the `gateway.yaml` a deployment needs so the gateway serves the application itself.
The runnable sample lives in `demo-client/` (see
-link:../../demo-client/README.adoc[`demo-client/README.adoc`] for the quickstart). How that module is
+link:../README.adoc[`demo-client/README.adoc`] for the quickstart). How that module is
built, and why its test suite is shaped as it is, is the contributor concern documented in
-link:../development/demo-client.adoc[Demo Client and the Playwright End-to-End Suite].
+link:playwright-suite.adoc[Demo Client and the Playwright End-to-End Suite].
The field-level contract for every key named here is
-link:../configuration.adoc[Configuration Reference]; the setup guides for the two session variants are
-link:bff-session.adoc[Server-Session BFF -- Operator Setup] and
-link:bff-cookie.adoc[Cookie-Based BFF -- Operator Setup]. This document is the *sample*, not a second
+link:../../doc/configuration.adoc[Configuration Reference]; the setup guides for the two session variants are
+link:../../doc/user/bff-session.adoc[Server-Session BFF -- Operator Setup] and
+link:../../doc/user/bff-cookie.adoc[Cookie-Based BFF -- Operator Setup]. This document is the *sample*, not a second
copy of either.
== The Browser-Facing Contract
@@ -80,6 +80,7 @@ what makes the whole contract reachable with a plain same-origin `fetch`.
Two properties of this table are worth stating explicitly, because assuming otherwise is the most
common way to mis-integrate:
+[#_the_401_versus_302_split]
* *The `401`-versus-`302` split is PATH-based, not `Accept`-based.* Both reserved endpoints are
blind to the request's `Accept` header. `/auth/userinfo` is an XHR probe and answers
`401 application/problem+json` without a session -- it never redirects, whatever you send. Do not
@@ -104,6 +105,13 @@ The whole leg is navigations the browser performs; your application starts it an
Tokens never reach the browser at any point: the gateway holds them, and the application's only
credential is the `HttpOnly` session cookie it cannot read.
+image::../../doc/resources/diagrams/demo-client-login-flow.svg[Demo-client login flow as the single-page application sees it, align=center]
+
+The diagram draws only the legs your application participates in. The identity-provider interior --
+the authorization request, the user authenticating, and the gateway's back-channel code exchange --
+is deliberately not redrawn here; it is the same handshake both BFF variants perform, drawn once in
+link:../../doc/resources/diagrams/bff-login-sequence.svg[the BFF login handshake diagram].
+
[NOTE]
.What you must configure at the identity provider
====
@@ -135,7 +143,7 @@ What this means for you as an integrator: *treat access logs and `Referer`-beari
callback path as sensitive*, and do not add third-party analytics or error reporting that captures
full URLs on `/auth/callback`. Nothing else in your application is affected -- the code never
reaches your code. The full statement is in
-link:../architecture.adoc#_oidc_callback_response_mode[Architecture -- OIDC callback response mode].
+link:../../doc/architecture.adoc#_oidc_callback_response_mode[Architecture -- OIDC callback response mode].
====
== The Four Identity-Disclosure States
@@ -171,6 +179,8 @@ Against the sample's `allowed_claims: ["sub", "preferred_username", "email", "gr
because the operator did not allow it.
|===
+image::../../doc/resources/diagrams/demo-client-userinfo-probe.svg[Demo-client userinfo probe and the four identity-disclosure states, align=center]
+
The last row is the one to internalise. *The operator -- never the browser -- widens disclosure.* A
claim outside `allowed_claims` can never be disclosed even when the identity provider issued it, and
naming one explicitly is rejected `403` before any disclosure happens. An empty allowlist discloses
@@ -219,11 +229,12 @@ with a live session redirects straight to the validated return URL. No new login
started and no binding cookie is set. An application may therefore link to `/auth/login`
unconditionally without worrying about spurious re-authentication.
+[#_serving_your_application_from_the_gateway]
== Serving Your Application from the Gateway
The sample is served through the asset terminal action
-(link:../adr/0014-asset-serving-terminal-action.adoc[ADR-0014]) under a `type: asset` /
-`access: public` anchor (link:../adr/0013-anchor-type-access-axes.adoc[ADR-0013]). The route is one
+(link:../../doc/adr/0014-asset-serving-terminal-action.adoc[ADR-0014]) under a `type: asset` /
+`access: public` anchor (link:../../doc/adr/0013-anchor-type-access-axes.adoc[ADR-0013]). The route is one
block:
[source,yaml]
@@ -249,7 +260,7 @@ Three things a deployment needs, and one it does not:
* You do *not* need a new anchor per bundle. Reusing an existing `access: public` asset anchor keeps
the anchor set -- which must stay pairwise prefix-disjoint -- unchanged.
-[IMPORTANT]
+[IMPORTANT#_no_directory_index]
====
*There is no directory index.* A request for `/assets/demo/` without a filename returns `404`. Every
entry point must name a concrete file: `/assets/demo/index.html`, not `/assets/demo/`. This applies
@@ -327,5 +338,9 @@ The sample runs unchanged against both session variants -- `session.mode: server
That is the intended property. An application is written against the reserved-path contract, and the
operator chooses the session variant on operational grounds
-(link:bff-session.adoc[server-session setup], link:bff-cookie.adoc[cookie setup]) without the
+(link:../../doc/user/bff-session.adoc[server-session setup], link:../../doc/user/bff-cookie.adoc[cookie setup]) without the
application knowing which was chosen.
+
+You are not asked to take that on trust: the Playwright suite runs the same specs against both
+variants, which is what makes "browser-observably identical" an executable assertion rather than a
+claim. link:playwright-suite.adoc#_why_the_demo_exists[Why the Demo Exists] records the mechanism.
diff --git a/doc/development/demo-client.adoc b/demo-client/doc/playwright-suite.adoc
similarity index 87%
rename from doc/development/demo-client.adoc
rename to demo-client/doc/playwright-suite.adoc
index 85f6de61..27f90aaf 100644
--- a/doc/development/demo-client.adoc
+++ b/demo-client/doc/playwright-suite.adoc
@@ -11,8 +11,8 @@ only forms that work against this gateway, and this document records which.
The operator- and integrator-facing view -- the browser-facing contract, the claim views, the asset
anchor an integrator declares in their own `gateway.yaml` -- is
-link:../user/demo-client.adoc[Demo Client -- Integration Sample]. The module's one-screen quickstart
-is link:../../demo-client/README.adoc[`demo-client/README.adoc`]. This document does not restate
+link:integration-sample.adoc[Demo Client -- Integration Sample]. The module's one-screen quickstart
+is link:../README.adoc[`demo-client/README.adoc`]. This document does not restate
either.
[IMPORTANT]
@@ -23,6 +23,7 @@ executable, and the asset route that serves it lives in the `integration-tests`
configuration -- not in any production example. See <<_the_demo_is_excluded_from_the_production_image>>.
====
+[#_why_the_demo_exists]
== Why the Demo Exists
The gateway's server-session and cookie-session BFF variants expose a browser-facing contract:
@@ -44,6 +45,16 @@ session modes -- `session.mode: server` on port 10443 and `session.mode: cookie`
as two Playwright projects that differ only in `baseURL`. "The two variants are browser-observably
identical" stops being a claim in a design document and becomes an executable assertion.
+The logout round-trip is the clearest example of what only a browser can assert. The suite drives the
+whole navigation chain and then re-probes the info endpoint, because the `401` -- not the landing page
+rendering -- is what proves the session is gone:
+
+image::../../doc/resources/diagrams/demo-client-logout-flow.svg[Demo-client logout round-trip and the 401 re-probe that proves it, align=center]
+
+Note where the authoritative step sits: the gateway destroys the local session *before* any
+identity-provider leg, so the elided round-trip cannot leave a usable local session behind even when
+it fails outright.
+
[[_why_the_java_it_suite_could_not_catch_it]]
=== The first gap was not hypothetical -- it hid a real, total login failure
@@ -58,7 +69,7 @@ Lax cookie is not sent on a cross-site POST*. A real browser therefore arrived a
without the binding cookie and was rejected `403` on the callback's "no browser-binding cookie"
branch. Every login. The fix was to drive `response_mode=query` so the callback is a top-level GET
navigation, on which a Lax cookie *is* sent -- see
-link:../architecture.adoc#_oidc_callback_response_mode[Architecture -- OIDC callback response mode]
+link:../../doc/architecture.adoc#_oidc_callback_response_mode[Architecture -- OIDC callback response mode]
for the mode, the rejected `SameSite=None` alternative, and the accepted code-in-URL tradeoff.
[IMPORTANT]
@@ -77,6 +88,7 @@ it. That limitation is now recorded on `BffKeycloakLoginFlow` itself, so the nex
the helper rather than in this document.
====
+[#_module_layout]
== Module Layout
`demo-client/` is a Maven module with `pom` and no Java. Every Java plugin is
@@ -124,7 +136,7 @@ Maven-standard source path even though no Maven plugin processes them.
== Why the Gateway Serves the SPA
The SPA is served *by the gateway itself*, through the `type: asset` / `access: public` terminal
-action decided in link:../adr/0014-asset-serving-terminal-action.adoc[ADR-0014]. It is not served by
+action decided in link:../../doc/adr/0014-asset-serving-terminal-action.adoc[ADR-0014]. It is not served by
a side-car static-file container, and that is the load-bearing decision in the whole module.
*Same-origin is what makes the contract observable.* The gateway's reserved paths are exact-match
@@ -139,13 +151,16 @@ of it.
Two consequences follow, and both are landed behaviour rather than demo choices:
-* The gateway owns the response envelope. It sets the `Content-Type` from the file extension, adds
- `X-Content-Type-Options: nosniff`, and strips any `Set-Cookie`. The SPA therefore needs no inline
- `