From d4e2554ca4d8817f296b440aa3aa68e3e0f1f013 Mon Sep 17 00:00:00 2001
From: Suheb <39208279+saubyk@users.noreply.github.com>
Date: Tue, 4 Aug 2026 18:17:19 -0700
Subject: [PATCH] Add a BTCPay Server SSO harness to the docker fixture (#1669)
* Add a BTCPay Server SSO harness to the docker fixture
BTCPay bundles RTL and runs it in single-sign-on mode, reached over an entry
path the standalone login never exercises: no password, a rotating cookie file,
an unregistered /rtl/api/authenticate/cookie URL that falls through to the
catch-all in server/utils/app.ts, and a reverse proxy in front. Regressions on
that path have previously gone unnoticed until they reached BTCPay users.
Adds an "sso" compose profile, so a plain `docker compose up -d` is unchanged:
- rtl-sso, a second RTL running with RTL_SSO=1, RTL_COOKIE_PATH and
LOGOUT_REDIRECT_LINK -- the environment block lifted verbatim from BTCPay's
own compose fragment, so this exercises the env-driven SSO path BTCPay
actually uses. A second container is required because RTL selects one
authentication mode at startup, so SSO and password login cannot coexist in
one instance.
- rtl-sso-config-init, staging rtl/RTL-Config.sso.json into a volume -- the
same copy-into-a-volume dance the standalone RTL already needs, because RTL
rewrites its config on startup.
- rtl-sso-proxy, nginx standing in for BTCPay's traefik, routing only /rtl
and /rtl/* exactly as BTCPay's router rule does. There is no prefix
stripping anywhere: RTL is built with and mounts every
route under baseHref '/rtl', so the prefix is passed through unmodified.
Everything outside /rtl 404s, so a request escaping the prefix surfaces as
a failure rather than being quietly served.
scripts/verify-sso.sh asserts the whole flow in 11 checks -- prefix routing,
CSRF token minting on the catch-all, the sha256 access-key handshake, an
authenticated node call, cookie rotation on login, and rejection of a wrong key
-- and exits non-zero so it can gate a change. bin/sso-url prints the link
BTCPay renders on its Services page. RTL_IMAGE overrides both RTL containers at
once, so a branch build gets tested through both entry paths.
BTCPay itself (postgres, nbxplorer, btcpayserver) is deliberately not included;
the README documents what that leaves untested and how to run against BTCPay's
own regtest stack when the question is BTCPay's behaviour rather than RTL's.
Also bumps the fixture's default RTL image from v0.15.8 to v0.15.10.
* Document the SSO harness in the rtl-docker-fixture skill
* Point CLAUDE.md at the BTCPay SSO harness
* Note that no CI runs on an open PR
---
.claude/skills/rtl-docker-fixture/SKILL.md | 15 +++
CLAUDE.md | 16 ++-
docker/README.md | 96 ++++++++++++++++++
docker/bin/sso-url | 39 ++++++++
docker/docker-compose.yml | 89 ++++++++++++++++-
docker/nginx/rtl-sso.conf | 46 +++++++++
docker/rtl/RTL-Config.sso.json | 30 ++++++
docker/scripts/verify-sso.sh | 110 +++++++++++++++++++++
8 files changed, 439 insertions(+), 2 deletions(-)
create mode 100755 docker/bin/sso-url
create mode 100644 docker/nginx/rtl-sso.conf
create mode 100644 docker/rtl/RTL-Config.sso.json
create mode 100755 docker/scripts/verify-sso.sh
diff --git a/.claude/skills/rtl-docker-fixture/SKILL.md b/.claude/skills/rtl-docker-fixture/SKILL.md
index a715d8db..ac333923 100644
--- a/.claude/skills/rtl-docker-fixture/SKILL.md
+++ b/.claude/skills/rtl-docker-fixture/SKILL.md
@@ -33,8 +33,23 @@ bin/ln-cli bob fwdinghistory
docker compose logs -f rtl
```
+Testing the **BTCPay Server integration** (RTL in single-sign-on mode behind a proxy) —
+behind a compose profile, so a plain `up` does not start it:
+
+```bash
+docker compose --profile sso up -d
+./scripts/verify-sso.sh # 11 assertions over the whole entry path; non-zero on failure
+open "$(bin/sso-url)" # the link BTCPay renders on its Services page
+```
+
Key facts when working with the fixture:
+- **Run `scripts/verify-sso.sh` after touching authentication, CSRF or static serving.**
+ BTCPay reaches RTL over a path the standalone login never exercises — a rotating cookie
+ file, an unregistered `/rtl/api/authenticate/cookie` URL that falls through to the
+ catch-all in `server/utils/app.ts`, and a reverse proxy. Note `GET /rtl/` is served by
+ `express.static` and mints **no** `XSRF-TOKEN`; only the catch-all does, so a client
+ entering there 403s on its first POST. That is long-standing, not a regression.
- **`scripts/seed.sh` is deterministic but not idempotent.** Every amount is fixed, so a
fresh run always produces identical state (screenshots differ only by your change) — so
**do not introduce randomness**. It refuses to run twice against an already-seeded
diff --git a/CLAUDE.md b/CLAUDE.md
index 2b869343..6fbba349 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -50,7 +50,10 @@ frequently do, since the controllers were written in parallel.
`ERESOLVE` conflict from `@fortawesome/angular-fontawesome`.
- **`npm run server` only works on Windows** — it sets `NODE_ENV` with `set X=Y&&` syntax. On
macOS/Linux use `npm run serverUbuntu`.
-- **`npm run lint` and `npm run test` must both be green before a PR.**
+- **`npm run lint` and `npm run test` must both be green before a PR.** Nothing will check
+ this for you: no build or test CI runs on an open PR. `checks.yml` fires on
+ `pull_request: closed` (i.e. on merge) and on tags/releases, and `rtlreviewbot` only on a
+ requested review or a comment — so running both locally is the only gate before merge.
- If lint reports hundreds of template "Parsing error" failures, look for a stale
**`coverage/`** directory (git-ignored Karma output). The template linter walks its HTML
report. Delete it and re-run.
@@ -91,6 +94,17 @@ Eclair, wired to RTL — for end-to-end testing across all three implementations
`docker/README.md`, or the `rtl-docker-fixture` skill in `.claude/skills/`. It is dev-only;
every credential in it is throwaway.
+The fixture also carries a **BTCPay Server SSO harness** behind a compose profile
+(`docker compose --profile sso up -d`). BTCPay bundles RTL and reaches it over a path the
+standalone login never exercises: a rotating cookie file, an unregistered
+`/rtl/api/authenticate/cookie` URL that is not a route at all and falls through to the
+catch-all in `server/utils/app.ts`, and a reverse proxy serving it under `/rtl`. Run
+`docker/scripts/verify-sso.sh` (11 assertions, exits non-zero) after touching
+authentication, CSRF or static serving — none of that path is covered by logging into the
+fixture's own RTL. One trap it encodes: `GET /rtl/` is served by `express.static`, which
+sits above the catch-all and mints no `XSRF-TOKEN`, so a client entering there gets a 403
+on its first POST. That is long-standing behaviour, not a regression.
+
Backend regression tests live in `test/backend/` (plain `node:test`, run against the
compiled `backend/`). `npm run test` compiles the backend, then runs them
(`npm run testbackend`) before the frontend Karma/Jasmine specs, so they never test stale
diff --git a/docker/README.md b/docker/README.md
index b0de051d..7cf8ccb9 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -74,6 +74,9 @@ docker compose up -d # bitcoind, alice, bob, carol, cln, eclair, rtl
./scripts/seed.sh # fund, connect, open channels, make payments
```
+To also bring up the BTCPay single-sign-on harness, add `--profile sso` — see
+[BTCPay SSO harness](#btcpay-sso-harness).
+
Then open — password `rtldev`. All five nodes (alice, bob,
carol, cln, eclair) appear in the node switcher.
@@ -118,6 +121,7 @@ bin/ln-cli bob listchannels
bin/ln-cli bob fwdinghistory # forwarding history
bin/e-cli getinfo # eclair-cli
bin/e-cli channels
+bin/sso-url # BTCPay-style SSO link (needs --profile sso)
docker compose exec cln lightning-cli --network=regtest listpeerchannels # Core Lightning
```
@@ -128,6 +132,94 @@ docker compose logs -f rtl
docker compose logs alice
```
+## BTCPay SSO harness
+
+BTCPay Server bundles RTL and runs it in single-sign-on mode, reached through a very
+different entry path than the standalone login: no password, a rotating cookie, and a
+reverse proxy in front. That path has broken before without the standalone flow
+noticing, so the fixture can reproduce it.
+
+It is behind a compose profile, so a plain `docker compose up -d` does not start it:
+
+```bash
+docker compose --profile sso up -d
+./scripts/verify-sso.sh # 11 assertions over the whole entry path
+open "$(bin/sso-url)" # or click through it yourself
+```
+
+`bin/sso-url` prints the link BTCPay renders on its Services page. Following it lands
+you in RTL already authenticated, against the `alice` node.
+
+### How the flow works
+
+```mermaid
+sequenceDiagram
+ participant B as Browser
+ participant P as rtl-sso-proxy
(stands in for traefik)
+ participant R as rtl-sso
(RTL_SSO=1)
+ participant C as .cookie
(shared volume)
+
+ R->>C: writes 64 random bytes at startup
+ Note over B: bin/sso-url reads the cookie —
BTCPay reads the same file
+ B->>P: GET /rtl/api/authenticate/cookie?access-key=
+ P->>R: same URI, prefix passed through
+ R-->>B: not a registered route → catch-all:
mints XSRF-TOKEN, serves index.html
+ B->>P: POST /rtl/api/authenticate
{ PASSWORD, sha256(access-key) }
+ P->>R:
+ R->>C: matches → rotates the cookie
+ R-->>B: JWT
+```
+
+The three services are `rtl-sso-config-init` (stages `rtl/RTL-Config.sso.json`, same
+copy-into-a-volume dance as the standalone RTL), `rtl-sso` (RTL with `RTL_SSO=1`,
+`RTL_COOKIE_PATH` and `LOGOUT_REDIRECT_LINK` — the env block is lifted verbatim from
+BTCPay's own compose fragment), and `rtl-sso-proxy` (nginx standing in for BTCPay's
+traefik). `RTL_IMAGE` overrides both RTL containers at once, so a branch build gets
+tested through both entry paths.
+
+It is a second RTL container rather than a flag on the first because RTL picks one
+authentication mode at startup — SSO and the password login cannot coexist in one
+instance. Both are up at the same time on different ports.
+
+### Things this makes visible
+
+**No prefix stripping anywhere.** RTL is built with `` and mounts
+every route under `baseHref '/rtl'`, so BTCPay's traefik — and the nginx here — pass
+`/rtl/…` through unmodified. The proxy deliberately 404s everything outside `/rtl`, so
+a request escaping the prefix shows up as a failure instead of being quietly served.
+
+**The entry URL is not a real route.** `/rtl/api/authenticate/cookie` matches nothing in
+`server/routes/shared/authenticate.ts`; it falls through to the catch-all in
+`server/utils/app.ts`, which is what mints the `XSRF-TOKEN` cookie and serves the SPA.
+The access-key is the raw cookie file content — the frontend sha256s it before posting
+and the backend compares against `sha256(cookieValue)`.
+
+**`GET /rtl/` mints no CSRF token.** That path is served by `express.static`, which
+sits *above* the catch-all, so a client entering there has no `XSRF-TOKEN` and its first
+POST gets a 403. Only the catch-all mints one. This is long-standing behaviour, not a
+regression — but it is why `verify-sso.sh` always seeds its cookie jar from the entry
+URL, and worth remembering before concluding that CSRF is broken.
+
+**The cookie is effectively single-use.** Authenticating rotates it, so a stale
+`bin/sso-url` link fails. BTCPay re-reads the file on every page render, which is why
+this is invisible in normal use.
+
+### What it does not cover
+
+BTCPay itself is not here — no postgres, nbxplorer or btcpayserver container. So this
+does not exercise BTCPay *generating* the link, its Services page, or its own upgrades.
+For that, run BTCPay's own regtest stack and point it at a local image:
+
+```bash
+# in a btcpayserver-docker checkout, after building an RTL image locally
+docker build -t shahanafarooqui/rtl:dev /path/to/RTL
+# then edit the rtl image tag in the generated docker-compose, or set it in
+# docker-compose-generator/docker-fragments/bitcoin-lnd.yml before generating
+```
+
+That tests the real composition rather than this reconstruction of it; the harness here
+is the fast everyday check.
+
## Notes and gotchas
**RTL's config.** `rtl/RTL-Config.regtest.json` is the tracked template. RTL rewrites
@@ -180,3 +272,7 @@ one LND uses, eclair never sees new blocks and channels never confirm.
## Not included
The Boltz swap service.
+
+BTCPay Server itself (postgres + nbxplorer + btcpayserver). The `sso` profile
+reproduces the entry path BTCPay uses to reach RTL without running BTCPay — see
+[BTCPay SSO harness](#btcpay-sso-harness) for what that covers and what it does not.
diff --git a/docker/bin/sso-url b/docker/bin/sso-url
new file mode 100755
index 00000000..08d92870
--- /dev/null
+++ b/docker/bin/sso-url
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+#
+# Print the BTCPay-style single-sign-on entry URL for the SSO harness.
+#
+# docker compose --profile sso up -d
+# bin/sso-url # print it
+# open "$(bin/sso-url)" # or follow it straight into RTL
+#
+# This is the link BTCPay renders on its Services page. BTCPay builds it from
+# BTCPAY_BTCEXTERNALRTL="server=/rtl/api/authenticate/cookie;cookiefile=..."
+# by reading the cookie file RTL wrote and appending it as ?access-key=. The
+# value is the raw file content: RTL's frontend sha256s it before posting
+# (src/app/app.component.ts) and the backend compares against
+# sha256(cookieValue), so no hashing happens here.
+#
+# Authenticating rotates the cookie (common.refreshCookie), so re-run this for
+# each login -- exactly as BTCPay re-reads the file on every page render.
+
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+# shellcheck disable=SC1091
+[ -f .env ] && source .env
+
+port="${RTL_SSO_PORT:-3001}"
+
+if ! docker compose --profile sso ps --status running --services 2>/dev/null | grep -qx rtl-sso; then
+ echo "rtl-sso is not running. Start it with: docker compose --profile sso up -d" >&2
+ exit 1
+fi
+
+cookie="$(docker compose --profile sso exec -T rtl-sso cat /RTL/cookie/.cookie | tr -d '\r\n')"
+
+if [ -z "$cookie" ]; then
+ echo "The cookie file /RTL/cookie/.cookie is empty. Is RTL_SSO=1 set on rtl-sso?" >&2
+ exit 1
+fi
+
+echo "http://localhost:${port}/rtl/api/authenticate/cookie?access-key=${cookie}"
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 55672171..e059ab3a 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -17,6 +17,9 @@ volumes:
eclair_data:
rtl_db:
rtl_config:
+ rtl_sso_db:
+ rtl_sso_config:
+ rtl_sso_cookie:
x-lnd: &lnd
image: polarlightning/lnd:0.20.0-beta
@@ -285,7 +288,7 @@ services:
container_name: ${COMPOSE_PROJECT_NAME}_rtl
# Defaults to the published image; override with RTL_IMAGE (e.g. a locally built
# branch image) to test unreleased changes: RTL_IMAGE=rtl:pr1625 docker compose up.
- image: ${RTL_IMAGE:-shahanafarooqui/rtl:v0.15.8}
+ image: ${RTL_IMAGE:-shahanafarooqui/rtl:v0.15.10}
restart: unless-stopped
depends_on:
rtl-config-init:
@@ -311,3 +314,87 @@ services:
- carol_data:/lnd/carol:ro
- cln_data:/cln:ro
- rtl_db:/RTL/database
+
+ # ---------------------------------------------------------------------------
+ # BTCPay Server SSO harness -- profile "sso", so a plain 'up' does not start it:
+ #
+ # docker compose --profile sso up -d
+ # bin/sso-url
+ #
+ # BTCPay bundles RTL as a service and runs it in single-sign-on mode. RTL
+ # writes a random cookie to RTL_COOKIE_PATH; BTCPay reads that file and renders
+ # a link to /rtl/api/authenticate/cookie?access-key= on its Services
+ # page. That path is not a registered route -- it falls through to RTL's
+ # catch-all, which mints the XSRF-TOKEN cookie and serves index.html, and the
+ # SPA then reads access-key from the query string and posts it as a password
+ # login. Reproducing that entry path is the whole point of this harness: it is
+ # where RTL's auth, CSRF and static-serving behaviour meet an external caller,
+ # and it has broken before without the standalone flow noticing.
+ #
+ # BTCPay itself (postgres + nbxplorer + btcpayserver) is deliberately not here.
+ # See README.md, "BTCPay SSO harness", for what that leaves untested and how to
+ # run against a real BTCPay when it matters.
+ # ---------------------------------------------------------------------------
+
+ # Same copy-into-a-volume dance as rtl-config-init above, and for the same
+ # reason: RTL rewrites its config on startup.
+ rtl-sso-config-init:
+ container_name: ${COMPOSE_PROJECT_NAME}_rtl_sso_config_init
+ profiles: ["sso"]
+ image: busybox:1.36
+ command: >
+ sh -c "cp /template/RTL-Config.sso.json /config/RTL-Config.json &&
+ chmod 644 /config/RTL-Config.json &&
+ echo 'sso config staged'"
+ volumes:
+ - ./rtl/RTL-Config.sso.json:/template/RTL-Config.sso.json:ro
+ - rtl_sso_config:/config
+
+ # A second RTL rather than a flag on the first: RTL picks one authentication
+ # mode at startup, so SSO and the password login cannot coexist in one
+ # instance. Only alice is wired up, matching BTCPay's one-node-per-RTL layout.
+ #
+ # The environment block is copied from BTCPay's own compose fragment
+ # (docker-compose-generator/docker-fragments/bitcoin-lnd.yml in
+ # btcpayserver-docker), so this exercises the env-driven SSO path BTCPay
+ # actually uses rather than the config-file equivalent -- which is why
+ # RTL-Config.sso.json leaves its SSO block zeroed and carries no multiPass.
+ #
+ # Only 'expose'd, never published: all access goes through the proxy, as it
+ # does under BTCPay.
+ rtl-sso:
+ container_name: ${COMPOSE_PROJECT_NAME}_rtl_sso
+ profiles: ["sso"]
+ image: ${RTL_IMAGE:-shahanafarooqui/rtl:v0.15.10}
+ restart: unless-stopped
+ depends_on:
+ rtl-sso-config-init:
+ condition: service_completed_successfully
+ alice:
+ condition: service_started
+ environment:
+ RTL_CONFIG_PATH: /RTL/config
+ RTL_SSO: 1
+ RTL_COOKIE_PATH: /RTL/cookie/.cookie
+ LOGOUT_REDIRECT_LINK: /server/services
+ expose:
+ - "3000"
+ volumes:
+ - rtl_sso_config:/RTL/config
+ - rtl_sso_cookie:/RTL/cookie
+ - alice_data:/lnd/alice:ro
+ - rtl_sso_db:/RTL/database
+
+ # Stands in for BTCPay's traefik. Routes only /rtl and /rtl/*, mirroring the
+ # label BTCPay puts on its RTL container; see nginx/rtl-sso.conf.
+ rtl-sso-proxy:
+ container_name: ${COMPOSE_PROJECT_NAME}_rtl_sso_proxy
+ profiles: ["sso"]
+ image: nginx:1.27-alpine
+ restart: unless-stopped
+ depends_on:
+ - rtl-sso
+ ports:
+ - "${RTL_SSO_PORT:-3001}:80"
+ volumes:
+ - ./nginx/rtl-sso.conf:/etc/nginx/conf.d/default.conf:ro
diff --git a/docker/nginx/rtl-sso.conf b/docker/nginx/rtl-sso.conf
new file mode 100644
index 00000000..314aa605
--- /dev/null
+++ b/docker/nginx/rtl-sso.conf
@@ -0,0 +1,46 @@
+# Reverse proxy in front of RTL running in BTCPay Server's single-sign-on mode.
+#
+# Stands in for the traefik instance BTCPay puts in front of its bundled RTL.
+# The location regex mirrors the router rule BTCPay labels that container with:
+#
+# Host(`${BTCPAY_HOST}`) && (Path(`/rtl`) || PathPrefix(`/rtl/`))
+#
+# so only /rtl and /rtl/* are proxied and everything else 404s here. That
+# strictness is deliberate: RTL is built with (angular.json)
+# and mounts every route under baseHref '/rtl' (server/utils/common.ts), so a
+# request that escapes the prefix is a bug the harness should surface rather
+# than quietly serve.
+#
+# The prefix is passed through unmodified -- there is no strip-prefix step to
+# get wrong, because RTL expects to see it. proxy_pass without a URI part
+# preserves the original request URI.
+
+map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+}
+
+server {
+ listen 80;
+ server_name _;
+
+ location ~ ^/rtl(/|$) {
+ proxy_pass http://rtl-sso:3000;
+
+ # RTL runs with Express 'trust proxy' enabled, so these are what it sees
+ # as the client address in its logs and rate limiting.
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # RTL's websocket lives at /rtl/api/ws and stays open for the life of the
+ # session. Without the upgrade headers it fails the handshake and the UI
+ # silently stops receiving live updates; without the long read timeout
+ # nginx drops it after 60s.
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ proxy_read_timeout 3600s;
+ }
+}
diff --git a/docker/rtl/RTL-Config.sso.json b/docker/rtl/RTL-Config.sso.json
new file mode 100644
index 00000000..59b7b1b4
--- /dev/null
+++ b/docker/rtl/RTL-Config.sso.json
@@ -0,0 +1,30 @@
+{
+ "port": "3000",
+ "defaultNodeIndex": 1,
+ "dbDirectoryPath": "/RTL/database",
+ "SSO": {
+ "rtlSSO": 0,
+ "rtlCookiePath": "",
+ "logoutRedirectLink": ""
+ },
+ "nodes": [
+ {
+ "index": 1,
+ "lnNode": "alice",
+ "lnImplementation": "LND",
+ "authentication": {
+ "macaroonPath": "/lnd/alice/data/chain/bitcoin/regtest"
+ },
+ "settings": {
+ "userPersona": "OPERATOR",
+ "themeMode": "DAY",
+ "themeColor": "PURPLE",
+ "logLevel": "ERROR",
+ "lnServerUrl": "https://alice:8080",
+ "fiatConversion": false,
+ "unannouncedChannels": false,
+ "blockExplorerUrl": "https://mempool.space"
+ }
+ }
+ ]
+}
diff --git a/docker/scripts/verify-sso.sh b/docker/scripts/verify-sso.sh
new file mode 100755
index 00000000..c9fe0e1f
--- /dev/null
+++ b/docker/scripts/verify-sso.sh
@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+#
+# Check the BTCPay SSO harness end to end.
+#
+# Walks the exact path a browser takes when an operator clicks the RTL link on
+# BTCPay Server's Services page, and asserts each step. Run it after any change
+# to authentication, CSRF or static serving -- this is the entry path BTCPay
+# uses, and none of it is covered by logging into the standalone fixture RTL.
+#
+# docker compose --profile sso up -d
+# ./scripts/verify-sso.sh
+#
+# Usage: ./scripts/verify-sso.sh (from the docker/ directory)
+#
+# Exits non-zero if any check fails, so it can gate a PR.
+
+# Deliberately no -e: a failing assertion must record itself and let the rest of
+# the checks run, rather than aborting on the first one. That means anything
+# that would normally rely on -e needs its own guard.
+set -uo pipefail
+
+cd "$(dirname "$0")/.." || exit 1
+
+if [ -f .env ]; then
+ set -a
+ # shellcheck disable=SC1091
+ source .env
+ set +a
+fi
+
+BASE="http://localhost:${RTL_SSO_PORT:-3001}"
+JAR="$(mktemp)"
+JAR2="$(mktemp)"
+trap 'rm -f "$JAR" "$JAR2"' EXIT
+
+pass=0
+fail=0
+# Both return 0 explicitly: the checks below are written as `test && ok || bad`,
+# which would also run `bad` if `ok` itself ever returned non-zero.
+ok() { echo " PASS: $1"; pass=$((pass + 1)); return 0; }
+bad() { echo " FAIL: $1"; fail=$((fail + 1)); return 0; }
+
+if ! docker compose --profile sso ps --status running --services 2>/dev/null | grep -qx rtl-sso; then
+ echo "rtl-sso is not running. Start it with: docker compose --profile sso up -d" >&2
+ exit 1
+fi
+
+cookie="$(docker compose --profile sso exec -T rtl-sso cat /RTL/cookie/.cookie | tr -d '\r\n')"
+echo "cookie: ${cookie:0:16}... (${#cookie} chars)"
+
+echo
+echo "1. the proxy routes only /rtl, mirroring BTCPay's traefik rule"
+code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/")
+[ "$code" = "404" ] && ok "GET / -> 404" || bad "GET / -> $code (want 404)"
+code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/authenticate")
+[ "$code" = "404" ] && ok "GET /api/authenticate -> 404" || bad "GET /api/authenticate -> $code (want 404)"
+code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/rtl/")
+[ "$code" = "200" ] && ok "GET /rtl/ -> 200" || bad "GET /rtl/ -> $code (want 200)"
+
+echo
+echo "2. the entry URL falls through to the catch-all, which mints the CSRF token"
+body=$(curl -s -c "$JAR" "$BASE/rtl/api/authenticate/cookie?access-key=$cookie")
+echo "$body" | grep -q '' \
+ && ok "entry URL serves the SPA shell with base href /rtl/" \
+ || bad "entry URL did not serve index.html"
+xsrf=$(awk '/XSRF-TOKEN/ {print $7}' "$JAR")
+[ -n "$xsrf" ] && ok "XSRF-TOKEN cookie minted" || bad "no XSRF-TOKEN cookie"
+grep -q '_csrf' "$JAR" && ok "_csrf cookie set" || bad "no _csrf cookie"
+
+echo
+echo "3. the SPA posts sha256(access-key) as a password login"
+hash=$(printf '%s' "$cookie" | shasum -a 256 | cut -d' ' -f1)
+resp=$(curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/rtl/api/authenticate" \
+ -H 'Content-Type: application/json' -H "X-XSRF-TOKEN: $xsrf" \
+ -d "{\"authenticateWith\":\"PASSWORD\",\"authenticationValue\":\"$hash\"}")
+token=$(echo "$resp" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("token",""))' 2>/dev/null)
+[ -n "$token" ] && ok "authenticated, JWT issued" || bad "auth failed: $resp"
+
+echo
+echo "4. the JWT reaches the node behind it"
+info=$(curl -s -b "$JAR" "$BASE/rtl/api/lnd/getinfo" -H "Authorization: Bearer $token")
+node_alias=$(echo "$info" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("alias",""))' 2>/dev/null)
+[ "$node_alias" = "alice" ] && ok "GET /rtl/api/lnd/getinfo -> alias '$node_alias'" || bad "getinfo returned: ${info:0:200}"
+
+echo
+echo "5. the cookie rotates on login, so each BTCPay page render hands out a fresh one"
+after=$(docker compose --profile sso exec -T rtl-sso cat /RTL/cookie/.cookie | tr -d '\r\n')
+[ "$after" != "$cookie" ] && ok "cookie rotated after authentication" || bad "cookie did NOT rotate"
+
+echo
+echo "6. a wrong access-key is refused"
+# The token has to come from the catch-all: GET /rtl/ is served by express.static,
+# which mints no XSRF-TOKEN, and the POST would then fail CSRF (403) before it
+# ever reached the access-key comparison this step is checking.
+curl -s -c "$JAR2" "$BASE/rtl/api/authenticate/cookie?access-key=x" > /dev/null
+x2=$(awk '/XSRF-TOKEN/ {print $7}' "$JAR2")
+badhash=$(printf '%s' "not-the-cookie-value-but-long-enough-to-pass-the-length-check" | shasum -a 256 | cut -d' ' -f1)
+code=$(curl -s -o /dev/null -w '%{http_code}' -b "$JAR2" -X POST "$BASE/rtl/api/authenticate" \
+ -H 'Content-Type: application/json' -H "X-XSRF-TOKEN: $x2" \
+ -d "{\"authenticateWith\":\"PASSWORD\",\"authenticationValue\":\"$badhash\"}")
+[ "$code" = "406" ] && ok "wrong access-key -> 406" || bad "wrong access-key -> $code (want 406)"
+
+echo
+echo "7. the standalone fixture RTL is unaffected"
+code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${RTL_PORT:-3000}/rtl/")
+[ "$code" = "200" ] && ok "standalone RTL still serving on ${RTL_PORT:-3000}" || bad "standalone RTL -> $code"
+
+echo
+echo "=== $pass passed, $fail failed ==="
+[ "$fail" -eq 0 ]