From 0cd1d4850b3824a4a7d0e540951b8db468e239f7 Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Mon, 10 Aug 2026 14:34:51 +0100 Subject: [PATCH 1/7] feature(ui): BFF Signed-off-by: Gabriel Costa --- README.md | 70 ++++- index.html | 2 +- package.json | 1 - public/favicon.ico | Bin 0 -> 15086 bytes server/.env.example | 28 ++ server/package.json | 32 +++ server/public/index.html | 18 ++ server/src/config.ts | 51 ++++ server/src/index.ts | 53 ++++ server/src/lib/memory-redis.ts | 92 ++++++ server/src/lib/session-store.ts | 109 +++++++ server/src/lib/sse-headers.ts | 20 ++ server/src/lib/upstream-http-client.ts | 18 ++ server/src/plugins/cookie.ts | 14 + server/src/plugins/csrf.ts | 45 +++ server/src/plugins/redis.ts | 40 +++ server/src/plugins/session.ts | 43 +++ server/src/plugins/static.ts | 60 ++++ server/src/routes/app.ts | 30 ++ server/src/routes/auth/login.ts | 83 ++++++ server/src/routes/auth/logout.ts | 67 +++++ server/src/routes/auth/session.ts | 27 ++ server/src/routes/proxy/catch-all.ts | 128 +++++++++ server/src/routes/sse/proxy-sse.ts | 110 ++++++++ server/src/routes/sse/registry.ts | 42 +++ .../src/routes/sse/revocation-subscriber.ts | 46 +++ server/src/routes/sse/routes.ts | 27 ++ server/src/types/fastify.d.ts | 20 ++ server/test/app.test.ts | 122 ++++++++ server/test/auth.test.ts | 265 ++++++++++++++++++ server/test/helpers/build-app.ts | 73 +++++ server/test/memory-redis.test.ts | 51 ++++ server/test/proxy.test.ts | 238 ++++++++++++++++ server/test/sse.test.ts | 131 +++++++++ server/tsconfig.json | 19 ++ server/vitest.config.ts | 11 + vite.bff.config.ts | 19 -- vite.config.ts | 8 +- 38 files changed, 2176 insertions(+), 37 deletions(-) create mode 100644 public/favicon.ico create mode 100644 server/.env.example create mode 100644 server/package.json create mode 100644 server/public/index.html create mode 100644 server/src/config.ts create mode 100644 server/src/index.ts create mode 100644 server/src/lib/memory-redis.ts create mode 100644 server/src/lib/session-store.ts create mode 100644 server/src/lib/sse-headers.ts create mode 100644 server/src/lib/upstream-http-client.ts create mode 100644 server/src/plugins/cookie.ts create mode 100644 server/src/plugins/csrf.ts create mode 100644 server/src/plugins/redis.ts create mode 100644 server/src/plugins/session.ts create mode 100644 server/src/plugins/static.ts create mode 100644 server/src/routes/app.ts create mode 100644 server/src/routes/auth/login.ts create mode 100644 server/src/routes/auth/logout.ts create mode 100644 server/src/routes/auth/session.ts create mode 100644 server/src/routes/proxy/catch-all.ts create mode 100644 server/src/routes/sse/proxy-sse.ts create mode 100644 server/src/routes/sse/registry.ts create mode 100644 server/src/routes/sse/revocation-subscriber.ts create mode 100644 server/src/routes/sse/routes.ts create mode 100644 server/src/types/fastify.d.ts create mode 100644 server/test/app.test.ts create mode 100644 server/test/auth.test.ts create mode 100644 server/test/helpers/build-app.ts create mode 100644 server/test/memory-redis.test.ts create mode 100644 server/test/proxy.test.ts create mode 100644 server/test/sse.test.ts create mode 100644 server/tsconfig.json create mode 100644 server/vitest.config.ts delete mode 100644 vite.bff.config.ts diff --git a/README.md b/README.md index de658e1..793311c 100644 --- a/README.md +++ b/README.md @@ -27,30 +27,67 @@ 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** in another terminal/repo (e.g. `make dev` in the + `mcp-context-forge` repo). Note the port — defaults to `4444`. + +2. **Configure the BFF's env:** ```bash - npm run build + cd server + cp .env.example .env ``` -2. **Start the client development server:** + Edit `server/.env` and set `FASTAPI_URL` to wherever ContextForge is + listening (default `http://127.0.0.1:4444` already matches `make dev`). + Other values (`PORT`, `REDIS_URL`, `COOKIE_SECURE`, etc.) have dev-safe + defaults — see comments in `server/.env.example`. `REDIS_URL=memory://` + is fine for a single local process; use a real `redis://` URL if you need + state shared across instances or restarts. + +3. **Install and start the BFF server:** ```bash + cd server + npm install npm run dev ``` - This starts the Vite dev server at `http://localhost:5173` with hot module replacement. + This runs Fastify with `tsx watch` at `http://localhost:3000` (or + whatever `PORT` you set). -3. **In another terminal, start the backend gateway:** +4. **Build and serve the frontend from the BFF**, 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 frontend changes — 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.) + +5. **Access the application:** + Open `http://localhost:3000/app`. + +> `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. ### Build @@ -58,7 +95,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 +309,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 0000000000000000000000000000000000000000..0b0f7173fb3cb396636d36a00c0edf22231d998c GIT binary patch literal 15086 zcmeI3X^a#_6vulVa8b}*K#^l*2ZM4%1(6W>UQw<78f@V?+ZbsH%b%0l)shu7&r?`E$7m}1~>*s zJv8xL2e||fZmoh=Fy&R(L6{7smIfO50c;;qq>Y1Ik|6T{_?3SmSHXz#`ccpKS=yIP zTR`K(PtGTCJ^0zKwAp@zB2Mc@eXHRg(0BLR(ni`k7y(_N25Ldufi-X(++{S5{)Rtb z5|l&w(8x6~1-5}~cH4B4=igxyECbD#UqR_cPh~cO=I&S+33|_R%Y`)9492dY;pdc5 z*-DU^7!lNNqqnpY4A0mwgU~%TjE%X%<&-(&G{3`{w6E>_XOPQQzr3B4iNom-tzp-} zO3*y@voBxT3Cb-2`LlA#c{Fqfya!sV^4XX#|6l5O0UDu{qW-=eK7k{U&#o}}`VL>h z{V)JHK}IH;U!y_(TkBOAyTa;fE!4h5^Li?1z0-QFHK7K=iq_6)pmis#Eye2VUG{>$ zi~4RMTn<5{Gm5j|FcfP`ROMG;i|h&_<54;QQMDaKJ=&)PIUmH(TTx-Q>;2b*)|()r zbCJf~<_cjgc4&_mR5ai9uA2LmK+tg?^!+-Tp4Jp@ADuIa&LyR05N$-+=NfxI!hYCS z26ofNZ4i~rq@pN%V3;}zj^2T=XLe(=H`HE5_j=_JjeGevukG?r zo8T$<8N4&sNvix6~whHAm0{m>e4+o$&BLwbQ^4!H9bpQ{W+I&Xl^jcy&? zo9b@59HO(`c2N7WU3**i_t#n0ZJXi`f)S0sw?So}-%wdoE^hVG;5H~?r zs7kd5sy$Hcf%YD-9l_BB4O{D4>+p(psy20M7;8yC^8>c>TQi~J4#`tPR$eC^y?M%S zVG&fAWKkg0!fYkdKMZPhM9ukS+=Oz@`lZ|nO+I1!dnCE^wbS(l3Te=>qm5=&j)ok2 zXdwMwUFy^C)w{Qo-gZ@kqu2Ime?qfIt`P%g6AE%7&Bd5V2&|O?m>5HPq?_u!P8}?bg;y2g^ z&%-^?53-5on(mLnwi8BQzBHTMNrw6^OnqT>XJ>1i^h@D!m|G&`lP-adAVV=y<5&Jf z{1DZdY|9?W5`6ylaE8{y>5f9sbAIiuFT?<#h z6bNF&U+7KElo>}j8H&A2m+hLj`PLrwzjS7S_5@Lg+Lqr+f%(2CTyeiN?L#!~!uB~u z{wJ4_vIk5%Ul3Q_-?-9tWYUyp^!5@T0mVu$DtH%Eui7+qzLH5(`ZADcoO}&R`v@8! z3h7TJ`u^_y)XO}Zca<$hr1KbDWD-gg&Zl0j72@dza#r|A)T?H;pLXj zJZKNR5!OPJ2c>O*C&BL bearer token TTL in Redis, seconds. +SESSION_TTL_SECONDS=86400 + +# Leave unset for a host-only cookie (recommended unless the BFF and its +# subdomains genuinely need to share the session cookie). +COOKIE_DOMAIN= +# Set to "false" only for local HTTP development. Must be "true" (default) in prod. +COOKIE_SECURE=true + +# 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.json b/server/package.json new file mode 100644 index 0000000..920e051 --- /dev/null +++ b/server/package.json @@ -0,0 +1,32 @@ +{ + "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.", + "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/public/index.html b/server/public/index.html new file mode 100644 index 0000000..608aa0d --- /dev/null +++ b/server/public/index.html @@ -0,0 +1,18 @@ + + + + + + ContextForge + + + + + + + + + +
+ + diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..c23730c --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,51 @@ +// 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; +} + +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"), + + // 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")), + + cookieDomain: process.env.COOKIE_DOMAIN, // undefined = host-only cookie + cookieSecure: optional("COOKIE_SECURE", "true") === "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: process.env.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"); +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..3567278 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,53 @@ +// 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"; + +const fastify = Fastify({ logger: { level: config.logLevel } }); + +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(); +}); + +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/session-store.ts b/server/src/lib/session-store.ts new file mode 100644 index 0000000..7fd1829 --- /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 `bff:session:${sessionId}`; +} + +/** Publish channel for cross-instance revocation (see routes/sse/revocation-subscriber.ts). */ +export function sessionRevokedChannel(sessionId: string): string { + return `bff: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-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..8c3daad --- /dev/null +++ b/server/src/plugins/static.ts @@ -0,0 +1,60 @@ +// 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; + // A missing asset (has a file extension, e.g. /assets/nope.js) is a + // real 404, not a client route — only extension-less paths (client + // router paths like /login, /tools/123) get the SPA fallback. + const looksLikeAsset = /\.[a-zA-Z0-9]+$/.test(pathname); + + if ( + request.method !== "GET" || + pathname.startsWith("/api/") || + pathname.startsWith("/auth/") || + looksLikeAsset + ) { + 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..0d9aca0 --- /dev/null +++ b/server/src/routes/auth/login.ts @@ -0,0 +1,83 @@ +// 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"; + +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) => { + const { email, password } = request.body ?? {}; + if (!email || !password) { + return reply.code(400).send({ error: "email and password are required" }); + } + + const 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 }), + }); + + 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 }); + } + + const auth = (await upstreamResponse.json()) as UpstreamAuthenticationResponse; // pragma: allowlist secret + + // 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. + const ttlSeconds = + Number.isFinite(auth.expires_in) && auth.expires_in > 0 + ? auth.expires_in + : config.sessionTtlSeconds; + + const sessionId = await createSession( + fastify.redis, + { + bearerToken: auth.access_token, + user: auth.user, + }, + ttlSeconds, + ); + + setSessionCookie(reply, sessionId, ttlSeconds); + // 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..bc15e8f --- /dev/null +++ b/server/src/routes/auth/logout.ts @@ -0,0 +1,67 @@ +// 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"; + +async function revokeUpstreamToken(request: FastifyRequest, bearerToken: string): Promise { + try { + const response = await fetch(`${config.fastapiUrl}/auth/logout`, { + method: "POST", + headers: { authorization: `Bearer ${bearerToken}` }, + }); + 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) => { + const sessionId = request.cookies[SESSION_COOKIE_NAME]; + if (sessionId) { + const record = await getSession(fastify.redis, sessionId); + if (record) { + await revokeUpstreamToken(request, record.bearerToken); + } + await deleteSession(fastify.redis, sessionId); + } + + clearSessionCookie(reply); + reply.clearCookie(CSRF_COOKIE_NAME, { path: "/" }); + + 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..234ce0a --- /dev/null +++ b/server/src/routes/auth/session.ts @@ -0,0 +1,27 @@ +// 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"; + +export default async function sessionRoute(fastify: FastifyInstance): Promise { + fastify.get("/auth/session", async (request: FastifyRequest, reply: FastifyReply) => { + 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..c9886c3 --- /dev/null +++ b/server/src/routes/proxy/catch-all.ts @@ -0,0 +1,128 @@ +// 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"; + +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]); + +// 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 { + const location = headers.location; + if (typeof location !== "string" || !location.startsWith(config.fastapiUrl)) { + return headers; + } + const upstreamPath = location.slice(config.fastapiUrl.length); + return { ...headers, 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) => ({ + ...headers, + authorization: `Bearer ${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..5150377 --- /dev/null +++ b/server/src/routes/sse/proxy-sse.ts @@ -0,0 +1,110 @@ +// 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. These routes are +// GET/read-only; defense in depth is the SameSite session cookie + this +// route never being state-changing. See Risk #2 in the plan doc. + +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 { 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) => { + 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: { + authorization: `Bearer ${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. + 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); + + 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..98a426d --- /dev/null +++ b/server/src/routes/sse/revocation-subscriber.ts @@ -0,0 +1,46 @@ +// 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"; + +const REVOKED_PATTERN = "bff:session:revoked:*"; + +function onPmessage(_pattern: string, channel: string): void { + const sessionId = channel.slice("bff:session:revoked:".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..7d2d5a9 --- /dev/null +++ b/server/test/auth.test.ts @@ -0,0 +1,265 @@ +// 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(); + }); +}); + +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/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..625fc75 --- /dev/null +++ b/server/vitest.config.ts @@ -0,0 +1,11 @@ +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, + }, +}); 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, From 2a082a60acb86c8ea4834d567f1da8fca6d30624 Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Mon, 10 Aug 2026 14:45:17 +0100 Subject: [PATCH 2/7] Refine README Signed-off-by: Gabriel Costa --- README.md | 59 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 793311c..3175a7f 100644 --- a/README.md +++ b/README.md @@ -41,35 +41,36 @@ The app is split into three pieces that all must run for local dev: Bring them up in this order: -1. **Start ContextForge** in another terminal/repo (e.g. `make dev` in the - `mcp-context-forge` repo). Note the port — defaults to `4444`. +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 the BFF's env:** +2. **Configure and start the BFF** (terminal B, this repo's `server/`): ```bash cd server cp .env.example .env ``` - Edit `server/.env` and set `FASTAPI_URL` to wherever ContextForge is - listening (default `http://127.0.0.1:4444` already matches `make dev`). - Other values (`PORT`, `REDIS_URL`, `COOKIE_SECURE`, etc.) have dev-safe - defaults — see comments in `server/.env.example`. `REDIS_URL=memory://` - is fine for a single local process; use a real `redis://` URL if you need - state shared across instances or restarts. + 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. -3. **Install and start the BFF server:** + 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 - cd server npm install - npm run dev + npm run dev # :3000, tsx watch ``` - This runs Fastify with `tsx watch` at `http://localhost:3000` (or - whatever `PORT` you set). - -4. **Build and serve the frontend from the BFF**, from the repo root: +3. **Build the frontend for the BFF to serve**, from the repo root: ```bash npm install @@ -77,18 +78,32 @@ Bring them up in this order: ``` This builds the SPA into `server/public/`, which the already-running BFF - serves directly. Re-run `npm run build` after frontend changes — 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.) + 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. -5. **Access the application:** - Open `http://localhost:3000/app`. + 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 ```bash From 2fcce921a5fcb2b2a6728be4ece4c7488b283081 Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Tue, 11 Aug 2026 14:58:05 +0100 Subject: [PATCH 3/7] Address comments Signed-off-by: Gabriel Costa --- server/.env.example | 3 +++ server/src/config.ts | 20 +++++++++++++++++++- server/src/index.ts | 2 +- server/src/lib/no-store.ts | 14 ++++++++++++++ server/src/lib/upstream-auth.ts | 14 ++++++++++++++ server/src/routes/auth/login.ts | 20 +++++++++++++++++++- server/src/routes/auth/logout.ts | 9 +++++++-- server/src/routes/auth/session.ts | 3 +++ server/src/routes/proxy/catch-all.ts | 19 ++++++++++++------- server/src/routes/sse/proxy-sse.ts | 3 ++- 10 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 server/src/lib/no-store.ts create mode 100644 server/src/lib/upstream-auth.ts diff --git a/server/.env.example b/server/.env.example index 4aebf09..999df14 100644 --- a/server/.env.example +++ b/server/.env.example @@ -6,6 +6,9 @@ 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. diff --git a/server/src/config.ts b/server/src/config.ts index c23730c..6aca7cb 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -10,6 +10,9 @@ function optional(name: string, fallback: string): string { return process.env[name] ?? fallback; } +// 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"), @@ -18,6 +21,9 @@ export const config = { // 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. @@ -31,6 +37,9 @@ export const config = { cookieDomain: process.env.COOKIE_DOMAIN, // undefined = host-only cookie cookieSecure: optional("COOKIE_SECURE", "true") === "true", + // Trust X-Forwarded-For so request.ip is the real client, not the LB. Only safe behind a trusted proxy. + trustProxy: optional("TRUST_PROXY", "true") === "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 @@ -46,6 +55,15 @@ export const config = { } 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)) { +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`, + ); +} diff --git a/server/src/index.ts b/server/src/index.ts index 3567278..0ddafa0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -23,7 +23,7 @@ import catchAllProxyRoute from "./routes/proxy/catch-all.js"; import { startRevocationSubscriber } from "./routes/sse/revocation-subscriber.js"; import sseRoutes from "./routes/sse/routes.js"; -const fastify = Fastify({ logger: { level: config.logLevel } }); +const fastify = Fastify({ logger: { level: config.logLevel }, trustProxy: config.trustProxy }); await fastify.register(cookiePlugin); await fastify.register(redisPlugin); 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/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/routes/auth/login.ts b/server/src/routes/auth/login.ts index 0d9aca0..b4d3068 100644 --- a/server/src/routes/auth/login.ts +++ b/server/src/routes/auth/login.ts @@ -11,6 +11,7 @@ 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"; interface LoginBody { email: string; @@ -26,10 +27,21 @@ interface UpstreamAuthenticationResponse { user: SessionUser; } +// No CSRF token yet at login, so check Sec-Fetch-Site instead to block cross-site login CSRF. +function isCrossSiteRequest(request: FastifyRequest): boolean { + return request.headers["sec-fetch-site"] === "cross-site"; +} + export default async function loginRoute(fastify: FastifyInstance): Promise { fastify.post<{ Body: LoginBody }>( "/auth/login", async (request: FastifyRequest<{ Body: LoginBody }>, reply: FastifyReply) => { + setNoStore(reply); + + if (isCrossSiteRequest(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" }); @@ -53,7 +65,13 @@ export default async function loginRoute(fastify: FastifyInstance): Promise { try { const response = await fetch(`${config.fastapiUrl}/auth/logout`, { method: "POST", - headers: { authorization: `Bearer ${bearerToken}` }, + headers: upstreamAuthHeader(bearerToken), }); if (!response.ok) { request.log.warn( @@ -49,6 +51,8 @@ export default async function logoutRoute(fastify: FastifyInstance): Promise { + setNoStore(reply); + const sessionId = request.cookies[SESSION_COOKIE_NAME]; if (sessionId) { const record = await getSession(fastify.redis, sessionId); @@ -59,7 +63,8 @@ export default async function logoutRoute(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; diff --git a/server/src/routes/proxy/catch-all.ts b/server/src/routes/proxy/catch-all.ts index c9886c3..ce268ec 100644 --- a/server/src/routes/proxy/catch-all.ts +++ b/server/src/routes/proxy/catch-all.ts @@ -22,6 +22,7 @@ import type { 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"]); @@ -96,13 +97,17 @@ export default async function catchAllProxyRoute(fastify: FastifyInstance): Prom const sessionId = request.session!.sessionId; return reply.from(upstreamPath, { - rewriteRequestHeaders: (_req, headers) => ({ - ...headers, - authorization: `Bearer ${bearerToken}`, - // Preserve real client IP for upstream audit logging. - "x-forwarded-for": request.ip, - "x-real-ip": request.ip, - }), + rewriteRequestHeaders: (_req, headers) => { + // Drop browser Cookie — bff_sid/bff_csrf are BFF-only secrets, upstream only needs the bearer. + const { cookie: _cookie, ...forwarded } = 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 diff --git a/server/src/routes/sse/proxy-sse.ts b/server/src/routes/sse/proxy-sse.ts index 5150377..078b9d1 100644 --- a/server/src/routes/sse/proxy-sse.ts +++ b/server/src/routes/sse/proxy-sse.ts @@ -22,6 +22,7 @@ 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 { register, unregister } from "./registry.js"; export interface SseProxyRouteOptions { @@ -53,7 +54,7 @@ export function registerSseProxyRoute(fastify: FastifyInstance, opts: SseProxyRo path: opts.upstreamPath, method: opts.upstreamMethod, headers: { - authorization: `Bearer ${session.bearerToken}`, + ...upstreamAuthHeader(session.bearerToken), accept: "text/event-stream", ...(body ? { "content-type": "application/json" } : {}), }, From 49f54e349ef42117df5efce2b6fc6080ed861caa Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Tue, 11 Aug 2026 19:16:58 +0100 Subject: [PATCH 4/7] Address more comments Signed-off-by: Gabriel Costa --- .gitignore | 5 +-- server/.env.example | 12 +++++++ server/src/config.ts | 15 +++++++-- server/src/lib/origin-guard.ts | 42 ++++++++++++++++++++++++ server/src/plugins/static.ts | 15 ++++++--- server/src/routes/auth/login.ts | 47 +++++++++++++++++--------- server/src/routes/proxy/catch-all.ts | 49 +++++++++++++++++++++++++--- server/src/routes/sse/proxy-sse.ts | 15 +++++++-- 8 files changed, 167 insertions(+), 33 deletions(-) create mode 100644 server/src/lib/origin-guard.ts 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/server/.env.example b/server/.env.example index 999df14..1b10ed5 100644 --- a/server/.env.example +++ b/server/.env.example @@ -23,6 +23,18 @@ COOKIE_DOMAIN= # Set to "false" only for local HTTP development. Must be "true" (default) in prod. COOKIE_SECURE=true +# 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. diff --git a/server/src/config.ts b/server/src/config.ts index 6aca7cb..2d9bddf 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -37,8 +37,19 @@ export const config = { cookieDomain: process.env.COOKIE_DOMAIN, // undefined = host-only cookie cookieSecure: optional("COOKIE_SECURE", "true") === "true", - // Trust X-Forwarded-For so request.ip is the real client, not the LB. Only safe behind a trusted proxy. - trustProxy: optional("TRUST_PROXY", "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: process.env.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 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/plugins/static.ts b/server/src/plugins/static.ts index 8c3daad..0ee491a 100644 --- a/server/src/plugins/static.ts +++ b/server/src/plugins/static.ts @@ -36,16 +36,21 @@ export default fp( fastify.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => { const pathname = request.url.split("?")[0] ?? request.url; - // A missing asset (has a file extension, e.g. /assets/nope.js) is a - // real 404, not a client route — only extension-less paths (client - // router paths like /login, /tools/123) get the SPA fallback. - const looksLikeAsset = /\.[a-zA-Z0-9]+$/.test(pathname); + // 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/") || - looksLikeAsset + isKnownAssetPath ) { return reply.code(404).send({ message: `Route ${request.method}:${request.url} not found`, diff --git a/server/src/routes/auth/login.ts b/server/src/routes/auth/login.ts index b4d3068..896ef59 100644 --- a/server/src/routes/auth/login.ts +++ b/server/src/routes/auth/login.ts @@ -12,6 +12,8 @@ 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; @@ -27,18 +29,13 @@ interface UpstreamAuthenticationResponse { user: SessionUser; } -// No CSRF token yet at login, so check Sec-Fetch-Site instead to block cross-site login CSRF. -function isCrossSiteRequest(request: FastifyRequest): boolean { - return request.headers["sec-fetch-site"] === "cross-site"; -} - export default async function loginRoute(fastify: FastifyInstance): Promise { fastify.post<{ Body: LoginBody }>( "/auth/login", async (request: FastifyRequest<{ Body: LoginBody }>, reply: FastifyReply) => { setNoStore(reply); - if (isCrossSiteRequest(request)) { + if (isForbiddenCrossOrigin(request)) { return reply.code(403).send({ error: "cross_site_request_forbidden" }); } @@ -47,16 +44,22 @@ export default async function loginRoute(fastify: FastifyInstance): Promise, +): 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 @@ -35,12 +66,18 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]); function rewriteUpstreamLocation( headers: Record, ): typeof headers { - const location = headers.location; + // 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 headers; + return rest; } const upstreamPath = location.slice(config.fastapiUrl.length); - return { ...headers, location: `/api${upstreamPath}` }; + return { ...rest, location: `/api${upstreamPath}` }; } // fastify.csrfProtection is callback-style (request, reply, done), not @@ -98,8 +135,10 @@ export default async function catchAllProxyRoute(fastify: FastifyInstance): Prom return reply.from(upstreamPath, { rewriteRequestHeaders: (_req, headers) => { - // Drop browser Cookie — bff_sid/bff_csrf are BFF-only secrets, upstream only needs the bearer. - const { cookie: _cookie, ...forwarded } = 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), diff --git a/server/src/routes/sse/proxy-sse.ts b/server/src/routes/sse/proxy-sse.ts index 078b9d1..d744e91 100644 --- a/server/src/routes/sse/proxy-sse.ts +++ b/server/src/routes/sse/proxy-sse.ts @@ -10,9 +10,13 @@ // 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. These routes are -// GET/read-only; defense in depth is the SameSite session cookie + this -// route never being state-changing. See Risk #2 in the plan doc. +// 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"; @@ -23,6 +27,7 @@ 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 { @@ -41,6 +46,10 @@ export function registerSseProxyRoute(fastify: FastifyInstance, opts: SseProxyRo 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(); From 05e1dd38945609c3b04b9dd11bfa023be1949364 Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Wed, 12 Aug 2026 08:54:17 +0100 Subject: [PATCH 5/7] Address more comments Signed-off-by: Gabriel Costa --- .github/workflows/server-lint-test.yml | 53 + server/package-lock.json | 2648 ++++++++++++++++++++++++ server/public/index.html | 18 - server/src/config.ts | 28 +- server/vitest.config.ts | 3 + 5 files changed, 2729 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/server-lint-test.yml create mode 100644 server/package-lock.json delete mode 100644 server/public/index.html 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/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/public/index.html b/server/public/index.html deleted file mode 100644 index 608aa0d..0000000 --- a/server/public/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - ContextForge - - - - - - - - - -
- - diff --git a/server/src/config.ts b/server/src/config.ts index 2d9bddf..fae5515 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -10,6 +10,13 @@ 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]+$/; @@ -34,7 +41,7 @@ export const config = { // session key once this elapses. sessionTtlSeconds: Number(optional("SESSION_TTL_SECONDS", "86400")), - cookieDomain: process.env.COOKIE_DOMAIN, // undefined = host-only cookie + 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 @@ -44,7 +51,7 @@ export const config = { // 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: process.env.PUBLIC_ORIGIN, + 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 @@ -55,7 +62,7 @@ export const config = { // 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: process.env.PUBLIC_DIR, + 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, @@ -78,3 +85,18 @@ if (!HTTP_TOKEN_RE.test(config.fastapiAuthHeaderName)) { `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/vitest.config.ts b/server/vitest.config.ts index 625fc75..a7783bb 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -7,5 +7,8 @@ export default defineConfig({ 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" }, }, }); From 1dd1b0ce22e94c19669b9f63060f6283f261348b Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Wed, 12 Aug 2026 10:58:36 +0100 Subject: [PATCH 6/7] fix(server): don't let logout stall on a hung upstream revoke Add a 3s AbortSignal.timeout to the upstream /auth/logout fetch and delete the BFF session before awaiting the revoke, so a hung (not refused) upstream can no longer hold the Redis session and cookies open indefinitely. Also fix .env.example: COOKIE_SECURE=true paired with the default REDIS_URL=memory:// and empty PUBLIC_ORIGIN tripped config.ts's fail-closed guards, so `cp .env.example .env && npm run dev` exited immediately. Ship COOKIE_SECURE=false to match the dev defaults and document the prod requirements inline. Signed-off-by: Gabriel Costa --- server/.env.example | 8 ++++++-- server/src/routes/auth/logout.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/server/.env.example b/server/.env.example index 1b10ed5..09b19d2 100644 --- a/server/.env.example +++ b/server/.env.example @@ -20,8 +20,12 @@ SESSION_TTL_SECONDS=86400 # Leave unset for a host-only cookie (recommended unless the BFF and its # subdomains genuinely need to share the session cookie). COOKIE_DOMAIN= -# Set to "false" only for local HTTP development. Must be "true" (default) in prod. -COOKIE_SECURE=true +# "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. diff --git a/server/src/routes/auth/logout.ts b/server/src/routes/auth/logout.ts index 5c4683c..d1aeb70 100644 --- a/server/src/routes/auth/logout.ts +++ b/server/src/routes/auth/logout.ts @@ -29,11 +29,16 @@ 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( @@ -56,10 +61,12 @@ export default async function logoutRoute(fastify: FastifyInstance): Promise Date: Wed, 12 Aug 2026 11:16:49 +0100 Subject: [PATCH 7/7] fix(server): Tighten security - login: warn-log when upstream expires_in is invalid, before falling back to the BFF default session TTL - session-store: configurable REDIS_KEY_PREFIX instead of hardcoded "bff:" (also fixes revocation-subscriber's pattern to match) - proxy-sse: jitter the SSE session-revocation recheck interval - index: close sseUpstreamPool on shutdown - package.json: pin engines.node >=18 (AbortSignal.timeout requirement) - tests: CSRF secret rotation on login, config validation (memory Redis in prod, COOKIE_SECURE without PUBLIC_ORIGIN/TRUST_PROXY) Signed-off-by: Gabriel Costa --- server/.env.example | 4 + server/package.json | 3 + server/src/config.ts | 4 + server/src/index.ts | 2 + server/src/lib/session-store.ts | 4 +- server/src/routes/auth/login.ts | 16 ++- server/src/routes/sse/proxy-sse.ts | 22 ++-- .../src/routes/sse/revocation-subscriber.ts | 6 +- server/test/auth.test.ts | 25 +++++ server/test/config.test.ts | 104 ++++++++++++++++++ 10 files changed, 174 insertions(+), 16 deletions(-) create mode 100644 server/test/config.test.ts diff --git a/server/.env.example b/server/.env.example index 09b19d2..80168df 100644 --- a/server/.env.example +++ b/server/.env.example @@ -17,6 +17,10 @@ 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= diff --git a/server/package.json b/server/package.json index 920e051..f778290 100644 --- a/server/package.json +++ b/server/package.json @@ -4,6 +4,9 @@ "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", diff --git a/server/src/config.ts b/server/src/config.ts index fae5515..e72ac98 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -41,6 +41,10 @@ export const config = { // 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", diff --git a/server/src/index.ts b/server/src/index.ts index 0ddafa0..2294db8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -22,6 +22,7 @@ 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 }); @@ -43,6 +44,7 @@ await fastify.register(appRoute); const revocationSubscriber = startRevocationSubscriber(fastify.log); fastify.addHook("onClose", async () => { await revocationSubscriber.quit(); + await sseUpstreamPool.close(); }); try { diff --git a/server/src/lib/session-store.ts b/server/src/lib/session-store.ts index 7fd1829..dbdfb28 100644 --- a/server/src/lib/session-store.ts +++ b/server/src/lib/session-store.ts @@ -40,12 +40,12 @@ export interface SessionRecord { } export function sessionRedisKey(sessionId: string): string { - return `bff:session:${sessionId}`; + return `${config.redisKeyPrefix}:session:${sessionId}`; } /** Publish channel for cross-instance revocation (see routes/sse/revocation-subscriber.ts). */ export function sessionRevokedChannel(sessionId: string): string { - return `bff:session:revoked:${sessionId}`; + return `${config.redisKeyPrefix}:session:revoked:${sessionId}`; } // TTL defaults to config.sessionTtlSeconds, but callers should pass the diff --git a/server/src/routes/auth/login.ts b/server/src/routes/auth/login.ts index 896ef59..82bd5af 100644 --- a/server/src/routes/auth/login.ts +++ b/server/src/routes/auth/login.ts @@ -84,10 +84,18 @@ export default async function loginRoute(fastify: FastifyInstance): Promise 0 - ? auth.expires_in - : config.sessionTtlSeconds; + 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, diff --git a/server/src/routes/sse/proxy-sse.ts b/server/src/routes/sse/proxy-sse.ts index d744e91..6632fef 100644 --- a/server/src/routes/sse/proxy-sse.ts +++ b/server/src/routes/sse/proxy-sse.ts @@ -97,14 +97,20 @@ export function registerSseProxyRoute(fastify: FastifyInstance, opts: SseProxyRo // 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. - 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); + // 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 diff --git a/server/src/routes/sse/revocation-subscriber.ts b/server/src/routes/sse/revocation-subscriber.ts index 98a426d..01a0fc7 100644 --- a/server/src/routes/sse/revocation-subscriber.ts +++ b/server/src/routes/sse/revocation-subscriber.ts @@ -17,10 +17,12 @@ import { config } from "../../config.js"; import { isMemoryRedisUrl, MemoryRedis } from "../../lib/memory-redis.js"; import { abortAll } from "./registry.js"; -const REVOKED_PATTERN = "bff:session:revoked:*"; +// 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("bff:session:revoked:".length); + const sessionId = channel.slice(REVOKED_CHANNEL_PREFIX.length); if (sessionId) abortAll(sessionId); } diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index 7d2d5a9..23c8594 100644 --- a/server/test/auth.test.ts +++ b/server/test/auth.test.ts @@ -154,6 +154,31 @@ describe("GET /auth/session", () => { 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", () => { 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 }; +}