diff --git a/.github/workflows/server-lint-test.yml b/.github/workflows/server-lint-test.yml
new file mode 100644
index 0000000..b9918c1
--- /dev/null
+++ b/.github/workflows/server-lint-test.yml
@@ -0,0 +1,53 @@
+# ===============================================================
+# ๐งฉ Server Lint & Test - BFF Quality Gate
+# ===============================================================
+# - runs typecheck and Vitest for the BFF (server/)
+# - runs on PRs and pushes to main
+# ---------------------------------------------------------------
+
+name: Server Lint & Test
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ types: [opened, synchronize, ready_for_review]
+ branches: ["main"]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ lint-and-test:
+ if: github.event_name != 'pull_request' || !github.event.pull_request.draft
+ name: Lint & Test Server
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ defaults:
+ run:
+ working-directory: server
+
+ steps:
+ - name: โฌ๏ธ Checkout source
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
+ with:
+ persist-credentials: false
+ fetch-depth: 1
+
+ - name: ๐ฉ Setup Node
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: "22"
+
+ - name: ๐ฅ Install dependencies
+ run: npm ci --no-audit --no-fund
+
+ - name: ๐ Typecheck
+ run: npm run lint
+
+ - name: ๐งช Run tests with Vitest
+ run: npm run test:run
diff --git a/.gitignore b/.gitignore
index 299c3da..d77e700 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,8 +5,9 @@ server/node_modules/
# Build outputs
dist/
server/dist/
-server/public/.vite/
-server/public/assets/
+# Entire SPA build dir โ vite's outDir with emptyOutDir:true regenerates all
+# of it, including index.html; nothing under here should be tracked.
+server/public/
# Test outputs
test-results/
diff --git a/README.md b/README.md
index de658e1..3175a7f 100644
--- a/README.md
+++ b/README.md
@@ -27,30 +27,82 @@ npm install
### Development
-The client development workflow requires both the client dev server and the backend gateway:
+The app is split into three pieces that all must run for local dev:
-1. **Build the client assets:**
+- **ContextForge** (`mcpgateway`) โ the upstream FastAPI gateway. It owns
+ auth and all business data.
+- **BFF** (`server/`) โ a Fastify app that sits between the browser and
+ ContextForge. It holds the session cookie/CSRF boundary and keeps the
+ API's JWT off the browser (`server/src/index.ts`). The browser only ever
+ talks to the BFF, never directly to ContextForge.
+- **Client** (`src/`) โ this React SPA, served as static files by the BFF
+ (same-origin โ the API client always calls relative paths, see
+ `src/api/client.ts`).
+
+Bring them up in this order:
+
+1. **Start ContextForge** โ the upstream `mcp-context-forge` repo. Follow
+ its own quick-start guide:
+ https://github.com/IBM/mcp-context-forge/issues/2503
+ Note whatever port it ends up listening on for the next step.
+
+2. **Configure and start the BFF** (terminal B, this repo's `server/`):
```bash
- npm run build
+ cd server
+ cp .env.example .env
```
-2. **Start the client development server:**
+ Edit `server/.env`:
+ - `FASTAPI_URL` โ point it at whatever host:port ContextForge is
+ listening on from step 1 (`.env.example`'s default is `4444`; confirm
+ against your ContextForge run rather than assuming).
+ - `COOKIE_SECURE=false` โ needed for local HTTP; the default (`true`) is
+ for prod and silently drops the session cookie over plain HTTP.
+
+ Other values (`PORT`, `REDIS_URL`, `SESSION_TTL_SECONDS`, etc.) have
+ dev-safe defaults โ see comments in `server/.env.example`.
+ `REDIS_URL=memory://` (the default) is an in-process store, no Redis
+ process needed for local dev โ state resets on restart.
```bash
- npm run dev
+ npm install
+ npm run dev # :3000, tsx watch
```
- This starts the Vite dev server at `http://localhost:5173` with hot module replacement.
-
-3. **In another terminal, start the backend gateway:**
+3. **Build the frontend for the BFF to serve**, from the repo root:
```bash
- make dev
+ npm install
+ npm run build
```
-4. **Access the application:**
- Open your browser and navigate to `http://localhost:8000/app` to view the UI.
+ This builds the SPA into `server/public/`, which the already-running BFF
+ serves directly. Re-run `npm run build` after any frontend change โ
+ there's no HMR dev server wired to the BFF, so this build step is the
+ loop for local iteration against the real backend. (`npm run build:watch`
+ reruns it automatically on file changes.)
+
+4. **Use it.** Visit `http://localhost:3000/` โ redirects to `/app/login`
+ (unauthed) or `/app/` (authed). The login form posts through the BFF,
+ which holds the ContextForge JWT server-side and hands the browser only
+ an opaque session cookie.
+
+ Default seeded admin: `admin@example.com` / `changeme` (first login
+ forces a password change unless `PASSWORD_CHANGE_ENFORCEMENT_ENABLED=false`
+ is set in ContextForge's `.env`).
+
+> `npm run dev` (plain Vite dev server at `:5173`, no BFF in front) still
+> works for UI-only iteration, but `/api/*` calls need the BFF โ it won't
+> reach ContextForge on its own.
+
+#### Troubleshooting
+
+- **`EADDRINUSE` on `:3000`** โ stale `tsx watch` process:
+ `lsof -ti:3000 | xargs kill`, then restart `npm run dev` in `server/`.
+- **401 mid-session** โ expected; the ContextForge token hard-expires per
+ `TOKEN_EXPIRY` (default 20 min). The BFF auto-revokes the session and
+ redirects to login.
### Build
@@ -58,7 +110,7 @@ The client development workflow requires both the client dev server and the back
npm run build
```
-Builds the production bundle to `dist/`.
+Builds the SPA into `server/public/`, for the BFF to serve.
### Preview Production Build
@@ -272,8 +324,17 @@ client/
โโโ vitest.config.ts # Vitest configuration
โโโ tsconfig.json # TypeScript base config
โโโ tsconfig.app.json # TypeScript app config
-โโโ vite.config.ts # Vite configuration
-โโโ package.json # Dependencies and scripts
+โโโ vite.config.ts # Vite configuration (builds to server/public/)
+โโโ package.json # Dependencies and scripts
+โโโ server/ # BFF (Fastify): session/CSRF boundary in front of ContextForge
+ โโโ src/
+ โ โโโ index.ts # Entrypoint
+ โ โโโ config.ts # Env-driven config
+ โ โโโ plugins/ # cookie, redis, session, csrf, static
+ โ โโโ routes/ # auth/, proxy/ (catch-all to ContextForge), sse/
+ โโโ public/ # Built SPA (npm run build output), served by BFF
+ โโโ .env.example # Copy to .env and configure FASTAPI_URL etc.
+ โโโ package.json
```
## Available Scripts
diff --git a/index.html b/index.html
index 85ccbd9..170e1ad 100644
--- a/index.html
+++ b/index.html
@@ -4,7 +4,7 @@
ContextForge
-
+
diff --git a/package.json b/package.json
index f2bf38e..e9e5054 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,6 @@
"dev:e2e": "vite --base=/ --port 5173 --strictPort",
"build": "npm run generate && tsc -b && vite build",
"build:watch": "vite build --watch",
- "build:bff": "npm run generate && tsc -b && vite build --config vite.bff.config.ts",
"preview": "vite preview",
"lint": "eslint src e2e",
"lint:fix": "eslint src e2e --fix",
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..0b0f717
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/server/.env.example b/server/.env.example
new file mode 100644
index 0000000..80168df
--- /dev/null
+++ b/server/.env.example
@@ -0,0 +1,51 @@
+# BFF server config. Copy to .env and adjust for your environment.
+
+PORT=3000
+HOST=0.0.0.0
+
+# Upstream ContextForge API (FastAPI). Server-to-server only.
+FASTAPI_URL=http://127.0.0.1:4444
+
+# Must match mcpgateway's own AUTH_HEADER_NAME.
+FASTAPI_AUTH_HEADER_NAME=Authorization
+
+# memory:// = in-process store, no Redis process needed (dev only โ state is
+# lost on restart, not shared across instances). Use a real redis:// URL for
+# anything beyond a single local dev process, e.g. redis://localhost:6379/0.
+REDIS_URL=memory://
+
+# Opaque session_id -> bearer token TTL in Redis, seconds.
+SESSION_TTL_SECONDS=86400
+
+# Redis key namespace. Only needs changing if multiple BFF deployments
+# (e.g. staging and prod) ever share one Redis instance.
+REDIS_KEY_PREFIX=bff
+
+# Leave unset for a host-only cookie (recommended unless the BFF and its
+# subdomains genuinely need to share the session cookie).
+COOKIE_DOMAIN=
+# "false" is the local-HTTP dev value, and is what this file ships with so a
+# fresh `cp .env.example .env` boots against the REDIS_URL=memory:// default
+# above. Set to "true" in prod โ config.ts fails closed on COOKIE_SECURE=true
+# paired with either memory:// or an unset PUBLIC_ORIGIN/TRUST_PROXY, so a prod
+# deployment must set REDIS_URL and PUBLIC_ORIGIN (or TRUST_PROXY) alongside it.
+COOKIE_SECURE=false
+
+# Only safe behind a trusted reverse proxy that overwrites (not appends to)
+# X-Forwarded-For. Leave "false" for a directly-exposed BFF.
+TRUST_PROXY=false
+
+# Exact scheme://host the BFF is publicly reached at (e.g.
+# https://app.example.com), used for Origin-header validation on login/SSE.
+# Leave unset to derive it from the request itself โ fine for a
+# single-hostname deployment; set explicitly behind a reverse proxy where
+# that derivation isn't trustworthy (e.g. TLS-terminated without
+# TRUST_PROXY=true).
+PUBLIC_ORIGIN=
+
+# How often an open SSE connection re-checks Redis for session revocation,
+# as a fallback to the pub/sub-based instant revocation. See
+# agent-output/bff-proxy-and-sse-plan.md.
+SSE_SESSION_RECHECK_SECONDS=15
+
+LOG_LEVEL=info
diff --git a/server/package-lock.json b/server/package-lock.json
new file mode 100644
index 0000000..2995d4d
--- /dev/null
+++ b/server/package-lock.json
@@ -0,0 +1,2648 @@
+{
+ "name": "mcp-context-forge-bff",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "mcp-context-forge-bff",
+ "version": "0.1.0",
+ "dependencies": {
+ "@fastify/cookie": "^11.1.2",
+ "@fastify/csrf-protection": "^8.0.1",
+ "@fastify/redis": "^8.0.0",
+ "@fastify/reply-from": "^12.6.4",
+ "@fastify/static": "^10.1.2",
+ "fastify": "^5.11.2",
+ "fastify-plugin": "^6.0.0",
+ "ioredis": "^5.11.1",
+ "undici": "^8.10.0"
+ },
+ "devDependencies": {
+ "@types/node": "^26.1.2",
+ "tsx": "^4.23.7",
+ "typescript": "^5.9.3",
+ "vitest": "^4.1.10"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@fastify/accept-negotiator": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/@fastify/ajv-compiler": {
+ "version": "4.0.5",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.12.0",
+ "ajv-formats": "^3.0.1",
+ "fast-uri": "^3.0.0"
+ }
+ },
+ "node_modules/@fastify/cookie": {
+ "version": "11.1.2",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^2.0.0",
+ "fastify-plugin": "^6.0.0"
+ }
+ },
+ "node_modules/@fastify/csrf": {
+ "version": "8.0.1",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/@fastify/csrf-protection": {
+ "version": "8.0.1",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/csrf": "^8.0.0",
+ "@fastify/error": "^4.0.0",
+ "fastify-plugin": "^6.0.0"
+ }
+ },
+ "node_modules/@fastify/error": {
+ "version": "4.2.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/@fastify/fast-json-stringify-compiler": {
+ "version": "5.1.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "fast-json-stringify": "^7.0.0"
+ }
+ },
+ "node_modules/@fastify/forwarded": {
+ "version": "3.0.2",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/@fastify/merge-json-schemas": {
+ "version": "0.2.1",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/@fastify/proxy-addr": {
+ "version": "5.1.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/forwarded": "^3.0.0",
+ "ipaddr.js": "^2.1.0"
+ }
+ },
+ "node_modules/@fastify/redis": {
+ "version": "8.0.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "fastify-plugin": "^5.0.0",
+ "ioredis": "^5.3.2"
+ }
+ },
+ "node_modules/@fastify/redis/node_modules/fastify-plugin": {
+ "version": "5.1.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/@fastify/reply-from": {
+ "version": "12.6.4",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/error": "^4.0.0",
+ "end-of-stream": "^1.4.4",
+ "fast-content-type-parse": "^3.0.0",
+ "fast-querystring": "^1.1.2",
+ "fastify-plugin": "^6.0.0",
+ "toad-cache": "^3.7.0",
+ "undici": "^7.0.0"
+ }
+ },
+ "node_modules/@fastify/reply-from/node_modules/undici": {
+ "version": "7.29.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/@fastify/send": {
+ "version": "4.1.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@lukeed/ms": "^2.0.2",
+ "escape-html": "~1.0.3",
+ "fast-decode-uri-component": "^1.0.1",
+ "http-errors": "^2.0.0",
+ "mime": "^3"
+ }
+ },
+ "node_modules/@fastify/static": {
+ "version": "10.1.2",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/accept-negotiator": "^2.0.0",
+ "@fastify/error": "^4.0.0",
+ "@fastify/send": "^4.0.0",
+ "content-disposition": "^2.0.1",
+ "fastify-plugin": "^6.0.0",
+ "fastq": "^1.17.1",
+ "glob": "^13.0.0"
+ }
+ },
+ "node_modules/@ioredis/commands": {
+ "version": "1.10.0",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@lukeed/ms": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.142.0",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@pinojs/redact": {
+ "version": "0.4.0",
+ "license": "MIT"
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz",
+ "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.2",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz",
+ "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz",
+ "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz",
+ "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz",
+ "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz",
+ "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz",
+ "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz",
+ "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz",
+ "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz",
+ "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz",
+ "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz",
+ "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz",
+ "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/abstract-logging": {
+ "version": "2.0.1",
+ "license": "MIT"
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/atomic-sleep": {
+ "version": "1.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/avvio": {
+ "version": "9.3.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/error": "^4.0.0",
+ "fastq": "^1.17.1"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.9",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.1",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "license": "MIT"
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fast-content-type-parse": {
+ "version": "3.0.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/fast-decode-uri-component": {
+ "version": "1.0.1",
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stringify": {
+ "version": "7.0.1",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/merge-json-schemas": "^0.2.0",
+ "ajv": "^8.12.0",
+ "ajv-formats": "^3.0.1",
+ "fast-uri": "^4.0.0",
+ "json-schema-ref-resolver": "^3.0.0",
+ "rfdc": "^1.2.0"
+ }
+ },
+ "node_modules/fast-json-stringify/node_modules/fast-uri": {
+ "version": "4.1.2",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/fast-querystring": {
+ "version": "1.1.2",
+ "license": "MIT",
+ "dependencies": {
+ "fast-decode-uri-component": "^1.0.1"
+ }
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.5",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/fastify": {
+ "version": "5.11.2",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/ajv-compiler": "^4.0.5",
+ "@fastify/error": "^4.0.0",
+ "@fastify/fast-json-stringify-compiler": "^5.0.0",
+ "@fastify/proxy-addr": "^5.0.0",
+ "abstract-logging": "^2.0.1",
+ "avvio": "^9.0.0",
+ "fast-json-stringify": "^7.0.0",
+ "find-my-way": "^9.6.0",
+ "light-my-request": "^6.0.0",
+ "pino": "^9.14.0 || ^10.1.0",
+ "process-warning": "^5.0.0",
+ "rfdc": "^1.3.1",
+ "secure-json-parse": "^4.0.0",
+ "semver": "^7.6.0",
+ "toad-cache": "^3.7.0"
+ }
+ },
+ "node_modules/fastify-plugin": {
+ "version": "6.0.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/find-my-way": {
+ "version": "9.7.0",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-querystring": "^1.0.0",
+ "safe-regex2": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/glob": {
+ "version": "13.0.6",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "minimatch": "^10.2.2",
+ "minipass": "^7.1.3",
+ "path-scurry": "^2.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "license": "ISC"
+ },
+ "node_modules/ioredis": {
+ "version": "5.11.1",
+ "license": "MIT",
+ "dependencies": {
+ "@ioredis/commands": "1.10.0",
+ "cluster-key-slot": "1.1.1",
+ "debug": "4.4.3",
+ "denque": "2.1.0",
+ "redis-errors": "1.2.0",
+ "redis-parser": "3.0.0",
+ "standard-as-callback": "2.1.0"
+ },
+ "engines": {
+ "node": ">=12.22.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ioredis"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "2.5.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/json-schema-ref-resolver": {
+ "version": "3.0.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "license": "MIT"
+ },
+ "node_modules/light-my-request": {
+ "version": "6.6.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "process-warning": "^4.0.0",
+ "set-cookie-parser": "^2.6.0"
+ }
+ },
+ "node_modules/light-my-request/node_modules/cookie": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/light-my-request/node_modules/process-warning": {
+ "version": "4.0.1",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "11.5.2",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mime": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.6",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.17",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/on-exit-leak-free": {
+ "version": "2.1.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/path-scurry": {
+ "version": "2.0.2",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pino": {
+ "version": "10.3.1",
+ "license": "MIT",
+ "dependencies": {
+ "@pinojs/redact": "^0.4.0",
+ "atomic-sleep": "^1.0.0",
+ "on-exit-leak-free": "^2.1.0",
+ "pino-abstract-transport": "^3.0.0",
+ "pino-std-serializers": "^7.0.0",
+ "process-warning": "^5.0.0",
+ "quick-format-unescaped": "^4.0.3",
+ "real-require": "^0.2.0",
+ "safe-stable-stringify": "^2.3.1",
+ "sonic-boom": "^4.0.1",
+ "thread-stream": "^4.0.0"
+ },
+ "bin": {
+ "pino": "bin.js"
+ }
+ },
+ "node_modules/pino-abstract-transport": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.0.0"
+ }
+ },
+ "node_modules/pino-std-serializers": {
+ "version": "7.1.0",
+ "license": "MIT"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.25",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/process-warning": {
+ "version": "5.1.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/quick-format-unescaped": {
+ "version": "4.0.4",
+ "license": "MIT"
+ },
+ "node_modules/real-require": {
+ "version": "0.2.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12.13.0"
+ }
+ },
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ret": {
+ "version": "0.5.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rfdc": {
+ "version": "1.4.1",
+ "license": "MIT"
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.142.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.2.2",
+ "@rolldown/binding-darwin-arm64": "1.2.2",
+ "@rolldown/binding-darwin-x64": "1.2.2",
+ "@rolldown/binding-freebsd-x64": "1.2.2",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.2",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.2",
+ "@rolldown/binding-linux-arm64-musl": "1.2.2",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.2",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.2",
+ "@rolldown/binding-linux-x64-gnu": "1.2.2",
+ "@rolldown/binding-linux-x64-musl": "1.2.2",
+ "@rolldown/binding-openharmony-arm64": "1.2.2",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.2",
+ "@rolldown/binding-win32-x64-msvc": "1.2.2"
+ }
+ },
+ "node_modules/safe-regex2": {
+ "version": "5.1.1",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "ret": "~0.5.0"
+ },
+ "bin": {
+ "safe-regex2": "bin/safe-regex2.js"
+ }
+ },
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/secure-json-parse": {
+ "version": "4.1.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "license": "MIT"
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "license": "ISC"
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/sonic-boom": {
+ "version": "4.2.1",
+ "license": "MIT",
+ "dependencies": {
+ "atomic-sleep": "^1.0.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/thread-stream": {
+ "version": "4.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "real-require": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/thread-stream/node_modules/real-require": {
+ "version": "1.0.0",
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/toad-cache": {
+ "version": "3.7.4",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tsx": {
+ "version": "4.23.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici": {
+ "version": "8.10.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "8.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.23",
+ "rolldown": "~1.2.0",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "license": "ISC"
+ }
+ }
+}
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..f778290
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "mcp-context-forge-bff",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "description": "Backend For Frontend: session/CSRF boundary between the browser and the ContextForge API, keeping the API JWT off the browser.",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "scripts": {
+ "dev": "tsx watch --env-file-if-exists=.env src/index.ts",
+ "build": "tsc -p tsconfig.json",
+ "start": "node --env-file-if-exists=.env dist/index.js",
+ "test": "vitest",
+ "test:run": "vitest run",
+ "lint": "tsc -p tsconfig.json --noEmit"
+ },
+ "dependencies": {
+ "@fastify/cookie": "^11.1.2",
+ "@fastify/static": "^10.1.2",
+ "@fastify/csrf-protection": "^8.0.1",
+ "@fastify/redis": "^8.0.0",
+ "@fastify/reply-from": "^12.6.4",
+ "fastify": "^5.11.2",
+ "fastify-plugin": "^6.0.0",
+ "ioredis": "^5.11.1",
+ "undici": "^8.10.0"
+ },
+ "devDependencies": {
+ "@types/node": "^26.1.2",
+ "tsx": "^4.23.7",
+ "typescript": "^5.9.3",
+ "vitest": "^4.1.10"
+ }
+}
diff --git a/server/src/config.ts b/server/src/config.ts
new file mode 100644
index 0000000..e72ac98
--- /dev/null
+++ b/server/src/config.ts
@@ -0,0 +1,106 @@
+// Location: ./client/server/src/config.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Env-driven config for the BFF. All values have dev-safe defaults; override
+// via env in every non-local deployment (COOKIE_SECURE and FASTAPI_URL in
+// particular).
+
+function optional(name: string, fallback: string): string {
+ return process.env[name] ?? fallback;
+}
+
+// Distinct from `optional`: for values with no fallback, `KEY=` (empty string)
+// must mean "unset", not "explicitly set to empty" โ otherwise `??` downstream
+// treats "" as a real value instead of falling through.
+function optionalUnset(name: string): string | undefined {
+ return process.env[name] || undefined;
+}
+
+// RFC 7230 token chars โ blocks CR/LF/space/separators (header-injection guard).
+const HTTP_TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
+
+export const config = {
+ port: Number(optional("PORT", "3000")),
+ host: optional("HOST", "0.0.0.0"),
+
+ // Upstream ContextForge API (FastAPI). All bearer-token traffic goes here,
+ // server-to-server only โ the browser never talks to this origin directly.
+ fastapiUrl: optional("FASTAPI_URL", "http://127.0.0.1:4444"),
+
+ // Header mcpgateway reads the bearer token from โ must match its own AUTH_HEADER_NAME.
+ fastapiAuthHeaderName: optional("FASTAPI_AUTH_HEADER_NAME", "Authorization"),
+
+ // memory:// (default) = in-process store, no Redis needed โ dev only.
+ // See lib/memory-redis.ts. Use a real redis:// URL beyond a single
+ // local dev process.
+ redisUrl: optional("REDIS_URL", "memory://"),
+
+ // Opaque session_id -> { bearerToken, user } TTL in Redis. Independent of
+ // the upstream JWT's own expiry; the BFF just stops trusting a stale
+ // session key once this elapses.
+ sessionTtlSeconds: Number(optional("SESSION_TTL_SECONDS", "86400")),
+
+ // Redis key namespace, in case multiple BFF deployments (staging/prod)
+ // ever share one Redis instance.
+ redisKeyPrefix: optional("REDIS_KEY_PREFIX", "bff"),
+
+ cookieDomain: optionalUnset("COOKIE_DOMAIN"), // undefined = host-only cookie
+ cookieSecure: optional("COOKIE_SECURE", "true") === "true",
+
+ // Exact scheme://host the BFF is publicly reached at, for Origin-header
+ // validation on routes that can't use CSRF tokens (see lib/origin-guard.ts).
+ // undefined = derive from the request itself (request.protocol/host) โ
+ // fine for a single-hostname deployment, but set this explicitly behind a
+ // reverse proxy where that derivation isn't trustworthy (e.g.
+ // TLS-terminated without TRUST_PROXY=true), or where request.host can't
+ // be relied on for other reasons.
+ publicOrigin: optionalUnset("PUBLIC_ORIGIN"),
+
+ // Trust X-Forwarded-For so request.ip is the real client, not the LB. Only
+ // safe behind a trusted proxy โ default off so a direct-exposed BFF
+ // doesn't let clients forge their own IP. Opt in with TRUST_PROXY=true.
+ trustProxy: optional("TRUST_PROXY", "false") === "true",
+
+ // SPA build directory (see plugins/static.ts). undefined = default,
+ // computed relative to that plugin's own file location
+ // (`npm run build` -> server/public/). Override
+ // for a non-standard layout, or to point at a temp dir in tests.
+ publicDir: optionalUnset("PUBLIC_DIR"),
+
+ // Session-revocation re-check cadence for long-lived SSE connections
+ // (Option A from agent-output/bff-proxy-and-sse-plan.md โ bounded staleness,
+ // no pub/sub required). Revisit if instant revocation becomes a hard requirement.
+ sseSessionRecheckSeconds: Number(optional("SSE_SESSION_RECHECK_SECONDS", "15")),
+
+ logLevel: optional("LOG_LEVEL", "info"),
+} as const;
+
+// NODE_ENV isn't reliably set by the start script, so also fail closed on COOKIE_SECURE=true (prod's default).
+if (
+ config.redisUrl.startsWith("memory://") &&
+ (process.env.NODE_ENV === "production" || config.cookieSecure)
+) {
+ throw new Error("REDIS_URL=memory:// is dev-only โ set a real redis:// URL in production");
+}
+
+if (!HTTP_TOKEN_RE.test(config.fastapiAuthHeaderName)) {
+ throw new Error(
+ `FASTAPI_AUTH_HEADER_NAME "${config.fastapiAuthHeaderName}" is not a valid HTTP header token`,
+ );
+}
+
+// COOKIE_SECURE=true (prod default) with neither PUBLIC_ORIGIN nor TRUST_PROXY
+// set means origin-guard.ts derives its expected origin from request.protocol,
+// which is wrong behind a TLS-terminating proxy (it reads "http" while the
+// browser sends "https"). That silently 403s every login and SSE connection,
+// so fail fast at boot instead of at the first request.
+if (config.cookieSecure && !config.publicOrigin && !config.trustProxy) {
+ throw new Error(
+ "COOKIE_SECURE=true requires either PUBLIC_ORIGIN or TRUST_PROXY=true, " +
+ "otherwise origin-guard.ts can't validate Origin behind a reverse proxy " +
+ "(request.protocol won't reflect TLS termination). Set PUBLIC_ORIGIN to " +
+ "this deployment's exact scheme://host, or TRUST_PROXY=true if the BFF " +
+ "is directly TLS-terminated.",
+ );
+}
diff --git a/server/src/index.ts b/server/src/index.ts
new file mode 100644
index 0000000..2294db8
--- /dev/null
+++ b/server/src/index.ts
@@ -0,0 +1,55 @@
+// Location: ./client/server/src/index.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// BFF entrypoint. Plugin order matters: cookie -> redis -> session -> csrf,
+// then routes. Session/CSRF are decorators applied per-route (see
+// plugins/session.ts, plugins/csrf.ts), not global onRequest hooks, since
+// SSE routes need different CSRF treatment than the /api/* catch-all.
+
+import Fastify from "fastify";
+
+import { config } from "./config.js";
+import cookiePlugin from "./plugins/cookie.js";
+import csrfPlugin from "./plugins/csrf.js";
+import redisPlugin from "./plugins/redis.js";
+import sessionPlugin from "./plugins/session.js";
+import staticPlugin from "./plugins/static.js";
+import appRoute from "./routes/app.js";
+import loginRoute from "./routes/auth/login.js";
+import logoutRoute from "./routes/auth/logout.js";
+import sessionRoute from "./routes/auth/session.js";
+import catchAllProxyRoute from "./routes/proxy/catch-all.js";
+import { startRevocationSubscriber } from "./routes/sse/revocation-subscriber.js";
+import sseRoutes from "./routes/sse/routes.js";
+import { sseUpstreamPool } from "./lib/upstream-http-client.js";
+
+const fastify = Fastify({ logger: { level: config.logLevel }, trustProxy: config.trustProxy });
+
+await fastify.register(cookiePlugin);
+await fastify.register(redisPlugin);
+await fastify.register(sessionPlugin);
+await fastify.register(csrfPlugin);
+await fastify.register(staticPlugin);
+
+fastify.get("/healthz", async () => ({ ok: true }));
+
+await fastify.register(loginRoute);
+await fastify.register(logoutRoute);
+await fastify.register(sessionRoute);
+await fastify.register(sseRoutes);
+await fastify.register(catchAllProxyRoute);
+await fastify.register(appRoute);
+
+const revocationSubscriber = startRevocationSubscriber(fastify.log);
+fastify.addHook("onClose", async () => {
+ await revocationSubscriber.quit();
+ await sseUpstreamPool.close();
+});
+
+try {
+ await fastify.listen({ port: config.port, host: config.host });
+} catch (err) {
+ fastify.log.error(err);
+ process.exit(1);
+}
diff --git a/server/src/lib/memory-redis.ts b/server/src/lib/memory-redis.ts
new file mode 100644
index 0000000..1ac2914
--- /dev/null
+++ b/server/src/lib/memory-redis.ts
@@ -0,0 +1,92 @@
+// Location: ./client/server/src/lib/memory-redis.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Zero-dependency in-process stand-in for ioredis, selected when
+// REDIS_URL=memory:// โ dev-only convenience so `pnpm dev` needs nothing
+// running beyond FastAPI, same spirit as sqlite for `make dev`. Never use
+// in production: state is lost on restart and isn't shared across
+// processes, which defeats both session revocation and horizontal scaling.
+//
+// Store and pub/sub bus are module-level singletons so every MemoryRedis
+// instance in this process (the command client in plugins/redis.ts and the
+// dedicated subscriber in routes/sse/revocation-subscriber.ts) sees the
+// other's writes/publishes, exactly like two connections to one real Redis.
+
+import { EventEmitter } from "node:events";
+
+export const MEMORY_REDIS_URL_PREFIX = "memory://";
+
+export function isMemoryRedisUrl(url: string): boolean {
+ return url.startsWith(MEMORY_REDIS_URL_PREFIX);
+}
+
+interface StoredValue {
+ value: string;
+ expiresAt: number | null;
+}
+
+const store = new Map();
+const bus = new EventEmitter();
+bus.setMaxListeners(0);
+
+function isExpired(entry: StoredValue): boolean {
+ return entry.expiresAt !== null && entry.expiresAt <= Date.now();
+}
+
+function globToRegExp(pattern: string): RegExp {
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
+ return new RegExp(`^${escaped}$`);
+}
+
+export class MemoryRedis extends EventEmitter {
+ async get(key: string): Promise {
+ const entry = store.get(key);
+ if (!entry || isExpired(entry)) {
+ if (entry) store.delete(key);
+ return null;
+ }
+ return entry.value;
+ }
+
+ async setex(key: string, ttlSeconds: number, value: string): Promise<"OK"> {
+ store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
+ return "OK";
+ }
+
+ async del(key: string): Promise {
+ return store.delete(key) ? 1 : 0;
+ }
+
+ async publish(channel: string, message: string): Promise {
+ const before = bus.listenerCount("publish");
+ bus.emit("publish", channel, message);
+ return before;
+ }
+
+ // Mirrors ioredis's variadic signature: one or more patterns, optional
+ // trailing (err, count) callback.
+ async psubscribe(
+ ...args: Array void)>
+ ): Promise {
+ const patterns = args.filter((a): a is string => typeof a === "string");
+ const callback = args.find(
+ (a): a is (err: Error | null, count?: number) => void => typeof a === "function",
+ );
+
+ for (const pattern of patterns) {
+ const regex = globToRegExp(pattern);
+ bus.on("publish", (channel: string, message: string) => {
+ if (regex.test(channel)) this.emit("pmessage", pattern, channel, message);
+ });
+ }
+
+ callback?.(null, patterns.length);
+ return patterns.length;
+ }
+
+ async quit(): Promise<"OK"> {
+ this.removeAllListeners();
+ return "OK";
+ }
+}
diff --git a/server/src/lib/no-store.ts b/server/src/lib/no-store.ts
new file mode 100644
index 0000000..6f272c5
--- /dev/null
+++ b/server/src/lib/no-store.ts
@@ -0,0 +1,14 @@
+// Location: ./client/server/src/lib/no-store.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// mcpgateway sets these on its own protected routes; /auth/* is BFF-owned and
+// never reaches that middleware, so set them here to keep session/CSRF data out of caches.
+
+import type { FastifyReply } from "fastify";
+
+export function setNoStore(reply: FastifyReply): void {
+ reply.header("cache-control", "no-store, private");
+ reply.header("pragma", "no-cache");
+ reply.header("expires", "0");
+}
diff --git a/server/src/lib/origin-guard.ts b/server/src/lib/origin-guard.ts
new file mode 100644
index 0000000..794e636
--- /dev/null
+++ b/server/src/lib/origin-guard.ts
@@ -0,0 +1,42 @@
+// Location: ./client/server/src/lib/origin-guard.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Cross-origin guard for routes that can't use double-submit CSRF (login:
+// no CSRF cookie exists yet; SSE: EventSource can't set X-CSRF-Token).
+//
+// Origin is set by the browser and can't be overridden from script, and โ
+// unlike Sec-Fetch-Site's same-site verdict โ an exact match isn't fooled by
+// a hostile sibling origin under the same registrable domain
+// (evil.example.com vs app.example.com both report Sec-Fetch-Site:
+// same-site). So Origin is the primary check when present. But browsers
+// don't reliably send Origin on a same-origin GET (EventSource in
+// particular): Sec-Fetch-Site remains the fallback for that case rather
+// than hard-failing every GET without an Origin header.
+
+import type { FastifyRequest } from "fastify";
+
+import { config } from "../config.js";
+
+export function isCrossSiteRequest(request: FastifyRequest): boolean {
+ return request.headers["sec-fetch-site"] === "cross-site";
+}
+
+// null = no Origin header to check (caller falls back to isCrossSiteRequest).
+// config.publicOrigin, when set, is the source of truth (needed behind a
+// reverse proxy that isn't reflected in request.protocol/host โ e.g.
+// TLS-terminated without TRUST_PROXY=true). Otherwise fall back to this
+// request's own scheme://host, which is only as trustworthy as
+// trustProxy's X-Forwarded-* handling (see config.ts).
+function originMismatch(request: FastifyRequest): boolean | null {
+ const origin = request.headers.origin;
+ if (typeof origin !== "string" || !origin) return null;
+ const expected = config.publicOrigin ?? `${request.protocol}://${request.host}`;
+ return origin !== expected;
+}
+
+export function isForbiddenCrossOrigin(request: FastifyRequest): boolean {
+ const mismatch = originMismatch(request);
+ if (mismatch !== null) return mismatch;
+ return isCrossSiteRequest(request);
+}
diff --git a/server/src/lib/session-store.ts b/server/src/lib/session-store.ts
new file mode 100644
index 0000000..dbdfb28
--- /dev/null
+++ b/server/src/lib/session-store.ts
@@ -0,0 +1,109 @@
+// Location: ./client/server/src/lib/session-store.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Opaque session_id -> { bearerToken, user } in Redis. The browser only ever
+// sees the session_id (HttpOnly cookie); the bearer token never leaves the BFF.
+
+import { randomUUID } from "node:crypto";
+
+import type { FastifyReply } from "fastify";
+
+import { config } from "../config.js";
+
+export const SESSION_COOKIE_NAME = "bff_sid";
+
+// Structural subset of the ioredis client this module actually calls.
+// Avoids coupling to @fastify/redis's decorated instance type (which wraps
+// ioredis with its own generics) and lets tests pass an in-memory fake.
+export interface RedisLike {
+ get(key: string): Promise;
+ setex(key: string, ttlSeconds: number, value: string): Promise;
+ del(key: string): Promise;
+ publish(channel: string, message: string): Promise;
+}
+
+// Passthrough of the upstream API's user object (EmailUserResponse โ email,
+// full_name, is_admin, is_active, auth_provider, email_verified,
+// password_change_required, ...). The BFF doesn't interpret these fields โ
+// it stores and echoes back whatever FastAPI returned, snake_case included,
+// so the SPA's User type (client/src/auth/AuthContext.tsx) matches without
+// a translation layer that would drift as the upstream schema evolves.
+export interface SessionUser {
+ email: string;
+ [key: string]: unknown;
+}
+
+export interface SessionRecord {
+ bearerToken: string;
+ user: SessionUser;
+}
+
+export function sessionRedisKey(sessionId: string): string {
+ return `${config.redisKeyPrefix}:session:${sessionId}`;
+}
+
+/** Publish channel for cross-instance revocation (see routes/sse/revocation-subscriber.ts). */
+export function sessionRevokedChannel(sessionId: string): string {
+ return `${config.redisKeyPrefix}:session:revoked:${sessionId}`;
+}
+
+// TTL defaults to config.sessionTtlSeconds, but callers should pass the
+// upstream token's real expires_in (see routes/auth/login.ts) โ the BFF
+// session and cookie must not outlive the bearer token they wrap. A session
+// that looks valid for 24h while the JWT died in 20 minutes just means
+// every call in between silently 401s until the proxy's own revoke-on-401
+// catches it (see routes/proxy/catch-all.ts); matching the TTL up front
+// avoids that window entirely.
+export async function createSession(
+ redis: RedisLike,
+ record: SessionRecord,
+ ttlSeconds: number = config.sessionTtlSeconds,
+): Promise {
+ const sessionId = randomUUID();
+ await redis.setex(sessionRedisKey(sessionId), ttlSeconds, JSON.stringify(record));
+ return sessionId;
+}
+
+export async function getSession(
+ redis: RedisLike,
+ sessionId: string,
+): Promise {
+ const raw = await redis.get(sessionRedisKey(sessionId));
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw) as SessionRecord;
+ } catch {
+ return null;
+ }
+}
+
+export async function deleteSession(redis: RedisLike, sessionId: string): Promise {
+ await redis.del(sessionRedisKey(sessionId));
+ // Best-effort fan-out so any BFF instance holding an open SSE socket for
+ // this session aborts it promptly. No subscribers = no-op; not required
+ // for correctness (see Option A staleness re-check in the SSE proxy).
+ await redis.publish(sessionRevokedChannel(sessionId), "1");
+}
+
+export function setSessionCookie(
+ reply: FastifyReply,
+ sessionId: string,
+ maxAgeSeconds: number = config.sessionTtlSeconds,
+): void {
+ reply.setCookie(SESSION_COOKIE_NAME, sessionId, {
+ httpOnly: true,
+ secure: config.cookieSecure,
+ sameSite: "lax",
+ path: "/",
+ domain: config.cookieDomain,
+ maxAge: maxAgeSeconds,
+ });
+}
+
+export function clearSessionCookie(reply: FastifyReply): void {
+ reply.clearCookie(SESSION_COOKIE_NAME, {
+ path: "/",
+ domain: config.cookieDomain,
+ });
+}
diff --git a/server/src/lib/sse-headers.ts b/server/src/lib/sse-headers.ts
new file mode 100644
index 0000000..5cf3ae9
--- /dev/null
+++ b/server/src/lib/sse-headers.ts
@@ -0,0 +1,20 @@
+// Location: ./client/server/src/lib/sse-headers.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Response headers for a hijacked SSE passthrough. Replicates what
+// infra/nginx/nginx.conf's SSE location blocks do for the browser<->nginx
+// hop (no buffering, no compression, indefinite keep-alive) โ there's no
+// nginx sitting between the BFF and FastAPI, so the BFF has to do it itself.
+
+import type { ServerResponse } from "node:http";
+
+export function writeSseHeaders(res: ServerResponse): void {
+ res.writeHead(200, {
+ "content-type": "text/event-stream",
+ "cache-control": "no-cache",
+ connection: "keep-alive",
+ "x-accel-buffering": "no", // belt-and-suspenders if nginx ever ends up in front of the BFF too
+ });
+ res.flushHeaders?.();
+}
diff --git a/server/src/lib/upstream-auth.ts b/server/src/lib/upstream-auth.ts
new file mode 100644
index 0000000..97460f4
--- /dev/null
+++ b/server/src/lib/upstream-auth.ts
@@ -0,0 +1,14 @@
+// Location: ./client/server/src/lib/upstream-auth.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Bearer header for calls to mcpgateway โ name configurable via
+// FASTAPI_AUTH_HEADER_NAME (see config.ts), so proxy/SSE/logout stay in sync.
+
+import { config } from "../config.js";
+
+const AUTH_HEADER_KEY = config.fastapiAuthHeaderName.toLowerCase();
+
+export function upstreamAuthHeader(bearerToken: string): Record {
+ return { [AUTH_HEADER_KEY]: `Bearer ${bearerToken}` };
+}
diff --git a/server/src/lib/upstream-http-client.ts b/server/src/lib/upstream-http-client.ts
new file mode 100644
index 0000000..b0ab028
--- /dev/null
+++ b/server/src/lib/upstream-http-client.ts
@@ -0,0 +1,18 @@
+// Location: ./client/server/src/lib/upstream-http-client.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Dedicated undici pool for long-lived SSE upstream connections, separate
+// from @fastify/reply-from's pool (used by the generic /api/* catch-all).
+// SSE needs headersTimeout/bodyTimeout disabled; that must not leak into the
+// pool used for normal short-lived request/response calls.
+// See agent-output/bff-proxy-and-sse-plan.md, "Why hand-rolled ... for SSE".
+
+import { Pool } from "undici";
+
+import { config } from "../config.js";
+
+export const sseUpstreamPool = new Pool(config.fastapiUrl, {
+ headersTimeout: 0,
+ bodyTimeout: 0,
+});
diff --git a/server/src/plugins/cookie.ts b/server/src/plugins/cookie.ts
new file mode 100644
index 0000000..daab47e
--- /dev/null
+++ b/server/src/plugins/cookie.ts
@@ -0,0 +1,14 @@
+// Location: ./client/server/src/plugins/cookie.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+
+import fastifyCookie from "@fastify/cookie";
+import type { FastifyInstance } from "fastify";
+import fp from "fastify-plugin";
+
+export default fp(
+ async function cookiePlugin(fastify: FastifyInstance) {
+ await fastify.register(fastifyCookie);
+ },
+ { name: "cookiePlugin" },
+);
diff --git a/server/src/plugins/csrf.ts b/server/src/plugins/csrf.ts
new file mode 100644
index 0000000..bc7504b
--- /dev/null
+++ b/server/src/plugins/csrf.ts
@@ -0,0 +1,45 @@
+// Location: ./client/server/src/plugins/csrf.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Double-submit CSRF, browser<->BFF boundary only (BFF<->API is a
+// server-to-server bearer token, no CSRF needed there). Cookie-mode
+// (backed by @fastify/cookie, no server session store) since the BFF's own
+// session plugin is hand-rolled, not @fastify/session.
+//
+// The cookie this plugin sets (bff_csrf) holds a *secret*, not the token โ
+// it stays HttpOnly. The actual token (`reply.generateCsrf()`'s return
+// value) is handed to the SPA in the JSON body of /auth/login and
+// /auth/session and must be echoed back in the X-CSRF-Token header on
+// mutating requests. This differs from the pre-BFF pattern of reading the
+// CSRF cookie straight off `document.cookie` (mcpgateway_csrf_token was the
+// token itself, not a secret) โ that pattern doesn't fit this library's
+// secret/token split.
+//
+// Registered as a decorator (fastify.csrfProtection), applied per-route via
+// preHandler โ not globally โ so SSE routes (which can't send custom
+// headers) can opt out. See agent-output/bff-proxy-and-sse-plan.md Risk #2.
+
+import fastifyCsrf from "@fastify/csrf-protection";
+import type { FastifyInstance } from "fastify";
+import fp from "fastify-plugin";
+
+import { config } from "../config.js";
+
+export const CSRF_COOKIE_NAME = "bff_csrf";
+
+export default fp(
+ async function csrfPlugin(fastify: FastifyInstance) {
+ await fastify.register(fastifyCsrf, {
+ cookieKey: CSRF_COOKIE_NAME,
+ cookieOpts: {
+ httpOnly: true,
+ secure: config.cookieSecure,
+ sameSite: "strict",
+ path: "/",
+ domain: config.cookieDomain,
+ },
+ });
+ },
+ { name: "csrfPlugin", dependencies: ["cookiePlugin"] },
+);
diff --git a/server/src/plugins/redis.ts b/server/src/plugins/redis.ts
new file mode 100644
index 0000000..cf59a72
--- /dev/null
+++ b/server/src/plugins/redis.ts
@@ -0,0 +1,40 @@
+// Location: ./client/server/src/plugins/redis.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Decorates fastify.redis with a command client (GET/SETEX/DEL for session
+// storage, PUBLISH for revocation). The dedicated subscriber connection used
+// by SSE revocation lives separately in routes/sse/revocation-subscriber.ts โ
+// ioredis connections in subscribe mode can't issue normal commands.
+//
+// REDIS_URL=memory:// swaps in an in-process store (lib/memory-redis.ts) โ
+// dev-only, no Redis process required, same spirit as sqlite for `make dev`.
+
+import fastifyRedis from "@fastify/redis";
+import type { FastifyInstance } from "fastify";
+import fp from "fastify-plugin";
+
+import { config } from "../config.js";
+import { isMemoryRedisUrl, MemoryRedis } from "../lib/memory-redis.js";
+
+export default fp(
+ async function redisPlugin(fastify: FastifyInstance) {
+ if (isMemoryRedisUrl(config.redisUrl)) {
+ fastify.log.warn(
+ "REDIS_URL=memory:// โ using an in-process session store. Dev only: state is lost on restart and not shared across instances.",
+ );
+ const memoryRedis = new MemoryRedis();
+ fastify.decorate("redis", memoryRedis as unknown as FastifyInstance["redis"]);
+ fastify.addHook("onClose", async () => {
+ await memoryRedis.quit();
+ });
+ return;
+ }
+
+ await fastify.register(fastifyRedis, {
+ url: config.redisUrl,
+ closeClient: true,
+ });
+ },
+ { name: "redisPlugin" },
+);
diff --git a/server/src/plugins/session.ts b/server/src/plugins/session.ts
new file mode 100644
index 0000000..24661f7
--- /dev/null
+++ b/server/src/plugins/session.ts
@@ -0,0 +1,43 @@
+// Location: ./client/server/src/plugins/session.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Decorates fastify with `sessionAuth`, a preHandler that resolves the
+// session_id cookie against Redis and populates request.session. Applied
+// per-route (proxy/auth/SSE), not globally โ SSE routes need different CSRF
+// treatment, and /healthz and /auth/login must stay unauthenticated.
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
+import fp from "fastify-plugin";
+
+import { getSession, SESSION_COOKIE_NAME } from "../lib/session-store.js";
+
+async function sessionAuth(request: FastifyRequest, reply: FastifyReply): Promise {
+ const sessionId = request.cookies[SESSION_COOKIE_NAME];
+ if (!sessionId) {
+ reply.code(401).send({ error: "unauthenticated" });
+ return;
+ }
+
+ const record = await getSession(request.server.redis, sessionId);
+
+ if (!record) {
+ reply.code(401).send({ error: "session_expired" });
+ return;
+ }
+
+ request.session = { sessionId, bearerToken: record.bearerToken, user: record.user };
+}
+
+export default fp(
+ async function sessionPlugin(fastify: FastifyInstance) {
+ fastify.decorate("sessionAuth", sessionAuth);
+ },
+ { name: "sessionPlugin" },
+);
+
+declare module "fastify" {
+ interface FastifyInstance {
+ sessionAuth: typeof sessionAuth;
+ }
+}
diff --git a/server/src/plugins/static.ts b/server/src/plugins/static.ts
new file mode 100644
index 0000000..0ee491a
--- /dev/null
+++ b/server/src/plugins/static.ts
@@ -0,0 +1,65 @@
+// Location: ./client/server/src/plugins/static.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Serves the SPA build (`npm run build` from repo root -> server/public/)
+// and owns the SPA-fallback 404: any GET that isn't a real static asset or an
+// already-registered API/auth/SSE route gets the app shell, so client-side
+// routing survives a hard refresh on a deep link (/app/login, /app/tools, ...).
+// Registered with fastify-plugin so both the `sendFile` decorator and the
+// not-found handler apply at the true root, not just this plugin's own
+// encapsulated context โ routes/app.ts's GET / relies on `sendFile` too.
+//
+// The auth-aware '/' redirect itself lives in routes/app.ts, not here: this
+// plugin's fallback always serves index.html unconditionally for anything
+// under /app/*, deferring to the client router's own AuthGuard.
+
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import fastifyStatic from "@fastify/static";
+import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
+import fp from "fastify-plugin";
+
+import { config } from "../config.js";
+
+const DEFAULT_PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "../../public");
+const PUBLIC_DIR = config.publicDir ?? DEFAULT_PUBLIC_DIR;
+
+export default fp(
+ async function staticPlugin(fastify: FastifyInstance) {
+ await fastify.register(fastifyStatic, {
+ root: PUBLIC_DIR,
+ prefix: "/",
+ index: false, // '/' is handled explicitly by routes/app.ts, for the auth check
+ });
+
+ fastify.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
+ const pathname = request.url.split("?")[0] ?? request.url;
+ // Allowlist known static-asset paths instead of guessing from a
+ // trailing extension โ a trailing-dot heuristic (e.g. "has a file
+ // extension") wrongly 404s client routes like
+ // /app/reset-password/:token when the token itself contains a dot.
+ // Anything under these prefixes that reaches here is a genuinely
+ // missing build artifact; everything else is a client-router path and
+ // gets the SPA shell. Keep in sync with vite.config.ts's outDir
+ // contents and the root public/ dir it copies verbatim.
+ const isKnownAssetPath = pathname.startsWith("/assets/") || pathname === "/favicon.ico";
+
+ if (
+ request.method !== "GET" ||
+ pathname.startsWith("/api/") ||
+ pathname.startsWith("/auth/") ||
+ isKnownAssetPath
+ ) {
+ return reply.code(404).send({
+ message: `Route ${request.method}:${request.url} not found`,
+ error: "Not Found",
+ statusCode: 404,
+ });
+ }
+ return reply.sendFile("index.html");
+ });
+ },
+ { name: "staticPlugin" },
+);
diff --git a/server/src/routes/app.ts b/server/src/routes/app.ts
new file mode 100644
index 0000000..6e46da0
--- /dev/null
+++ b/server/src/routes/app.ts
@@ -0,0 +1,30 @@
+// Location: ./client/server/src/routes/app.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// The one route where the BFF decides server-side instead of leaving it to
+// client-side routing: GET / redirects to the dashboard for an authenticated
+// visitor, and to the login screen for everyone else, before any app JS
+// loads. Both targets are under /app/ because the client router
+// (client/src/router/index.tsx) hardcodes that prefix and only ever renders
+// /app/* paths โ a bare '/' matches none of its routes and would render a
+// blank page if served directly instead of redirected. /app/* itself (and
+// every other deep client route) falls through to plugins/static.ts's
+// unconditional SPA-fallback 404 handler, where the client router's own
+// AuthGuard takes over.
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
+
+import { getSession, SESSION_COOKIE_NAME } from "../lib/session-store.js";
+
+const HOME_PATH = "/app/";
+const LOGIN_PATH = "/app/login";
+
+export default async function appRoute(fastify: FastifyInstance): Promise {
+ fastify.get("/", async (request: FastifyRequest, reply: FastifyReply) => {
+ const sessionId = request.cookies[SESSION_COOKIE_NAME];
+ const record = sessionId ? await getSession(fastify.redis, sessionId) : null;
+
+ return reply.redirect(record ? HOME_PATH : LOGIN_PATH);
+ });
+}
diff --git a/server/src/routes/auth/login.ts b/server/src/routes/auth/login.ts
new file mode 100644
index 0000000..82bd5af
--- /dev/null
+++ b/server/src/routes/auth/login.ts
@@ -0,0 +1,124 @@
+// Location: ./client/server/src/routes/auth/login.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// POST /auth/login: browser -> BFF only. The BFF makes its own
+// server-to-server call to the upstream FastAPI login endpoint and never
+// forwards the resulting access_token to the browser โ only an opaque
+// session_id cookie goes back. See agent-output/microfrontend-bff-auth-architecture.md.
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
+
+import { config } from "../../config.js";
+import { createSession, setSessionCookie, type SessionUser } from "../../lib/session-store.js";
+import { setNoStore } from "../../lib/no-store.js";
+import { isForbiddenCrossOrigin } from "../../lib/origin-guard.js";
+import { CSRF_COOKIE_NAME } from "../../plugins/csrf.js";
+
+interface LoginBody {
+ email: string;
+ password: string;
+}
+
+// Mirrors mcpgateway.schemas.AuthenticationResponse. `user` is forwarded to
+// the browser verbatim (see SessionUser) โ the BFF only needs access_token
+// and expires_in.
+interface UpstreamAuthenticationResponse {
+ access_token: string;
+ expires_in: number;
+ user: SessionUser;
+}
+
+export default async function loginRoute(fastify: FastifyInstance): Promise {
+ fastify.post<{ Body: LoginBody }>(
+ "/auth/login",
+ async (request: FastifyRequest<{ Body: LoginBody }>, reply: FastifyReply) => {
+ setNoStore(reply);
+
+ if (isForbiddenCrossOrigin(request)) {
+ return reply.code(403).send({ error: "cross_site_request_forbidden" });
+ }
+
+ const { email, password } = request.body ?? {};
+ if (!email || !password) {
+ return reply.code(400).send({ error: "email and password are required" });
+ }
+
+ let upstreamResponse: Response;
+ try {
+ upstreamResponse = await fetch(`${config.fastapiUrl}/auth/email/login`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ // Preserve real client IP for upstream audit logging.
+ "x-forwarded-for": request.ip,
+ "x-real-ip": request.ip,
+ },
+ body: JSON.stringify({ email, password }),
+ });
+ } catch (err) {
+ request.log.error({ err }, "upstream login request failed");
+ return reply.code(502).send({ error: "upstream_unavailable" });
+ }
+
+ if (!upstreamResponse.ok) {
+ // Upstream 401/403/429 pass through as-is; body may carry rate-limit or
+ // lockout detail the SPA's login form wants to show.
+ const detail = await upstreamResponse.text();
+ return reply.code(upstreamResponse.status).send({ error: "login_failed", detail });
+ }
+
+ let auth: UpstreamAuthenticationResponse; // pragma: allowlist secret
+ try {
+ auth = (await upstreamResponse.json()) as UpstreamAuthenticationResponse;
+ } catch (err) {
+ request.log.error({ err }, "upstream login returned a non-JSON 2xx body");
+ return reply.code(502).send({ error: "upstream_invalid_response" });
+ }
+
+ if (typeof auth.access_token !== "string" || !auth.access_token) {
+ request.log.error({ auth }, "upstream login 2xx response missing access_token");
+ return reply.code(502).send({ error: "upstream_invalid_response" });
+ }
+
+ // The BFF session/cookie must not outlive the bearer token it wraps โ
+ // use the upstream JWT's own lifetime, not a fixed BFF-side default.
+ // See createSession's comment in lib/session-store.ts.
+ let ttlSeconds = config.sessionTtlSeconds;
+ if (Number.isFinite(auth.expires_in) && auth.expires_in > 0) {
+ ttlSeconds = auth.expires_in;
+ } else {
+ // Upstream returned a bogus expires_in โ fall back, but log it: this
+ // means the BFF session can outlive the JWT it wraps until the
+ // proxy's revoke-on-401 catches up (see session-store.ts).
+ request.log.warn(
+ { expires_in: auth.expires_in },
+ "upstream login returned invalid expires_in, using BFF default session TTL",
+ );
+ }
+
+ const sessionId = await createSession(
+ fastify.redis,
+ {
+ bearerToken: auth.access_token,
+ user: auth.user,
+ },
+ ttlSeconds,
+ );
+
+ setSessionCookie(reply, sessionId, ttlSeconds);
+ // generateCsrf() only mints a fresh secret when request.cookies has no
+ // bff_csrf entry โ reply.clearCookie() alone doesn't clear that (it
+ // only queues an outgoing Set-Cookie, request.cookies is untouched),
+ // so delete it directly to force rotation. Otherwise a secret planted
+ // before login (subdomain XSS, a plaintext hop with COOKIE_SECURE=false)
+ // survives into the authenticated session.
+ delete request.cookies[CSRF_COOKIE_NAME];
+ // Cookie holds the CSRF secret (HttpOnly); the SPA needs the derived
+ // token itself to echo back via X-CSRF-Token โ see plugins/csrf.ts.
+ const csrfToken = await reply.generateCsrf();
+
+ return reply.send({ user: auth.user, csrfToken });
+ },
+ );
+}
diff --git a/server/src/routes/auth/logout.ts b/server/src/routes/auth/logout.ts
new file mode 100644
index 0000000..d1aeb70
--- /dev/null
+++ b/server/src/routes/auth/logout.ts
@@ -0,0 +1,79 @@
+// Location: ./client/server/src/routes/auth/logout.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// POST /auth/logout: CSRF-protected like any other state-changing
+// browser->BFF call. Idempotent w.r.t. session state โ clears cookies and
+// drops the Redis session even if session_id is already missing/expired
+// (double-click or retry), as long as the caller still holds a valid CSRF
+// cookie/token pair.
+//
+// Also revokes the upstream JWT itself via FastAPI's bearer-token logout
+// (mcpgateway/routers/auth.py POST /auth/logout, blocklist-backed โ
+// DB or Redis depending on deployment). Without this, dropping the BFF's
+// own session/cookie only makes the token unreachable from the browser;
+// the JWT stays cryptographically valid until its natural TOKEN_EXPIRY.
+// Best-effort: an upstream failure (network blip, already-revoked token)
+// must not block the BFF-side logout the user is waiting on.
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
+
+import { config } from "../../config.js";
+import {
+ clearSessionCookie,
+ deleteSession,
+ getSession,
+ SESSION_COOKIE_NAME,
+} from "../../lib/session-store.js";
+import { CSRF_COOKIE_NAME } from "../../plugins/csrf.js";
+import { upstreamAuthHeader } from "../../lib/upstream-auth.js";
+import { setNoStore } from "../../lib/no-store.js";
+
+// The user is waiting on this request, so cap how long a hung (not refused)
+// upstream can hold it open.
+const UPSTREAM_REVOKE_TIMEOUT_MS = 3000;
+
+async function revokeUpstreamToken(request: FastifyRequest, bearerToken: string): Promise {
+ try {
+ const response = await fetch(`${config.fastapiUrl}/auth/logout`, {
+ method: "POST",
+ headers: upstreamAuthHeader(bearerToken),
+ signal: AbortSignal.timeout(UPSTREAM_REVOKE_TIMEOUT_MS),
+ });
+ if (!response.ok) {
+ request.log.warn(
+ { status: response.status },
+ "upstream token revocation returned a non-2xx status",
+ );
+ }
+ } catch (err) {
+ request.log.warn({ err }, "upstream token revocation failed");
+ }
+}
+
+export default async function logoutRoute(fastify: FastifyInstance): Promise {
+ fastify.post(
+ "/auth/logout",
+ { preHandler: [fastify.csrfProtection] },
+ async (request: FastifyRequest, reply: FastifyReply) => {
+ setNoStore(reply);
+
+ const sessionId = request.cookies[SESSION_COOKIE_NAME];
+ if (sessionId) {
+ const record = await getSession(fastify.redis, sessionId);
+ // Drop the BFF session first: the upstream revoke is best-effort and
+ // must not leave a live session behind if it stalls or throws.
+ await deleteSession(fastify.redis, sessionId);
+ if (record) {
+ await revokeUpstreamToken(request, record.bearerToken);
+ }
+ }
+
+ clearSessionCookie(reply);
+ // domain must match csrf.ts's setCookie or this clear is a no-op under COOKIE_DOMAIN.
+ reply.clearCookie(CSRF_COOKIE_NAME, { path: "/", domain: config.cookieDomain });
+
+ return reply.send({ ok: true });
+ },
+ );
+}
diff --git a/server/src/routes/auth/session.ts b/server/src/routes/auth/session.ts
new file mode 100644
index 0000000..fd27742
--- /dev/null
+++ b/server/src/routes/auth/session.ts
@@ -0,0 +1,30 @@
+// Location: ./client/server/src/routes/auth/session.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// GET /auth/session: SPA bootstrap probe. Never 401s past the network layer
+// with a body the app can't use โ returns { authenticated: false } for an
+// anonymous visitor so the SPA can render a login screen without treating it
+// as an error. Also (re)seeds the CSRF cookie, since a page reload needs one
+// even mid-session.
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
+
+import { getSession, SESSION_COOKIE_NAME } from "../../lib/session-store.js";
+import { setNoStore } from "../../lib/no-store.js";
+
+export default async function sessionRoute(fastify: FastifyInstance): Promise {
+ fastify.get("/auth/session", async (request: FastifyRequest, reply: FastifyReply) => {
+ setNoStore(reply);
+
+ const sessionId = request.cookies[SESSION_COOKIE_NAME];
+ const record = sessionId ? await getSession(fastify.redis, sessionId) : null;
+
+ if (!record) {
+ return reply.send({ authenticated: false });
+ }
+
+ const csrfToken = await reply.generateCsrf();
+ return reply.send({ authenticated: true, user: record.user, csrfToken });
+ });
+}
diff --git a/server/src/routes/proxy/catch-all.ts b/server/src/routes/proxy/catch-all.ts
new file mode 100644
index 0000000..841234f
--- /dev/null
+++ b/server/src/routes/proxy/catch-all.ts
@@ -0,0 +1,172 @@
+// Location: ./client/server/src/routes/proxy/catch-all.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Generic `/api/*` -> FastAPI proxy. Covers the bulk of the API surface
+// without mirroring routes: session lookup -> inject Authorization header ->
+// forward via @fastify/reply-from. Only BFF-owned auth routes and SSE routes
+// (registered separately, see routes/sse/) are excluded โ find-my-way
+// resolves their static paths before this wildcard regardless of
+// registration order, so there's no risk of this route swallowing them.
+//
+// SAFE_METHODS mirrors mcpgateway/middleware/csrf_middleware.py so the
+// browser<->BFF CSRF boundary matches the same-origin behavior it replaces.
+
+import replyFrom from "@fastify/reply-from";
+import type {
+ FastifyInstance,
+ FastifyReply,
+ FastifyRequest,
+ HookHandlerDoneFunction,
+} from "fastify";
+
+import { config } from "../../config.js";
+import { clearSessionCookie, deleteSession } from "../../lib/session-store.js";
+import { upstreamAuthHeader } from "../../lib/upstream-auth.js";
+
+const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]);
+
+// Inbound headers that must never reach upstream verbatim: bff_sid/bff_csrf
+// (Cookie) are BFF-only secrets; the rest are infra/auth headers mcpgateway
+// trusts for request-URL construction (Forwarded/X-Forwarded-*, including
+// OAuth redirect URLs) or for the bearer token itself (Authorization / the
+// configured FASTAPI_AUTH_HEADER_NAME). None of these are on the Fetch
+// spec's forbidden-header list, so a browser tab can set them via fetch()
+// directly โ strip all of them and let the BFF inject its own values below,
+// rather than only overwriting the ones we happen to already set.
+const STRIPPED_INBOUND_HEADERS = new Set([
+ "cookie",
+ "authorization",
+ "forwarded",
+ "x-forwarded-for",
+ "x-forwarded-host",
+ "x-forwarded-proto",
+ "x-forwarded-port",
+ "x-real-ip",
+]);
+
+function stripInboundHeaders(
+ headers: Record,
+): Record {
+ const authHeaderKey = config.fastapiAuthHeaderName.toLowerCase();
+ const result: Record = {};
+ for (const [key, value] of Object.entries(headers)) {
+ if (STRIPPED_INBOUND_HEADERS.has(key) || key === authHeaderKey) continue;
+ result[key] = value;
+ }
+ return result;
+}
+
+// FastAPI/Starlette 307s bare "/teams" -> "/teams/" (redirect_slashes) with an
+// absolute Location built from its own host:port. Passed through unmodified,
+// the browser would follow it straight to FastAPI โ leaking the upstream
+// origin and losing the BFF session (FastAPI has no bearer token or
+// understanding of the bff_sid cookie). Rewrite it back to a same-origin
+// /api/* path so every hop stays behind the BFF.
+function rewriteUpstreamLocation(
+ headers: Record,
+): typeof headers {
+ // Drop upstream Set-Cookie unconditionally โ mcpgateway's own jwt_token
+ // cookie must never reach the browser; the BFF session cookie is the only
+ // cookie the browser should ever see. See catch-all's own Cookie-stripping
+ // on the request side above.
+ const { "set-cookie": _dropped, ...rest } = headers;
+
+ const location = rest.location;
+ if (typeof location !== "string" || !location.startsWith(config.fastapiUrl)) {
+ return rest;
+ }
+ const upstreamPath = location.slice(config.fastapiUrl.length);
+ return { ...rest, location: `/api${upstreamPath}` };
+}
+
+// fastify.csrfProtection is callback-style (request, reply, done), not
+// promise-returning โ mirror that shape rather than mixing async/await with it.
+function csrfIfUnsafe(
+ request: FastifyRequest,
+ reply: FastifyReply,
+ done: HookHandlerDoneFunction,
+): void {
+ if (SAFE_METHODS.has(request.method)) return done();
+ request.server.csrfProtection(request, reply, done);
+}
+
+export default async function catchAllProxyRoute(fastify: FastifyInstance): Promise {
+ await fastify.register(replyFrom, { base: config.fastapiUrl });
+
+ // Fastify's default JSON parser throws FST_ERR_CTP_EMPTY_JSON_BODY on an
+ // empty body with Content-Type: application/json โ before preHandler, so
+ // before this route (or even sessionAuth) ever runs. Several real calls
+ // (e.g. the tool/gateway activate-state toggle) send that header with no
+ // body at all.
+ //
+ // This must still produce a real parsed object for a non-empty body, not
+ // a raw Buffer passthrough: @fastify/reply-from unconditionally
+ // JSON.stringify()s request.body whenever Content-Type is
+ // application/json (contentTypesToEncode always includes it, with no way
+ // to opt out โ see its index.js). A raw Buffer JSON.stringifies to
+ // `{"type":"Buffer","data":[...]}`, corrupting every JSON body sent
+ // through the proxy. So: parse for real (letting reply-from's re-encode
+ // round-trip correctly), just don't throw on empty.
+ fastify.addContentTypeParser("application/json", { parseAs: "string" }, (_req, rawBody, done) => {
+ const body = rawBody.toString();
+ if (!body) {
+ done(null, undefined);
+ return;
+ }
+ try {
+ done(null, JSON.parse(body));
+ } catch (err) {
+ done(err as Error, undefined);
+ }
+ });
+
+ fastify.all(
+ "/api/*",
+ { preHandler: [fastify.sessionAuth, csrfIfUnsafe] },
+ async (request: FastifyRequest, reply: FastifyReply) => {
+ // Wildcard capture excludes the leading '/api/'; FastAPI routes are
+ // mounted at root, so reattach a single leading slash.
+ const wildcard = (request.params as Record)["*"] ?? "";
+ const upstreamPath = `/${wildcard}`;
+ const bearerToken = request.session!.bearerToken;
+
+ const sessionId = request.session!.sessionId;
+
+ return reply.from(upstreamPath, {
+ rewriteRequestHeaders: (_req, headers) => {
+ // See STRIPPED_INBOUND_HEADERS above โ drop every inbound
+ // infra/auth header before injecting the BFF-owned bearer and IP
+ // headers, rather than only overwriting the ones we set below.
+ const forwarded = stripInboundHeaders(headers);
+ return {
+ ...forwarded,
+ ...upstreamAuthHeader(bearerToken),
+ // Preserve real client IP for upstream audit logging.
+ "x-forwarded-for": request.ip,
+ "x-real-ip": request.ip,
+ };
+ },
+ rewriteHeaders: rewriteUpstreamLocation,
+ onResponse: (req, res, upstreamResponse) => {
+ // 401 from upstream means the bearer token itself is dead
+ // (expired/invalid) โ not a permissions problem (that's 403,
+ // left alone; a valid session can still get 403s). Drop the BFF
+ // session and clear cookies now rather than let the browser keep
+ // retrying with a token that will never become valid again;
+ // its next call 401s from sessionAuth and the SPA's existing
+ // redirect-to-login handles the rest.
+ if (upstreamResponse.statusCode === 401) {
+ deleteSession(fastify.redis, sessionId).catch((err) =>
+ req.log.warn({ err, sessionId }, "failed to revoke session after upstream 401"),
+ );
+ // reply-from's onResponse types `res` generically enough (HTTP/2 union)
+ // to not structurally match FastifyReply; this app never runs HTTP/2.
+ clearSessionCookie(res as unknown as FastifyReply);
+ }
+ res.send(upstreamResponse.stream);
+ },
+ });
+ },
+ );
+}
diff --git a/server/src/routes/sse/proxy-sse.ts b/server/src/routes/sse/proxy-sse.ts
new file mode 100644
index 0000000..6632fef
--- /dev/null
+++ b/server/src/routes/sse/proxy-sse.ts
@@ -0,0 +1,126 @@
+// Location: ./client/server/src/routes/sse/proxy-sse.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Generic SSE proxy route factory. Concrete registrations live in routes.ts.
+// Hand-rolled (reply.hijack() + a dedicated undici pool) rather than
+// @fastify/reply-from โ see "Why hand-rolled ... for SSE" in
+// agent-output/bff-proxy-and-sse-plan.md: SSE needs a pool with no
+// headers/body timeouts, which must not leak into the shared catch-all pool,
+// and a first-class AbortController to register for cleanup.
+//
+// CSRF is intentionally not applied here: EventSource can't set custom
+// headers, so double-submit CSRF doesn't work for SSE. That matters because
+// upstreamMethod can be POST (see resources/subscribe) โ an exact
+// Origin-header check below (lib/origin-guard.ts) is the substitute for the
+// CSRF double-submit, same as login.ts's guard. SameSite=Lax on the session
+// cookie is not sufficient by itself: it's still sent on cross-site
+// top-level GET navigations, and the browser-facing verb here is always GET
+// even when the upstream call it triggers is a state-changing POST.
+
+import { pipeline } from "node:stream/promises";
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
+
+import { config } from "../../config.js";
+import { getSession } from "../../lib/session-store.js";
+import { sseUpstreamPool } from "../../lib/upstream-http-client.js";
+import { writeSseHeaders } from "../../lib/sse-headers.js";
+import { upstreamAuthHeader } from "../../lib/upstream-auth.js";
+import { isForbiddenCrossOrigin } from "../../lib/origin-guard.js";
+import { register, unregister } from "./registry.js";
+
+export interface SseProxyRouteOptions {
+ /** Browser-facing path, e.g. '/api/resources/subscribe'. Always GET (EventSource). */
+ browserPath: string;
+ /** Upstream FastAPI path, e.g. '/resources/subscribe'. */
+ upstreamPath: string;
+ /** Upstream verb โ independent of the browser's GET, since e.g. /resources/subscribe is POST-only upstream. */
+ upstreamMethod: "GET" | "POST";
+ /** Optional upstream request body builder, for POST upstreams that take subscription params. */
+ buildUpstreamBody?: (request: FastifyRequest) => unknown;
+}
+
+export function registerSseProxyRoute(fastify: FastifyInstance, opts: SseProxyRouteOptions): void {
+ fastify.get(
+ opts.browserPath,
+ { preHandler: [fastify.sessionAuth] },
+ async (request: FastifyRequest, reply: FastifyReply) => {
+ if (isForbiddenCrossOrigin(request)) {
+ return reply.code(403).send({ error: "cross_site_request_forbidden" });
+ }
+
+ const session = request.session!;
+ const controller = new AbortController();
+
+ const body = opts.buildUpstreamBody
+ ? JSON.stringify(opts.buildUpstreamBody(request))
+ : undefined;
+
+ let upstream;
+ try {
+ upstream = await sseUpstreamPool.request({
+ path: opts.upstreamPath,
+ method: opts.upstreamMethod,
+ headers: {
+ ...upstreamAuthHeader(session.bearerToken),
+ accept: "text/event-stream",
+ ...(body ? { "content-type": "application/json" } : {}),
+ },
+ body,
+ signal: controller.signal,
+ });
+ } catch (err) {
+ request.log.error({ err, path: opts.upstreamPath }, "sse upstream connect failed");
+ return reply.code(502).send({ error: "upstream_unavailable" });
+ }
+
+ if (upstream.statusCode >= 400) {
+ const detail = await upstream.body.text().catch(() => "");
+ return reply.code(upstream.statusCode).send({ error: "upstream_error", detail });
+ }
+
+ reply.hijack();
+ writeSseHeaders(reply.raw);
+ register(session.sessionId, controller);
+
+ let closed = false;
+ const cleanup = (): void => {
+ if (closed) return;
+ closed = true;
+ clearInterval(recheckTimer);
+ unregister(session.sessionId, controller);
+ controller.abort();
+ };
+
+ request.raw.on("close", cleanup);
+
+ // Option A (bounded-staleness): re-check the Redis session periodically
+ // and abort if it's gone, in case pub/sub revocation (Option B, see
+ // revocation-subscriber.ts) is missed for any reason. Jittered ยฑ10% so
+ // many connections opened around the same time don't all poll Redis
+ // in lockstep.
+ const jitter = 1 + (Math.random() * 0.2 - 0.1);
+ const recheckTimer = setInterval(
+ () => {
+ getSession(fastify.redis, session.sessionId)
+ .then((record) => {
+ if (!record) cleanup();
+ })
+ .catch((err) => request.log.warn({ err }, "sse session recheck failed"));
+ },
+ config.sseSessionRecheckSeconds * 1000 * jitter,
+ );
+
+ try {
+ // pipeline() handles backpressure and tears down both streams on
+ // error/abort โ no manual write()/drain() loop needed.
+ await pipeline(upstream.body, reply.raw, { signal: controller.signal });
+ } catch (err) {
+ if (!closed) request.log.debug({ err }, "sse stream ended");
+ } finally {
+ cleanup();
+ }
+ },
+ );
+}
diff --git a/server/src/routes/sse/registry.ts b/server/src/routes/sse/registry.ts
new file mode 100644
index 0000000..0aae37a
--- /dev/null
+++ b/server/src/routes/sse/registry.ts
@@ -0,0 +1,42 @@
+// Location: ./client/server/src/routes/sse/registry.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Tracks live upstream SSE sockets per session on this BFF instance, so
+// logout / revocation can abort them and browser disconnect can unregister
+// them. Keyed by a Set, not a single controller โ one session can have
+// multiple concurrent SSE subscriptions open (resources, future db-records).
+// Revocation must abort all of them. This registry is process-local by
+// design: session state lives in Redis, sockets live on whichever BFF
+// instance the browser's long-lived connection landed on.
+
+const sessionSockets = new Map>();
+
+export function register(sessionId: string, controller: AbortController): void {
+ let sockets = sessionSockets.get(sessionId);
+ if (!sockets) {
+ sockets = new Set();
+ sessionSockets.set(sessionId, sockets);
+ }
+ sockets.add(controller);
+}
+
+export function unregister(sessionId: string, controller: AbortController): void {
+ const sockets = sessionSockets.get(sessionId);
+ if (!sockets) return;
+ sockets.delete(controller);
+ if (sockets.size === 0) sessionSockets.delete(sessionId);
+}
+
+/** Abort every open SSE socket for a session (logout / revocation). */
+export function abortAll(sessionId: string): void {
+ const sockets = sessionSockets.get(sessionId);
+ if (!sockets) return;
+ for (const controller of sockets) controller.abort();
+ sessionSockets.delete(sessionId);
+}
+
+/** Test-only: count of currently-registered sockets for a session. */
+export function socketCount(sessionId: string): number {
+ return sessionSockets.get(sessionId)?.size ?? 0;
+}
diff --git a/server/src/routes/sse/revocation-subscriber.ts b/server/src/routes/sse/revocation-subscriber.ts
new file mode 100644
index 0000000..01a0fc7
--- /dev/null
+++ b/server/src/routes/sse/revocation-subscriber.ts
@@ -0,0 +1,48 @@
+// Location: ./client/server/src/routes/sse/revocation-subscriber.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Cross-instance SSE revocation (Option B from agent-output/bff-proxy-and-sse-plan.md,
+// layered on top of Option A's periodic re-check). A dedicated ioredis
+// connection in subscribe mode โ the command client decorated by
+// plugins/redis.ts can't issue normal commands once subscribed, hence the
+// separate connection here. REDIS_URL=memory:// swaps in the in-process
+// MemoryRedis (see plugins/redis.ts) instead โ irrelevant for a single dev
+// process, but kept symmetric with the command client's mode.
+
+import { Redis } from "ioredis";
+import type { FastifyBaseLogger } from "fastify";
+
+import { config } from "../../config.js";
+import { isMemoryRedisUrl, MemoryRedis } from "../../lib/memory-redis.js";
+import { abortAll } from "./registry.js";
+
+// Must stay in sync with session-store.ts's sessionRevokedChannel().
+const REVOKED_CHANNEL_PREFIX = `${config.redisKeyPrefix}:session:revoked:`;
+const REVOKED_PATTERN = `${REVOKED_CHANNEL_PREFIX}*`;
+
+function onPmessage(_pattern: string, channel: string): void {
+ const sessionId = channel.slice(REVOKED_CHANNEL_PREFIX.length);
+ if (sessionId) abortAll(sessionId);
+}
+
+export function startRevocationSubscriber(log: FastifyBaseLogger): Redis | MemoryRedis {
+ const subscriber = isMemoryRedisUrl(config.redisUrl)
+ ? new MemoryRedis()
+ : new Redis(config.redisUrl);
+
+ // Without a listener, an unhandled "error" emit crashes the process.
+ subscriber.on("error", (err) => {
+ log.warn({ err }, "revocation subscriber redis error");
+ });
+
+ subscriber.psubscribe(REVOKED_PATTERN, (err) => {
+ if (err) {
+ subscriber.emit("error", err);
+ }
+ });
+
+ subscriber.on("pmessage", onPmessage);
+
+ return subscriber;
+}
diff --git a/server/src/routes/sse/routes.ts b/server/src/routes/sse/routes.ts
new file mode 100644
index 0000000..c2ff7ce
--- /dev/null
+++ b/server/src/routes/sse/routes.ts
@@ -0,0 +1,27 @@
+// Location: ./client/server/src/routes/sse/routes.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Concrete SSE route registrations. Both go through the same
+// registerSseProxyRoute factory despite the upstream method mismatch
+// (resources/subscribe is POST-only upstream, roots/changes is GET) โ
+// proof the factory is genuinely generic, not a one-off for that mismatch.
+// Add future SSE streams (e.g. a db-records subscription) here.
+
+import type { FastifyInstance } from "fastify";
+
+import { registerSseProxyRoute } from "./proxy-sse.js";
+
+export default async function sseRoutes(fastify: FastifyInstance): Promise {
+ registerSseProxyRoute(fastify, {
+ browserPath: "/api/resources/subscribe",
+ upstreamPath: "/resources/subscribe",
+ upstreamMethod: "POST",
+ });
+
+ registerSseProxyRoute(fastify, {
+ browserPath: "/api/roots/changes",
+ upstreamPath: "/roots/changes",
+ upstreamMethod: "GET",
+ });
+}
diff --git a/server/src/types/fastify.d.ts b/server/src/types/fastify.d.ts
new file mode 100644
index 0000000..dd6b31f
--- /dev/null
+++ b/server/src/types/fastify.d.ts
@@ -0,0 +1,20 @@
+// Location: ./client/server/src/types/fastify.d.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+
+import "fastify";
+
+import type { SessionUser } from "../lib/session-store.js";
+
+export interface BffSession {
+ sessionId: string;
+ bearerToken: string;
+ user: SessionUser;
+}
+
+declare module "fastify" {
+ interface FastifyRequest {
+ /** Populated by the session preHandler. Absent on unauthenticated routes. */
+ session?: BffSession;
+ }
+}
diff --git a/server/test/app.test.ts b/server/test/app.test.ts
new file mode 100644
index 0000000..2d63dd1
--- /dev/null
+++ b/server/test/app.test.ts
@@ -0,0 +1,122 @@
+// Location: ./client/server/test/app.test.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// PUBLIC_DIR must be set before src/config.ts (and plugins/static.ts,
+// transitively) is first evaluated โ same env-ordering constraint as
+// proxy.test.ts/sse.test.ts โ so a temp SPA build dir is created and
+// process.env.PUBLIC_DIR set in beforeAll, with modules under test
+// dynamic-imported afterwards.
+
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+
+import Fastify, { type FastifyInstance } from "fastify";
+import type { Redis } from "ioredis";
+import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
+
+let publicDir: string;
+
+beforeAll(async () => {
+ publicDir = await mkdtemp(path.join(tmpdir(), "bff-public-"));
+ await writeFile(
+ path.join(publicDir, "index.html"),
+ "spa-shell-marker",
+ );
+ process.env.PUBLIC_DIR = publicDir;
+});
+
+afterAll(() => rm(publicDir, { recursive: true, force: true }));
+
+async function buildApp(): Promise<{
+ fastify: FastifyInstance;
+ redis: import("./helpers/build-app.js").FakeRedis;
+}> {
+ const { FakeRedis } = await import("./helpers/build-app.js");
+ const cookiePlugin = (await import("../src/plugins/cookie.js")).default;
+ const sessionPlugin = (await import("../src/plugins/session.js")).default;
+ const staticPlugin = (await import("../src/plugins/static.js")).default;
+ const appRoute = (await import("../src/routes/app.js")).default;
+ const catchAllProxyRoute = (await import("../src/routes/proxy/catch-all.js")).default;
+
+ const fastify = Fastify();
+ const redis = new FakeRedis();
+ fastify.decorate("redis", redis as unknown as Redis);
+ await fastify.register(cookiePlugin);
+ await fastify.register(sessionPlugin);
+ await fastify.register(staticPlugin);
+ await fastify.register(catchAllProxyRoute); // registered alongside app/static to prove /api/* isn't swallowed by the SPA fallback
+ await fastify.register(appRoute);
+
+ return { fastify, redis };
+}
+
+let app: Awaited>;
+
+afterEach(async () => {
+ await app?.fastify.close();
+});
+
+describe("GET /", () => {
+ it("redirects an anonymous visitor to /app/login", async () => {
+ app = await buildApp();
+ const response = await app.fastify.inject({ method: "GET", url: "/" });
+
+ expect(response.statusCode).toBe(302);
+ expect(response.headers.location).toBe("/app/login");
+ });
+
+ it("redirects an authenticated visitor to /app/", async () => {
+ app = await buildApp();
+ const { createSession } = await import("../src/lib/session-store.js");
+ const sessionId = await createSession(app.redis as never, {
+ bearerToken: "test-bearer-token", // pragma: allowlist secret
+ user: { email: "user@example.com", isAdmin: false },
+ });
+
+ const response = await app.fastify.inject({
+ method: "GET",
+ url: "/",
+ headers: { cookie: `bff_sid=${sessionId}` },
+ });
+
+ expect(response.statusCode).toBe(302);
+ expect(response.headers.location).toBe("/app/");
+ });
+});
+
+describe("SPA fallback (404 handler)", () => {
+ it("serves the app shell for /app/login (the client router's own auth screen)", async () => {
+ app = await buildApp();
+ const response = await app.fastify.inject({ method: "GET", url: "/app/login" });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body).toContain("spa-shell-marker");
+ });
+
+ it("serves the app shell for a deep client-side route", async () => {
+ app = await buildApp();
+ const response = await app.fastify.inject({ method: "GET", url: "/app/tools" });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body).toContain("spa-shell-marker");
+ });
+
+ it("404s a missing asset instead of serving the app shell", async () => {
+ app = await buildApp();
+ const response = await app.fastify.inject({ method: "GET", url: "/assets/does-not-exist.js" });
+
+ expect(response.statusCode).toBe(404);
+ expect(response.body).not.toContain("spa-shell-marker");
+ });
+
+ it("does not swallow /api/* into the SPA fallback", async () => {
+ app = await buildApp();
+ const response = await app.fastify.inject({ method: "GET", url: "/api/tools" });
+
+ // 401 (no session) proves the catch-all's own auth check ran, not the fallback.
+ expect(response.statusCode).toBe(401);
+ expect(response.body).not.toContain("spa-shell-marker");
+ });
+});
diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts
new file mode 100644
index 0000000..23c8594
--- /dev/null
+++ b/server/test/auth.test.ts
@@ -0,0 +1,290 @@
+// Location: ./client/server/test/auth.test.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { config } from "../src/config.js";
+import { buildTestApp, type TestApp } from "./helpers/build-app.js";
+
+function mockUpstreamLogin(ok: boolean, body: unknown, status = ok ? 200 : 401): void {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({
+ ok,
+ status,
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ })),
+ );
+}
+
+async function login(app: TestApp): Promise<{ cookies: string[]; csrfToken: string }> {
+ mockUpstreamLogin(true, {
+ access_token: "upstream-jwt", // pragma: allowlist secret
+ user: { email: "user@example.com", is_admin: false },
+ });
+
+ const response = await app.fastify.inject({
+ method: "POST",
+ url: "/auth/login",
+ payload: { email: "user@example.com", password: "secret" }, // pragma: allowlist secret
+ });
+
+ expect(response.statusCode).toBe(200);
+ const cookies = response.cookies.map((c) => `${c.name}=${c.value}`);
+ const csrfToken = response.json().csrfToken as string;
+ expect(csrfToken).toBeTruthy();
+ return { cookies, csrfToken };
+}
+
+describe("POST /auth/login", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("never returns the upstream access_token to the browser", async () => {
+ const app = await buildTestApp();
+ mockUpstreamLogin(true, {
+ access_token: "upstream-jwt", // pragma: allowlist secret
+ user: { email: "user@example.com", is_admin: false },
+ });
+
+ const response = await app.fastify.inject({
+ method: "POST",
+ url: "/auth/login",
+ payload: { email: "user@example.com", password: "secret" }, // pragma: allowlist secret
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(JSON.stringify(response.json())).not.toContain("upstream-jwt");
+ const setCookieNames = response.cookies.map((c) => c.name);
+ expect(setCookieNames).toContain("bff_sid");
+ expect(setCookieNames).toContain("bff_csrf");
+ });
+
+ it("sets the session cookie's maxAge to the upstream token's own expires_in, not a fixed BFF default", async () => {
+ const app = await buildTestApp();
+ mockUpstreamLogin(true, {
+ access_token: "upstream-jwt", // pragma: allowlist secret
+ expires_in: 1200, // 20 minutes โ FastAPI's default TOKEN_EXPIRY
+ user: { email: "user@example.com", is_admin: false },
+ });
+
+ const response = await app.fastify.inject({
+ method: "POST",
+ url: "/auth/login",
+ payload: { email: "user@example.com", password: "secret" }, // pragma: allowlist secret
+ });
+
+ const sessionCookie = response.cookies.find((c) => c.name === "bff_sid");
+ expect(sessionCookie?.maxAge).toBe(1200);
+ });
+
+ it("falls back to the BFF's default TTL if the upstream response omits expires_in", async () => {
+ const app = await buildTestApp();
+ mockUpstreamLogin(true, {
+ access_token: "upstream-jwt", // pragma: allowlist secret
+ user: { email: "user@example.com", is_admin: false },
+ });
+
+ const response = await app.fastify.inject({
+ method: "POST",
+ url: "/auth/login",
+ payload: { email: "user@example.com", password: "secret" }, // pragma: allowlist secret
+ });
+
+ const sessionCookie = response.cookies.find((c) => c.name === "bff_sid");
+ expect(sessionCookie?.maxAge).toBeGreaterThan(1200); // sanity: not accidentally near-zero
+ });
+
+ it("passes through upstream failure status without leaking a session", async () => {
+ const app = await buildTestApp();
+ mockUpstreamLogin(false, { detail: "Invalid email or password" }, 401);
+
+ const response = await app.fastify.inject({
+ method: "POST",
+ url: "/auth/login",
+ payload: { email: "user@example.com", password: "wrong" }, // pragma: allowlist secret
+ });
+
+ expect(response.statusCode).toBe(401);
+ expect(response.cookies.map((c) => c.name)).not.toContain("bff_sid");
+ });
+
+ it("rejects a request missing credentials before calling upstream", async () => {
+ const app = await buildTestApp();
+ const fetchSpy = vi.fn();
+ vi.stubGlobal("fetch", fetchSpy);
+
+ const response = await app.fastify.inject({
+ method: "POST",
+ url: "/auth/login",
+ payload: { email: "a@b.com" },
+ });
+
+ expect(response.statusCode).toBe(400);
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+});
+
+describe("GET /auth/session", () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("reports unauthenticated with no error for an anonymous visitor", async () => {
+ const app = await buildTestApp();
+ const response = await app.fastify.inject({ method: "GET", url: "/auth/session" });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.json()).toEqual({ authenticated: false });
+ });
+
+ it("reports the session user and a fresh csrfToken once logged in", async () => {
+ const app = await buildTestApp();
+ const { cookies } = await login(app);
+
+ const response = await app.fastify.inject({
+ method: "GET",
+ url: "/auth/session",
+ headers: { cookie: cookies.join("; ") },
+ });
+
+ const payload = response.json();
+ expect(payload.authenticated).toBe(true);
+ expect(payload.user.email).toBe("user@example.com");
+ expect(payload.csrfToken).toBeTruthy();
+ });
+
+ it("rotates the CSRF secret on login, so a pre-existing secret can't survive into the new session", async () => {
+ const app = await buildTestApp();
+ const first = await login(app);
+ const firstCsrfCookie = first.cookies.find((c) => c.startsWith("bff_csrf="));
+ expect(firstCsrfCookie).toBeTruthy();
+
+ // Log in again while presenting the previous login's CSRF secret cookie โ
+ // simulates a secret planted before login (subdomain cookie tossing, a
+ // plaintext hop) surviving across the login call.
+ mockUpstreamLogin(true, {
+ access_token: "upstream-jwt-2", // pragma: allowlist secret
+ user: { email: "user@example.com", is_admin: false },
+ });
+ const second = await app.fastify.inject({
+ method: "POST",
+ url: "/auth/login",
+ headers: { cookie: firstCsrfCookie! },
+ payload: { email: "user@example.com", password: "secret" }, // pragma: allowlist secret
+ });
+
+ const secondCsrfCookie = second.cookies.find((c) => c.name === "bff_csrf");
+ expect(secondCsrfCookie).toBeTruthy();
+ expect(`bff_csrf=${secondCsrfCookie!.value}`).not.toBe(firstCsrfCookie);
+ });
+});
+
+describe("POST /auth/logout", () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("rejects without a valid CSRF token", async () => {
+ const app = await buildTestApp();
+ const { cookies } = await login(app);
+
+ const response = await app.fastify.inject({
+ method: "POST",
+ url: "/auth/logout",
+ headers: { cookie: cookies.join("; ") }, // no X-CSRF-Token
+ });
+
+ expect(response.statusCode).toBe(403);
+ });
+
+ it("clears cookies and drops the Redis session given a valid CSRF token", async () => {
+ const app = await buildTestApp();
+ const { cookies, csrfToken } = await login(app);
+
+ const response = await app.fastify.inject({
+ method: "POST",
+ url: "/auth/logout",
+ headers: { cookie: cookies.join("; "), "x-csrf-token": csrfToken },
+ });
+
+ expect(response.statusCode).toBe(200);
+ const cleared = response.cookies.find((c) => c.name === "bff_sid");
+ expect(cleared?.value).toBe("");
+
+ // Session is really gone, not just the cookie cleared client-side.
+ const followUp = await app.fastify.inject({
+ method: "GET",
+ url: "/auth/session",
+ headers: { cookie: cookies.join("; ") },
+ });
+ expect(followUp.json()).toEqual({ authenticated: false });
+ });
+
+ it("is safe to call twice (idempotent) given a still-valid CSRF pair", async () => {
+ const app = await buildTestApp();
+ const { cookies, csrfToken } = await login(app);
+ const headers = { cookie: cookies.join("; "), "x-csrf-token": csrfToken };
+
+ const first = await app.fastify.inject({ method: "POST", url: "/auth/logout", headers });
+ const second = await app.fastify.inject({ method: "POST", url: "/auth/logout", headers });
+
+ expect(first.statusCode).toBe(200);
+ expect(second.statusCode).toBe(200);
+ });
+
+ it("revokes the upstream JWT via FastAPI's bearer-token logout, not just the BFF session", async () => {
+ const app = await buildTestApp();
+ const { cookies, csrfToken } = await login(app);
+
+ const fetchCalls: Array<{ url: string; authorization: string | undefined }> = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (url: string, init?: RequestInit) => {
+ const headers = init?.headers as Record | undefined;
+ fetchCalls.push({ url: String(url), authorization: headers?.authorization });
+ return { ok: true, status: 200, json: async () => ({}), text: async () => "" };
+ }),
+ );
+
+ const response = await app.fastify.inject({
+ method: "POST",
+ url: "/auth/logout",
+ headers: { cookie: cookies.join("; "), "x-csrf-token": csrfToken },
+ });
+
+ expect(response.statusCode).toBe(200);
+ const revokeCall = fetchCalls.find((call) => call.url === `${config.fastapiUrl}/auth/logout`);
+ expect(revokeCall).toBeTruthy();
+ // The stored bearer token, minted at login โ never a session/cookie value.
+ expect(revokeCall?.authorization).toBe("Bearer upstream-jwt");
+ });
+
+ it("still clears the BFF session even when upstream token revocation fails", async () => {
+ const app = await buildTestApp();
+ const { cookies, csrfToken } = await login(app);
+
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => {
+ throw new Error("upstream unreachable");
+ }),
+ );
+
+ const response = await app.fastify.inject({
+ method: "POST",
+ url: "/auth/logout",
+ headers: { cookie: cookies.join("; "), "x-csrf-token": csrfToken },
+ });
+
+ expect(response.statusCode).toBe(200);
+ const cleared = response.cookies.find((c) => c.name === "bff_sid");
+ expect(cleared?.value).toBe("");
+
+ const followUp = await app.fastify.inject({
+ method: "GET",
+ url: "/auth/session",
+ headers: { cookie: cookies.join("; ") },
+ });
+ expect(followUp.json()).toEqual({ authenticated: false });
+ });
+});
diff --git a/server/test/config.test.ts b/server/test/config.test.ts
new file mode 100644
index 0000000..ce55538
--- /dev/null
+++ b/server/test/config.test.ts
@@ -0,0 +1,104 @@
+// Location: ./client/server/test/config.test.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// config.ts validates itself at import time (throws on bad env), so each
+// case here mutates process.env then re-imports the fresh module via
+// vi.resetModules() rather than calling a validate() function directly.
+
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+const ENV_KEYS = [
+ "NODE_ENV",
+ "REDIS_URL",
+ "COOKIE_SECURE",
+ "PUBLIC_ORIGIN",
+ "TRUST_PROXY",
+] as const;
+
+let savedEnv: Record;
+
+beforeEach(() => {
+ savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
+});
+
+afterEach(() => {
+ for (const key of ENV_KEYS) {
+ if (savedEnv[key] === undefined) delete process.env[key];
+ else process.env[key] = savedEnv[key];
+ }
+});
+
+async function importConfig(): Promise {
+ const { config } = await import("../src/config.js");
+ return config;
+}
+
+describe("config validation", () => {
+ it("rejects REDIS_URL=memory:// in production", async () => {
+ delete process.env.COOKIE_SECURE;
+ process.env.NODE_ENV = "production";
+ process.env.REDIS_URL = "memory://";
+ process.env.TRUST_PROXY = "true"; // avoid tripping the unrelated origin-guard check
+
+ const { resetModules, run } = await freshImport();
+ await expect(run()).rejects.toThrow("REDIS_URL=memory:// is dev-only");
+ resetModules();
+ });
+
+ it("rejects REDIS_URL=memory:// when COOKIE_SECURE defaults to true, even outside production", async () => {
+ delete process.env.NODE_ENV;
+ delete process.env.COOKIE_SECURE; // defaults to "true"
+ process.env.REDIS_URL = "memory://";
+ process.env.TRUST_PROXY = "true";
+
+ const { resetModules, run } = await freshImport();
+ await expect(run()).rejects.toThrow("REDIS_URL=memory:// is dev-only");
+ resetModules();
+ });
+
+ it("allows REDIS_URL=memory:// for local dev (COOKIE_SECURE=false, no NODE_ENV)", async () => {
+ delete process.env.NODE_ENV;
+ process.env.COOKIE_SECURE = "false";
+ process.env.REDIS_URL = "memory://";
+
+ const { resetModules, run } = await freshImport();
+ await expect(run()).resolves.toBeTruthy();
+ resetModules();
+ });
+
+ it("rejects COOKIE_SECURE=true without PUBLIC_ORIGIN or TRUST_PROXY", async () => {
+ process.env.COOKIE_SECURE = "true";
+ process.env.REDIS_URL = "redis://localhost:6379";
+ delete process.env.PUBLIC_ORIGIN;
+ delete process.env.TRUST_PROXY;
+
+ const { resetModules, run } = await freshImport();
+ await expect(run()).rejects.toThrow(
+ "COOKIE_SECURE=true requires either PUBLIC_ORIGIN or TRUST_PROXY=true",
+ );
+ resetModules();
+ });
+
+ it("allows COOKIE_SECURE=true with TRUST_PROXY=true set", async () => {
+ process.env.COOKIE_SECURE = "true";
+ process.env.REDIS_URL = "redis://localhost:6379";
+ process.env.TRUST_PROXY = "true";
+ delete process.env.PUBLIC_ORIGIN;
+
+ const { resetModules, run } = await freshImport();
+ await expect(run()).resolves.toBeTruthy();
+ resetModules();
+ });
+});
+
+// vi.resetModules() alone doesn't help here because config.ts throws at
+// *import* time โ dynamic import() caches rejected promises too, so each
+// case needs both a fresh module registry AND a fresh dynamic import call.
+async function freshImport(): Promise<{ resetModules: () => void; run: () => Promise }> {
+ const { resetModules } = await import("vitest").then((v) => ({
+ resetModules: v.vi.resetModules,
+ }));
+ resetModules();
+ return { resetModules, run: importConfig };
+}
diff --git a/server/test/helpers/build-app.ts b/server/test/helpers/build-app.ts
new file mode 100644
index 0000000..a4218ee
--- /dev/null
+++ b/server/test/helpers/build-app.ts
@@ -0,0 +1,73 @@
+// Location: ./client/server/test/helpers/build-app.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Test fixture: a Fastify instance wired the same way as src/index.ts, but
+// with an in-memory fake in place of plugins/redis.ts so tests don't need a
+// real Redis instance. Only the ioredis surface the app actually touches
+// (get/setex/del/publish) is implemented.
+
+import Fastify, { type FastifyInstance } from "fastify";
+import { type Redis } from "ioredis";
+
+import cookiePlugin from "../../src/plugins/cookie.js";
+import csrfPlugin from "../../src/plugins/csrf.js";
+import sessionPlugin from "../../src/plugins/session.js";
+import loginRoute from "../../src/routes/auth/login.js";
+import logoutRoute from "../../src/routes/auth/logout.js";
+import sessionRoute from "../../src/routes/auth/session.js";
+import catchAllProxyRoute from "../../src/routes/proxy/catch-all.js";
+
+export class FakeRedis {
+ private store = new Map();
+ public published: Array<{ channel: string; message: string }> = [];
+
+ async get(key: string): Promise {
+ return this.store.has(key) ? this.store.get(key)! : null;
+ }
+
+ async setex(key: string, _ttlSeconds: number, value: string): Promise<"OK"> {
+ this.store.set(key, value);
+ return "OK";
+ }
+
+ async del(key: string): Promise {
+ return this.store.delete(key) ? 1 : 0;
+ }
+
+ async publish(channel: string, message: string): Promise {
+ this.published.push({ channel, message });
+ return 0;
+ }
+}
+
+export interface TestApp {
+ fastify: FastifyInstance;
+ redis: FakeRedis;
+}
+
+export async function buildTestApp(opts: { withProxy?: boolean } = {}): Promise {
+ const fastify = Fastify();
+ const redis = new FakeRedis();
+ fastify.decorate("redis", redis as unknown as Redis);
+
+ await fastify.register(cookiePlugin);
+ await fastify.register(sessionPlugin);
+ await fastify.register(csrfPlugin);
+
+ await fastify.register(loginRoute);
+ await fastify.register(logoutRoute);
+ await fastify.register(sessionRoute);
+
+ if (opts.withProxy) {
+ await fastify.register(catchAllProxyRoute);
+ }
+
+ await fastify.ready();
+ return { fastify, redis };
+}
+
+/** Parse `Set-Cookie` response headers into a `name=value; name2=value2` request Cookie header. */
+export function cookieHeaderFrom(setCookieHeaders: string[] | undefined): string {
+ return (setCookieHeaders ?? []).map((raw) => raw.split(";")[0]).join("; ");
+}
diff --git a/server/test/memory-redis.test.ts b/server/test/memory-redis.test.ts
new file mode 100644
index 0000000..b476c6e
--- /dev/null
+++ b/server/test/memory-redis.test.ts
@@ -0,0 +1,51 @@
+// Location: ./client/server/test/memory-redis.test.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it, vi } from "vitest";
+
+import { MemoryRedis } from "../src/lib/memory-redis.js";
+
+describe("MemoryRedis", () => {
+ it("round-trips get/setex/del", async () => {
+ const redis = new MemoryRedis();
+ expect(await redis.get("k")).toBeNull();
+
+ await redis.setex("k", 60, "v");
+ expect(await redis.get("k")).toBe("v");
+
+ expect(await redis.del("k")).toBe(1);
+ expect(await redis.get("k")).toBeNull();
+ expect(await redis.del("k")).toBe(0);
+ });
+
+ it("expires keys after their TTL", async () => {
+ vi.useFakeTimers();
+ try {
+ const redis = new MemoryRedis();
+ await redis.setex("k", 1, "v");
+ expect(await redis.get("k")).toBe("v");
+
+ vi.advanceTimersByTime(1001);
+ expect(await redis.get("k")).toBeNull();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("delivers publish() to a matching psubscribe() pattern across instances", async () => {
+ const subscriber = new MemoryRedis();
+ const publisher = new MemoryRedis();
+ const received: Array<[string, string, string]> = [];
+
+ await subscriber.psubscribe("bff:session:revoked:*");
+ subscriber.on("pmessage", (pattern: string, channel: string, message: string) => {
+ received.push([pattern, channel, message]);
+ });
+
+ await publisher.publish("bff:session:revoked:abc123", "1");
+ await publisher.publish("some:other:channel", "ignored");
+
+ expect(received).toEqual([["bff:session:revoked:*", "bff:session:revoked:abc123", "1"]]);
+ });
+});
diff --git a/server/test/proxy.test.ts b/server/test/proxy.test.ts
new file mode 100644
index 0000000..3050258
--- /dev/null
+++ b/server/test/proxy.test.ts
@@ -0,0 +1,238 @@
+// Location: ./client/server/test/proxy.test.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// FASTAPI_URL must be set before src/config.ts (and anything importing it)
+// is first evaluated, so the fake upstream server is spun up and
+// process.env.FASTAPI_URL set in beforeAll, with every module under test
+// dynamic-imported afterwards rather than statically at the top of the file.
+
+import { createServer, type IncomingMessage, type Server } from "node:http";
+import type { AddressInfo } from "node:net";
+
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+let upstream: Server;
+let upstreamOrigin: string;
+let lastRequest:
+ | { path: string; authorization: string | undefined; method: string; body: string }
+ | undefined;
+
+beforeAll(async () => {
+ upstream = createServer((req: IncomingMessage, res) => {
+ const chunks: Buffer[] = [];
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
+ req.on("end", () => {
+ lastRequest = {
+ path: req.url ?? "",
+ authorization: req.headers.authorization,
+ method: req.method ?? "",
+ body: Buffer.concat(chunks).toString("utf8"),
+ };
+ // Mirrors Starlette's redirect_slashes: bare "/teams" -> "/teams/"
+ // with an absolute Location built from the upstream's own host:port.
+ if (req.url === "/teams") {
+ res.writeHead(307, { location: `${upstreamOrigin}/teams/` });
+ res.end();
+ return;
+ }
+ // Simulates an expired/invalid bearer token โ FastAPI's real
+ // rbac middleware rejects with 401 here.
+ if (req.url === "/expired") {
+ res.writeHead(401, { "content-type": "application/json" });
+ res.end(JSON.stringify({ detail: "Token has expired" }));
+ return;
+ }
+ // Simulates a valid session with insufficient RBAC permissions โ
+ // must not be treated the same as an expired token.
+ if (req.url === "/forbidden") {
+ res.writeHead(403, { "content-type": "application/json" });
+ res.end(JSON.stringify({ detail: "Insufficient permissions" }));
+ return;
+ }
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify({ ok: true }));
+ });
+ });
+ await new Promise((resolve) => upstream.listen(0, "127.0.0.1", () => resolve()));
+ const { port } = upstream.address() as AddressInfo;
+ upstreamOrigin = `http://127.0.0.1:${port}`;
+ process.env.FASTAPI_URL = upstreamOrigin;
+});
+
+afterAll(() => new Promise((resolve) => upstream.close(() => resolve())));
+
+async function buildApp() {
+ const { buildTestApp } = await import("./helpers/build-app.js");
+ return buildTestApp({ withProxy: true });
+}
+
+async function seedSession(app: Awaited>) {
+ const { createSession } = await import("../src/lib/session-store.js");
+ const sessionId = await createSession(app.redis as never, {
+ bearerToken: "test-bearer-token", // pragma: allowlist secret
+ user: { email: "user@example.com", isAdmin: false },
+ });
+
+ // Round-trip through /auth/session to get a real CSRF cookie + token pair
+ // tied to this Fastify instance, the same way the SPA would.
+ const sessionProbe = await app.fastify.inject({
+ method: "GET",
+ url: "/auth/session",
+ headers: { cookie: `bff_sid=${sessionId}` },
+ });
+ const csrfCookie = sessionProbe.cookies.find((c) => c.name === "bff_csrf");
+ const csrfToken = sessionProbe.json().csrfToken as string;
+
+ return {
+ cookie: `bff_sid=${sessionId}; bff_csrf=${csrfCookie?.value}`,
+ csrfToken,
+ };
+}
+
+describe("ALL /api/*", () => {
+ it("401s without a session cookie", async () => {
+ const app = await buildApp();
+ const response = await app.fastify.inject({ method: "GET", url: "/api/tools" });
+ expect(response.statusCode).toBe(401);
+ });
+
+ it("strips the /api prefix and injects Authorization for an authenticated GET", async () => {
+ const app = await buildApp();
+ const { cookie } = await seedSession(app);
+
+ const response = await app.fastify.inject({
+ method: "GET",
+ url: "/api/tools?limit=5",
+ headers: { cookie },
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(lastRequest?.path).toBe("/tools?limit=5");
+ expect(lastRequest?.authorization).toBe("Bearer test-bearer-token");
+ });
+
+ it("never lets the browser override the injected Authorization header", async () => {
+ const app = await buildApp();
+ const { cookie } = await seedSession(app);
+
+ await app.fastify.inject({
+ method: "GET",
+ url: "/api/tools",
+ headers: { cookie, authorization: "Bearer attacker-supplied-token" }, // pragma: allowlist secret
+ });
+
+ expect(lastRequest?.authorization).toBe("Bearer test-bearer-token");
+ });
+
+ it("rejects a state-changing request without a CSRF token", async () => {
+ const app = await buildApp();
+ const { cookie } = await seedSession(app);
+
+ const response = await app.fastify.inject({
+ method: "POST",
+ url: "/api/tools",
+ headers: { cookie },
+ payload: { name: "x" },
+ });
+
+ expect(response.statusCode).toBe(403);
+ });
+
+ it("forwards a state-changing request given a valid CSRF token, with the JSON body intact", async () => {
+ const app = await buildApp();
+ const { cookie, csrfToken } = await seedSession(app);
+
+ const response = await app.fastify.inject({
+ method: "POST",
+ url: "/api/tools",
+ headers: { cookie, "x-csrf-token": csrfToken },
+ payload: { name: "x" },
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(lastRequest?.method).toBe("POST");
+ // @fastify/reply-from always JSON.stringify()s request.body for
+ // Content-Type: application/json (no way to opt out โ see catch-all.ts).
+ // A naive raw-Buffer passthrough JSON.stringifies to
+ // {"type":"Buffer","data":[...]}; must round-trip as real JSON instead.
+ expect(JSON.parse(lastRequest!.body)).toEqual({ name: "x" });
+ });
+
+ it("forwards a state-changing request with Content-Type: application/json but no body (e.g. an activate/deactivate toggle)", async () => {
+ const app = await buildApp();
+ const { cookie, csrfToken } = await seedSession(app);
+
+ const response = await app.fastify.inject({
+ method: "POST",
+ url: "/api/gateways/gw-1/state?activate=false",
+ headers: { cookie, "x-csrf-token": csrfToken, "content-type": "application/json" },
+ });
+
+ // Fastify's default JSON parser 400s an empty body under this
+ // Content-Type before the request ever reaches this route โ must not
+ // regress to that (see catch-all.ts's addContentTypeParser override).
+ expect(response.statusCode).toBe(200);
+ expect(lastRequest?.method).toBe("POST");
+ expect(lastRequest?.body).toBe("");
+ });
+
+ it("rewrites an upstream redirect's absolute Location back to a same-origin /api/* path", async () => {
+ const app = await buildApp();
+ const { cookie } = await seedSession(app);
+
+ const response = await app.fastify.inject({
+ method: "GET",
+ url: "/api/teams",
+ headers: { cookie },
+ });
+
+ expect(response.statusCode).toBe(307);
+ // Must never leak the upstream host:port to the browser, or the
+ // redirect would leave the BFF and drop the session entirely.
+ expect(response.headers.location).toBe("/api/teams/");
+ });
+
+ it("revokes the BFF session when upstream returns 401 (expired/invalid bearer token)", async () => {
+ const app = await buildApp();
+ const { cookie } = await seedSession(app);
+
+ const response = await app.fastify.inject({
+ method: "GET",
+ url: "/api/expired",
+ headers: { cookie },
+ });
+
+ expect(response.statusCode).toBe(401);
+ const clearedCookie = response.cookies.find((c) => c.name === "bff_sid");
+ expect(clearedCookie?.value).toBe("");
+
+ // Not just the cookie cleared client-side โ the session is really gone,
+ // so a follow-up request can't keep retrying with a dead token.
+ const followUp = await app.fastify.inject({
+ method: "GET",
+ url: "/auth/session",
+ headers: { cookie },
+ });
+ expect(followUp.json()).toEqual({ authenticated: false });
+ });
+
+ it("does not revoke the session on a plain 403 (valid session, insufficient permissions)", async () => {
+ const app = await buildApp();
+ const { cookie } = await seedSession(app);
+
+ const response = await app.fastify.inject({
+ method: "GET",
+ url: "/api/forbidden",
+ headers: { cookie },
+ });
+ expect(response.statusCode).toBe(403);
+
+ const followUp = await app.fastify.inject({
+ method: "GET",
+ url: "/auth/session",
+ headers: { cookie },
+ });
+ expect(followUp.json().authenticated).toBe(true);
+ });
+});
diff --git a/server/test/sse.test.ts b/server/test/sse.test.ts
new file mode 100644
index 0000000..07ff7a5
--- /dev/null
+++ b/server/test/sse.test.ts
@@ -0,0 +1,131 @@
+// Location: ./client/server/test/sse.test.ts
+// Copyright contributors to the MCP-CONTEXT-FORGE project
+// SPDX-License-Identifier: Apache-2.0
+//
+// Exercises the real network path (fastify.listen + fetch), not
+// fastify.inject(), because reply.hijack() takes the response out of
+// Fastify/light-my-request's normal capture path โ inject() would hang
+// waiting for a stream that's designed to live indefinitely.
+//
+// Same env-ordering constraint as proxy.test.ts: FASTAPI_URL must be set
+// before anything importing src/config.ts (transitively, the SSE upstream
+// pool) is first evaluated, so every module under test is dynamic-imported
+// after the fake upstream server is listening.
+
+import { createServer, type Server } from "node:http";
+import type { AddressInfo } from "node:net";
+
+import Fastify, { type FastifyInstance } from "fastify";
+import type { Redis } from "ioredis";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+let upstream: Server;
+let upstreamSocketCount = 0;
+
+beforeAll(async () => {
+ upstream = createServer((req, res) => {
+ if (req.url === "/roots/changes") {
+ upstreamSocketCount += 1;
+ res.on("close", () => {
+ upstreamSocketCount -= 1;
+ });
+ res.writeHead(200, { "content-type": "text/event-stream" });
+ res.write("data: hello\n\n");
+ // Deliberately never ends โ mirrors FastAPI's indefinite SSE stream.
+ return;
+ }
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify({ ok: true, path: req.url }));
+ });
+ await new Promise((resolve) => upstream.listen(0, "127.0.0.1", () => resolve()));
+ const { port } = upstream.address() as AddressInfo;
+ process.env.FASTAPI_URL = `http://127.0.0.1:${port}`;
+ process.env.SSE_SESSION_RECHECK_SECONDS = "3600"; // keep the recheck timer out of the way of these tests
+});
+
+afterAll(() => new Promise((resolve) => upstream.close(() => resolve())));
+
+interface App {
+ fastify: FastifyInstance;
+ redis: import("./helpers/build-app.js").FakeRedis;
+ baseUrl: string;
+}
+
+async function buildRunningApp(): Promise {
+ const { FakeRedis } = await import("./helpers/build-app.js");
+ const cookiePlugin = (await import("../src/plugins/cookie.js")).default;
+ const sessionPlugin = (await import("../src/plugins/session.js")).default;
+ const sseRoutes = (await import("../src/routes/sse/routes.js")).default;
+ const catchAllProxyRoute = (await import("../src/routes/proxy/catch-all.js")).default;
+
+ const fastify = Fastify();
+ const redis = new FakeRedis();
+ fastify.decorate("redis", redis as unknown as Redis);
+ await fastify.register(cookiePlugin);
+ await fastify.register(sessionPlugin);
+ await fastify.register(sseRoutes);
+ await fastify.register(catchAllProxyRoute); // registered alongside SSE routes to prove routing precedence
+
+ await fastify.listen({ port: 0, host: "127.0.0.1" });
+ const address = fastify.server.address() as AddressInfo;
+ return { fastify, redis, baseUrl: `http://127.0.0.1:${address.port}` };
+}
+
+async function seedSessionCookie(app: App): Promise {
+ const { createSession } = await import("../src/lib/session-store.js");
+ const sessionId = await createSession(app.redis as never, {
+ bearerToken: "test-bearer-token", // pragma: allowlist secret
+ user: { email: "user@example.com", isAdmin: false },
+ });
+ return `bff_sid=${sessionId}`;
+}
+
+describe("SSE proxy", () => {
+ it("routes /api/roots/changes to the SSE handler, not the /api/* catch-all", async () => {
+ const app = await buildRunningApp();
+ try {
+ const cookie = await seedSessionCookie(app);
+ const response = await fetch(`${app.baseUrl}/api/roots/changes`, { headers: { cookie } });
+ expect(response.headers.get("content-type")).toContain("text/event-stream");
+ await response.body?.cancel();
+ } finally {
+ await app.fastify.close();
+ }
+ });
+
+ it("streams upstream events through to the client", async () => {
+ const app = await buildRunningApp();
+ try {
+ const cookie = await seedSessionCookie(app);
+ const response = await fetch(`${app.baseUrl}/api/roots/changes`, { headers: { cookie } });
+ const reader = response.body!.getReader();
+ const { value } = await reader.read();
+ expect(new TextDecoder().decode(value)).toContain("data: hello");
+ await reader.cancel();
+ } finally {
+ await app.fastify.close();
+ }
+ });
+
+ it("aborts the upstream socket when the client disconnects", async () => {
+ const app = await buildRunningApp();
+ try {
+ const cookie = await seedSessionCookie(app);
+ const controller = new AbortController();
+ const response = await fetch(`${app.baseUrl}/api/roots/changes`, {
+ headers: { cookie },
+ signal: controller.signal,
+ });
+ const reader = response.body!.getReader();
+ await reader.read(); // make sure the stream is actually flowing first
+ expect(upstreamSocketCount).toBe(1);
+
+ controller.abort();
+ await new Promise((resolve) => setTimeout(resolve, 100)); // let close events propagate
+
+ expect(upstreamSocketCount).toBe(0);
+ } finally {
+ await app.fastify.close();
+ }
+ });
+});
diff --git a/server/tsconfig.json b/server/tsconfig.json
new file mode 100644
index 0000000..875edb5
--- /dev/null
+++ b/server/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2022"],
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": false,
+ "sourceMap": true
+ },
+ "include": ["src"]
+}
diff --git a/server/vitest.config.ts b/server/vitest.config.ts
new file mode 100644
index 0000000..a7783bb
--- /dev/null
+++ b/server/vitest.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "vitest/config";
+
+// Standalone config so this package isn't swept up by client/vitest.config.ts
+// (React/jsdom setup for the SPA) when vitest searches up the directory tree.
+export default defineConfig({
+ test: {
+ environment: "node",
+ include: ["test/**/*.test.ts"],
+ globals: false,
+ // REDIS_URL defaults to memory://, and config.ts fails closed when that's
+ // paired with COOKIE_SECURE's own default of "true" โ opt out for tests.
+ env: { COOKIE_SECURE: "false" },
+ },
+});
diff --git a/vite.bff.config.ts b/vite.bff.config.ts
deleted file mode 100644
index 5b23d18..0000000
--- a/vite.bff.config.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { mergeConfig, defineConfig } from "vite";
-
-import baseConfig from "./vite.config";
-
-// Alternate build target for the BFF-served SPA: outputs to
-// client/server/public/ with base '/', instead of vite.config.ts's default
-// (mcpgateway/static/app/ with base '/static/app/', for FastAPI's existing
-// static mount). Everything else โ plugins, chunking, etc. โ is inherited
-// from the base config. See client/server/src/plugins/static.ts.
-export default mergeConfig(
- baseConfig,
- defineConfig({
- base: "/",
- build: {
- outDir: "server/public",
- emptyOutDir: true,
- },
- })
-);
diff --git a/vite.config.ts b/vite.config.ts
index d5df8c5..3834e66 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -19,12 +19,12 @@ export default defineConfig({
},
},
- // Assets are served from /static/app/ by FastAPI's StaticFiles mount
- base: "/static/app/",
+ base: "/",
build: {
- // Output goes into mcpgateway/static/app/ โ FastAPI serves /static/* from mcpgateway/static/
- outDir: "../mcpgateway/static/app",
+ // BFF (server/) serves this directory as static files โ see
+ // server/src/plugins/static.ts.
+ outDir: "server/public",
emptyOutDir: true,
manifest: true,
sourcemap: false,