From 1459d77bbaa33312f3d17118fe946295e6e5b734 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:41:02 +0000 Subject: [PATCH] feat: draft anonymizing MCP proxy with Fullstory preset Gateway that holds the Fullstory MCP API key, authenticates clients with revocable proxy tokens, and runs every tool result (JSON + SSE) through @epilot/anonymization: strict mode for user property bags, URL query redaction, binary content removal, and blocked raw-DOM/screenshot tools. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DmJFpgrBbyyuyoyLFihyow --- .github/workflows/ci.yml | 15 + README.md | 5 + mcp-proxy/.env.example | 14 + mcp-proxy/.gitignore | 3 + mcp-proxy/Dockerfile | 17 + mcp-proxy/README.md | 69 ++ mcp-proxy/package-lock.json | 1878 +++++++++++++++++++++++++++++++++++ mcp-proxy/package.json | 27 + mcp-proxy/src/config.ts | 82 ++ mcp-proxy/src/fullstory.ts | 68 ++ mcp-proxy/src/index.ts | 8 + mcp-proxy/src/proxy.test.ts | 176 ++++ mcp-proxy/src/sanitize.ts | 245 +++++ mcp-proxy/src/server.ts | 193 ++++ mcp-proxy/src/sse.ts | 53 + mcp-proxy/tsconfig.json | 16 + vitest.config.ts | 8 + 17 files changed, 2877 insertions(+) create mode 100644 mcp-proxy/.env.example create mode 100644 mcp-proxy/.gitignore create mode 100644 mcp-proxy/Dockerfile create mode 100644 mcp-proxy/README.md create mode 100644 mcp-proxy/package-lock.json create mode 100644 mcp-proxy/package.json create mode 100644 mcp-proxy/src/config.ts create mode 100644 mcp-proxy/src/fullstory.ts create mode 100644 mcp-proxy/src/index.ts create mode 100644 mcp-proxy/src/proxy.test.ts create mode 100644 mcp-proxy/src/sanitize.ts create mode 100644 mcp-proxy/src/server.ts create mode 100644 mcp-proxy/src/sse.ts create mode 100644 mcp-proxy/tsconfig.json create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63c3180..9cc7567 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,21 @@ jobs: - run: npm test - run: npm run build + mcp-proxy: + runs-on: ubuntu-latest + defaults: + run: + working-directory: mcp-proxy + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 + with: + node-version: "22" + - run: npm ci + - run: npm run lint + - run: npm test + - run: npm run build + publish: name: Publish runs-on: ubuntu-latest diff --git a/README.md b/README.md index adee035..cdaa19e 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,11 @@ counts as PII". | `data_classification: 'pii'` fields | pseudonym (single-line) / `[REDACTED]` (multiline) | | Everything else | unchanged | +## MCP proxy (draft) + +[`mcp-proxy/`](./mcp-proxy) is an MCP gateway that runs upstream MCP tool results (first preset: +Fullstory MCP) through this package before they reach an LLM. See its README. + ## Development ```sh diff --git a/mcp-proxy/.env.example b/mcp-proxy/.env.example new file mode 100644 index 0000000..6bb275f --- /dev/null +++ b/mcp-proxy/.env.example @@ -0,0 +1,14 @@ +# Fullstory API key (Admin/Architect). Region picked from prefix (eu1./na1.). Keep in a secret store. +FULLSTORY_API_KEY=eu1.xxxxx +# One token per client/user, comma-separated. Clients send `Authorization: Bearer ` or use /mcp/. +PROXY_TOKENS= +# HMAC secret for deterministic pseudonyms. Unset -> static [REDACTED] (fail safe). +ANONYMIZATION_SECRET= +# Optional +# KEEP_USER_PROPERTIES=plan,role,org_id +# BLOCKED_TOOLS=session_screenshot,session_get_a11y_tree,session_diff +# ALLOWED_TOOLS= +# ALLOW_BINARY_CONTENT=false +# UPSTREAM_URL=https://api.eu1.fullstory.com/mcp/fullstory +# PORT=8080 +# MCP_PATH=/mcp diff --git a/mcp-proxy/.gitignore b/mcp-proxy/.gitignore new file mode 100644 index 0000000..9c97bbd --- /dev/null +++ b/mcp-proxy/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.env diff --git a/mcp-proxy/Dockerfile b/mcp-proxy/Dockerfile new file mode 100644 index 0000000..5dbc7bd --- /dev/null +++ b/mcp-proxy/Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build && npm prune --omit=dev + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production PORT=8080 +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY package.json ./ +USER node +EXPOSE 8080 +CMD ["node", "dist/index.js"] diff --git a/mcp-proxy/README.md b/mcp-proxy/README.md new file mode 100644 index 0000000..52db700 --- /dev/null +++ b/mcp-proxy/README.md @@ -0,0 +1,69 @@ +# @epilot/anonymizing-mcp-proxy (draft) + +MCP gateway that sits between an AI client (Claude, …) and an upstream MCP server and runs every +tool result through [`@epilot/anonymization`](../README.md) before the model sees it. +First preset: **Fullstory MCP**, whose tool results include end-user `user_properties` +(names, emails, uids, custom vars) and Fullstory has no per-scope access level to turn that off. + +``` +Claude ──(proxy token)──▶ anonymizing-mcp-proxy ──(FS API key)──▶ api.eu1.fullstory.com/mcp/fullstory + ◀── pseudonymized ── ◀── raw ───────── +``` + +## What it does + +| Layer | Behavior | +| --- | --- | +| **Credentials** | Holds the Fullstory API key ([documented gateway mode](https://developer.fullstory.com/mcp/authentication/)); clients authenticate with their own revocable `PROXY_TOKENS`. Client headers (cookies, auth) never reach Fullstory. | +| **User property bags** | Any object under `user_properties` / `userVars` / `properties` / `traits` / … is anonymized **strictly**: known PII keys pseudonymized (`displayName` → `person_4f2a…`, `email` → `…@anonymized.invalid`, `uid` → `value_…`), every other string **redacted** unless allow-listed via `KEEP_USER_PROPERTIES` (e.g. `plan,role`). | +| **Everything else** | `anonymizeUnknown` field heuristics + Fullstory overrides (`displayName`, `uid`, `ip`, `latlong`, …) + pattern scrub of emails / phones / IBANs in all strings. | +| **URLs** | Query-string values & fragments redacted (`?search=REDACTED`), except links into `*.fullstory.com` (replay links stay clickable). | +| **Prose / markdown output** | `Key: value` lines get the same field heuristics; rest is pattern-scrubbed. | +| **Binary content** | Images / audio / blobs replaced with a placeholder (pixels can't be anonymized). | +| **Blocked tools** | `session_screenshot`, `session_get_a11y_tree`, `session_diff` hidden from `tools/list` and answered locally with `isError` — raw DOM/pixels. Override with `BLOCKED_TOOLS` / `ALLOWED_TOOLS`. | +| **Transport** | Streamable HTTP: JSON and SSE responses (event-by-event, ids preserved), `Mcp-Session-Id` passthrough. Unknown content types → 502, never passthrough. | +| **Logging** | Method, tool name, status, latency. Never bodies. | + +Pseudonyms are deterministic (HMAC, `ANONYMIZATION_SECRET`), so the same user maps to the same +pseudonym across tool calls — the model can still correlate sessions, count distinct users, etc. + +## Run + +```sh +npm ci && npm run build +FULLSTORY_API_KEY=eu1.… PROXY_TOKENS=$(openssl rand -hex 24) ANONYMIZATION_SECRET=$(openssl rand -hex 32) npm start +``` + +See [`.env.example`](.env.example) for all options. Docker: `docker build -t fs-mcp-proxy . && docker run -p 8080:8080 --env-file .env fs-mcp-proxy`. + +### Claude Code + +```sh +claude mcp add --transport http fullstory https:///mcp --header "Authorization: Bearer " +``` + +### claude.ai (org connector) + +claude.ai custom connectors don't take static headers: use the path-token form +`https:///mcp/` (treat the URL as a secret), or add OAuth in front +(see follow-ups). + +## Limitations — read this + +- **Heuristic, not a guarantee.** Novel property names outside a user-property bag, or names in + free text (`"Max clicked Save"`), are not caught. Fullstory's own masking/exclusion rules stay the + first line of defense — keep them tight; this proxy is the second. +- **Pseudonyms are one-way.** If the model asks Fullstory to filter by `person_4f2a…`, Fullstory + won't find it. Filtering by raw PII was the thing we wanted to avoid anyway. +- **Upstream output format isn't contractually stable.** Tests use fixtures; validate against real + `get_session_events` / `get_opportunity` output before rollout and extend the overrides. +- **Shared API key** = everyone behind the proxy has the key's Fullstory permissions. Use per-user + proxy tokens so access can be revoked individually. + +## Follow-ups before prod + +1. Validate against live Fullstory MCP output (EU org), extend `src/fullstory.ts`. +2. Deploy (Lambda Function URL with response streaming, or ECS) with secrets from SSM. +3. OAuth 2.1 front door (e.g. epilot SSO / Cognito) instead of static tokens, for claude.ai. +4. Optional: request-side scrubbing of tool arguments. +5. Extract into its own repo (`epilot-dev/anonymizing-mcp-proxy`) — this folder is self-contained. diff --git a/mcp-proxy/package-lock.json b/mcp-proxy/package-lock.json new file mode 100644 index 0000000..ee2779b --- /dev/null +++ b/mcp-proxy/package-lock.json @@ -0,0 +1,1878 @@ +{ + "name": "@epilot/anonymizing-mcp-proxy", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@epilot/anonymizing-mcp-proxy", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@epilot/anonymization": "^0.2.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.4.0", + "vitest": "^1.5.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@epilot/anonymization": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@epilot/anonymization/-/anonymization-0.2.0.tgz", + "integrity": "sha512-o7Uk9UJMFnUoRNMq0UaJiNwcV+B13JTmal+MUe7V1e0lC1d3097oAYL+xHAg3S9midTCcREZN7dluwe0A9kb9A==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "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/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "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.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", + "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "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/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/mcp-proxy/package.json b/mcp-proxy/package.json new file mode 100644 index 0000000..fbcda9f --- /dev/null +++ b/mcp-proxy/package.json @@ -0,0 +1,27 @@ +{ + "name": "@epilot/anonymizing-mcp-proxy", + "version": "0.0.1", + "private": true, + "description": "MCP gateway that anonymizes upstream MCP tool results with @epilot/anonymization before they reach an LLM (preset: Fullstory MCP)", + "license": "MIT", + "author": "epilot GmbH", + "main": "dist/index.js", + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsc && node dist/index.js", + "test": "vitest run", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@epilot/anonymization": "^0.2.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.4.0", + "vitest": "^1.5.0" + } +} diff --git a/mcp-proxy/src/config.ts b/mcp-proxy/src/config.ts new file mode 100644 index 0000000..a35021a --- /dev/null +++ b/mcp-proxy/src/config.ts @@ -0,0 +1,82 @@ +import { createAnonymizer } from '@epilot/anonymization'; +import { + FULLSTORY_BLOCKED_TOOLS, + FULLSTORY_KEEP_URL_HOSTS, + FULLSTORY_OVERRIDES, + FULLSTORY_USER_PROPERTY_KEYS, + fullstoryUpstreamFromApiKey, +} from './fullstory'; +import { createSanitizer, type Sanitizer } from './sanitize'; + +export type ProxyConfig = { + port: number; + /** path the MCP endpoint is served on; `/` also accepted (for clients without custom headers) */ + path: string; + upstreamUrl: string; + upstreamApiKey: string; + /** client credentials accepted by the proxy (one per user/client, so they can be revoked individually) */ + proxyTokens: string[]; + isToolBlocked: (name: string) => boolean; + sanitizer: Sanitizer; + maxBodyBytes: number; + log: (entry: Record) => void; +}; + +const list = (value: string | undefined): string[] | undefined => + value === undefined + ? undefined + : value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + +export const loadConfig = (env: NodeJS.ProcessEnv = process.env): ProxyConfig => { + const upstreamApiKey = env.UPSTREAM_API_KEY ?? env.FULLSTORY_API_KEY; + if (!upstreamApiKey) { + throw new Error('FULLSTORY_API_KEY (or UPSTREAM_API_KEY) is required'); + } + + const proxyTokens = list(env.PROXY_TOKENS) ?? []; + if (proxyTokens.length === 0 && env.ALLOW_UNAUTHENTICATED !== 'true') { + // the proxy holds an org-wide API key: never expose it unauthenticated by accident + throw new Error('PROXY_TOKENS is required (set ALLOW_UNAUTHENTICATED=true for local dev only)'); + } + + const log = (entry: Record) => console.log(JSON.stringify({ ts: new Date().toISOString(), ...entry })); + + if (!env.ANONYMIZATION_SECRET) { + log({ level: 'warn', msg: 'ANONYMIZATION_SECRET not set: PII is statically redacted instead of pseudonymized' }); + } + + const blocked = new Set(list(env.BLOCKED_TOOLS) ?? FULLSTORY_BLOCKED_TOOLS); + const allowed = list(env.ALLOWED_TOOLS); + const allowedSet = allowed && new Set(allowed); + const isToolBlocked = (name: string) => blocked.has(name) || (allowedSet !== undefined && !allowedSet.has(name)); + + const anonymizer = createAnonymizer({ + orgId: env.ANONYMIZATION_ORG_ID ?? 'fullstory', + secret: env.ANONYMIZATION_SECRET, + overrides: FULLSTORY_OVERRIDES, + }); + + const sanitizer = createSanitizer({ + anonymizer, + userPropertyKeys: env.USER_PROPERTY_KEYS ? new RegExp(env.USER_PROPERTY_KEYS, 'i') : FULLSTORY_USER_PROPERTY_KEYS, + keepUserProperties: list(env.KEEP_USER_PROPERTIES), + keepUrlHosts: FULLSTORY_KEEP_URL_HOSTS, + allowBinaryContent: env.ALLOW_BINARY_CONTENT === 'true', + isToolBlocked, + }); + + return { + port: Number(env.PORT ?? 8080), + path: env.MCP_PATH ?? '/mcp', + upstreamUrl: env.UPSTREAM_URL ?? fullstoryUpstreamFromApiKey(upstreamApiKey), + upstreamApiKey, + proxyTokens, + isToolBlocked, + sanitizer, + maxBodyBytes: Number(env.MAX_BODY_BYTES ?? 1_000_000), + log, + }; +}; diff --git a/mcp-proxy/src/fullstory.ts b/mcp-proxy/src/fullstory.ts new file mode 100644 index 0000000..f60f6c6 --- /dev/null +++ b/mcp-proxy/src/fullstory.ts @@ -0,0 +1,68 @@ +import type { AnonymizationStrategy } from '@epilot/anonymization'; + +/** + * Fullstory MCP preset. + * + * Upstream: https://developer.fullstory.com/mcp/introduction/ + * Auth: `Authorization: Bearer ` (the documented option for gateways). + */ +export const FULLSTORY_UPSTREAM_URL = { + eu1: 'https://api.eu1.fullstory.com/mcp/fullstory', + na1: 'https://api.na1.fullstory.com/mcp/fullstory', +} as const; + +/** Region is encoded in the API key prefix (`eu1.` / `na1.`) */ +export const fullstoryUpstreamFromApiKey = (apiKey: string): string => + apiKey.startsWith('na1.') ? FULLSTORY_UPSTREAM_URL.na1 : FULLSTORY_UPSTREAM_URL.eu1; + +/** + * Fullstory identity / user-var field names (lowercased, `*:` = any object). + * Covers FS.identify / setUserVars built-ins and their export-API spellings. + */ +export const FULLSTORY_OVERRIDES: Record = { + '*:displayname': 'person', + '*:user_displayname': 'person', + '*:userdisplayname': 'person', + '*:user_name': 'person', + '*:username': 'person', + '*:useremail': 'email', + '*:user_email': 'email', + '*:email_str': 'email', + '*:name_str': 'person', + '*:displayname_str': 'person', + // uid = the customer's own user id -> pseudonymize but keep joinable + '*:uid': 'value', + '*:user_uid': 'value', + '*:useruid': 'value', + '*:user_id_str': 'value', + // network / geo quasi-identifiers + '*:ip': 'redact', + '*:ip_address': 'redact', + '*:ipaddress': 'redact', + '*:client_ip': 'redact', + '*:latlong': 'redact', + '*:lat': 'redact', + '*:lng': 'redact', + '*:latitude': 'redact', + '*:longitude': 'redact', +}; + +/** + * Keys whose whole subtree is a bag of customer-defined user properties. Everything below is + * treated STRICTLY: known PII keys get pseudonymized, all other string leaves are redacted, + * unless allow-listed via `KEEP_USER_PROPERTIES`. This is the `user_properties` leak. + */ +export const FULLSTORY_USER_PROPERTY_KEYS = + /^(user_?)?(properties|props|vars|variables|traits)$|^(custom|user|page|session)_?vars$|^identity$/i; + +/** + * Tools whose output cannot be anonymized as text: + * - `session_screenshot` returns rendered pixels (typed names/emails are only hidden if + * Fullstory masking rules already cover them) + * - `session_get_a11y_tree` / `session_diff` return raw DOM text (form values, names in + * headers, table cells) — free text the heuristics cannot reliably classify + */ +export const FULLSTORY_BLOCKED_TOOLS = ['session_screenshot', 'session_get_a11y_tree', 'session_diff']; + +/** Links back into the Fullstory app are useful and contain only FS-internal ids */ +export const FULLSTORY_KEEP_URL_HOSTS = /(^|\.)fullstory\.com$/i; diff --git a/mcp-proxy/src/index.ts b/mcp-proxy/src/index.ts new file mode 100644 index 0000000..6ed2009 --- /dev/null +++ b/mcp-proxy/src/index.ts @@ -0,0 +1,8 @@ +import { loadConfig } from './config'; +import { createProxyServer } from './server'; + +const config = loadConfig(); + +createProxyServer(config).listen(config.port, () => { + config.log({ level: 'info', msg: 'anonymizing MCP proxy listening', port: config.port, upstream: config.upstreamUrl }); +}); diff --git a/mcp-proxy/src/proxy.test.ts b/mcp-proxy/src/proxy.test.ts new file mode 100644 index 0000000..7d041f4 --- /dev/null +++ b/mcp-proxy/src/proxy.test.ts @@ -0,0 +1,176 @@ +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { loadConfig } from './config'; +import { createProxyServer } from './server'; + +const env = { + FULLSTORY_API_KEY: 'eu1.test-key', + PROXY_TOKENS: 'tok-a,tok-b', + ANONYMIZATION_SECRET: 'test-secret', + KEEP_USER_PROPERTIES: 'plan', +}; +const silent = () => undefined; + +const sessionEvents = { + session: { + url: 'https://app.eu1.fullstory.com/ui/o-123/session/1234:5678', + user_properties: { + displayName: 'Max Mustermann', + email: 'max@web.de', + uid: 'user-42', + plan: 'enterprise', + favorite_customer: 'Erika Musterfrau', + }, + }, + events: [ + { type: 'navigate', page_url: 'https://portal.epilot.cloud/app/contacts?search=Erika%20Musterfrau&id=9' }, + { type: 'error', message: 'Could not send mail to erika@example.org' }, + { type: 'click', target: 'Save button', count: 3 }, + ], +}; + +describe('sanitizer', () => { + const { sanitizer } = { ...loadConfig(env), log: silent }; + + it('strictly anonymizes user property bags, keeps allow-listed keys and structure', () => { + const out = sanitizer.sanitizeJson(sessionEvents) as typeof sessionEvents; + const props = out.session.user_properties; + + expect(props.displayName).toMatch(/^person_[0-9a-f]+$/); + expect(props.email).toMatch(/@anonymized\.invalid$/); + expect(props.uid).toMatch(/^value_[0-9a-f]+$/); + expect(props.plan).toBe('enterprise'); + expect(props.favorite_customer).toBe('[REDACTED]'); + // deterministic -> joinable across tool calls + expect((sanitizer.sanitizeJson(sessionEvents) as typeof sessionEvents).session.user_properties.uid).toBe(props.uid); + }); + + it('redacts URL query strings except on Fullstory hosts, scrubs embedded identifiers', () => { + const out = JSON.stringify(sanitizer.sanitizeJson(sessionEvents)); + + expect(out).toContain('https://app.eu1.fullstory.com/ui/o-123/session/1234:5678'); + expect(out).toContain('contacts?search=REDACTED&id=REDACTED'); + expect(out).not.toMatch(/Musterfrau|Mustermann|max@web\.de|erika@example\.org|user-42/); + expect(out).toContain('Save button'); + }); + + it('handles markdown/prose tool output', () => { + const text = [ + '## Session 1234:5678', + '- **Display Name:** Max Mustermann', + '- **Email:** erika@example.org', + '- Email: max@web.de', + '- Plan: enterprise', + 'Visited https://portal.epilot.cloud/app?email=max%40web.de', + ].join('\n'); + const out = sanitizer.sanitizeText(text); + + expect(out).not.toMatch(/Mustermann|max@web\.de|max%40web/); + expect(out).toContain('- Plan: enterprise'); + expect(out).toContain('## Session 1234:5678'); + }); + + it('replaces binary content and hides blocked tools', () => { + const shot = sanitizer.sanitizePayload({ + jsonrpc: '2.0', + id: 1, + result: { content: [{ type: 'image', data: 'iVBOR…', mimeType: 'image/png' }] }, + }) as { result: { content: { type: string }[] } }; + expect(shot.result.content[0].type).toBe('text'); + + const list = sanitizer.sanitizePayload({ + jsonrpc: '2.0', + id: 2, + result: { tools: [{ name: 'session_screenshot' }, { name: 'compute_metric' }] }, + }) as { result: { tools: { name: string }[] } }; + expect(list.result.tools.map((t) => t.name)).toEqual(['compute_metric']); + }); +}); + +describe('proxy server (fake upstream)', () => { + let upstream: http.Server; + let proxy: http.Server; + let proxyUrl: string; + const seen: { authorization?: string; cookie?: string }[] = []; + + beforeAll(async () => { + upstream = http.createServer((req, res) => { + seen.push({ authorization: req.headers.authorization, cookie: req.headers.cookie }); + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + const msg = JSON.parse(body); + const result = { content: [{ type: 'text', text: JSON.stringify(sessionEvents) }] }; + if (msg.params?.name === 'get_session_events_sse') { + res.writeHead(200, { 'content-type': 'text/event-stream', 'mcp-session-id': 's-1' }); + res.write(`id: 1\ndata: ${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/message', params: { data: 'hi max@web.de' } })}\n\n`); + res.end(`id: 2\ndata: ${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result })}\n\n`); + return; + } + res.writeHead(200, { 'content-type': 'application/json', 'mcp-session-id': 's-1' }); + res.end(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result })); + }); + }); + await new Promise((r) => upstream.listen(0, r)); + const upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}/mcp/fullstory`; + + proxy = createProxyServer({ ...loadConfig({ ...env, UPSTREAM_URL: upstreamUrl }), log: silent }); + await new Promise((r) => proxy.listen(0, r)); + proxyUrl = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}/mcp`; + }); + + afterAll(() => { + proxy.close(); + upstream.close(); + }); + + const call = (name: string, auth: Record = { authorization: 'Bearer tok-a' }, url = proxyUrl) => + fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', cookie: 'x=1', ...auth }, + body: JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name, arguments: {} } }), + }); + + it('rejects missing / wrong client tokens', async () => { + expect((await call('get_session_events', {})).status).toBe(401); + expect((await call('get_session_events', { authorization: 'Bearer nope' })).status).toBe(401); + }); + + it('accepts path tokens, swaps credentials, drops client headers, anonymizes JSON responses', async () => { + const res = await call('get_session_events', {}, `${proxyUrl}/tok-b`); + expect(res.status).toBe(200); + expect(res.headers.get('mcp-session-id')).toBe('s-1'); + const text = JSON.stringify(await res.json()); + + expect(text).not.toMatch(/Mustermann|max@web\.de|user-42/); + expect(seen.at(-1)).toEqual({ authorization: 'Bearer eu1.test-key', cookie: undefined }); + }); + + it('anonymizes SSE streams event by event', async () => { + const res = await call('get_session_events_sse'); + const text = await res.text(); + + expect(res.headers.get('content-type')).toContain('text/event-stream'); + expect(text).toMatch(/^id: 1\ndata: /); + expect(text).toContain('id: 2'); + expect(text).not.toMatch(/Mustermann|max@web\.de|user-42/); + }); + + it('answers blocked tools locally without calling upstream', async () => { + const before = seen.length; + const res = await call('session_screenshot'); + const body = (await res.json()) as { result: { isError: boolean } }; + + expect(body.result.isError).toBe(true); + expect(seen.length).toBe(before); + }); +}); + +describe('markdown key/value lines', () => { + const { sanitizer } = loadConfig(env); + + it('keeps bold markers and pseudonymizes the value only', () => { + expect(sanitizer.sanitizeText('- **Display Name:** Max Mustermann')).toMatch(/^- \*\*Display Name:\*\* person_[0-9a-f]+$/); + }); +}); diff --git a/mcp-proxy/src/sanitize.ts b/mcp-proxy/src/sanitize.ts new file mode 100644 index 0000000..87a6e38 --- /dev/null +++ b/mcp-proxy/src/sanitize.ts @@ -0,0 +1,245 @@ +import { REDACTED_PLACEHOLDER, type Anonymizer } from '@epilot/anonymization'; + +export type SanitizerOptions = { + anonymizer: Anonymizer; + /** keys whose subtree is a bag of user properties -> strict anonymization */ + userPropertyKeys: RegExp; + /** user property keys (lowercased) explicitly allowed through unchanged, e.g. `plan`, `role` */ + keepUserProperties?: string[]; + /** URLs on these hosts keep their query string (e.g. links back into the Fullstory app) */ + keepUrlHosts?: RegExp; + /** pass image/audio/blob content through (cannot be anonymized). Default: false */ + allowBinaryContent?: boolean; + /** tool visibility: default deny-list, optional allow-list */ + isToolBlocked: (name: string) => boolean; +}; + +export type JsonRpcMessage = { + jsonrpc?: string; + id?: string | number | null; + method?: string; + params?: Record; + result?: Record; + error?: { code: number; message: string; data?: unknown }; +}; + +type ContentItem = { type?: string; [key: string]: unknown }; + +const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>`)\]]+/g; +const PROSE_KEY_VALUE = /^(\s*(?:[-*]\s+)?\**)([A-Za-z_$][\w .$-]{0,60}?)(\**\s*[:=]\**\s*)(?!\/\/)(\S.*)$/; +const BINARY_PLACEHOLDER = '[binary content removed by anonymizing proxy]'; + +/** server-initiated methods whose params carry protocol data only */ +const PROTOCOL_ONLY_METHODS = new Set([ + 'ping', + 'notifications/progress', + 'notifications/cancelled', + 'notifications/initialized', + 'notifications/tools/list_changed', + 'notifications/resources/list_changed', + 'notifications/prompts/list_changed', + 'notifications/resources/updated', +]); + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export const createSanitizer = (options: SanitizerOptions) => { + const { anonymizer, userPropertyKeys, keepUrlHosts, allowBinaryContent = false } = options; + const keepUserProperties = new Set((options.keepUserProperties ?? []).map((key) => key.toLowerCase())); + + /** query strings / fragments routinely carry emails, tokens, search terms -> redact values, keep keys */ + const redactUrls = (text: string): string => + text.replace(URL_PATTERN, (match) => { + let url: URL; + try { + url = new URL(match); + } catch { + return match; + } + if (keepUrlHosts?.test(url.hostname) || (!url.search && !url.hash)) { + return match; + } + const keys = [...new Set(url.searchParams.keys())]; + url.search = keys.map((key) => `${encodeURIComponent(key)}=REDACTED`).join('&'); + url.hash = ''; + + return url.toString(); + }); + + /** user property bag: allow-listed keys raw, everything else strict (unknown strings redacted) */ + const sanitizeUserProperties = (node: unknown): unknown => { + if (Array.isArray(node)) { + return node.map(sanitizeUserProperties); + } + if (!isPlainObject(node)) { + return anonymizer.anonymizeUnknown(node, { strict: true }); + } + + return Object.fromEntries( + Object.entries(node).map(([key, value]) => { + if (keepUserProperties.has(key.toLowerCase())) { + return [key, value]; + } + + return [key, (anonymizer.anonymizeUnknown({ [key]: value }, { strict: true }) as Record)[key]]; + }), + ); + }; + + const preWalk = (node: unknown): unknown => { + if (typeof node === 'string') { + return redactUrls(node); + } + if (Array.isArray(node)) { + return node.map(preWalk); + } + if (!isPlainObject(node)) { + return node; + } + + return Object.fromEntries( + Object.entries(node).map(([key, value]) => [ + key, + userPropertyKeys.test(key) && value !== null && typeof value === 'object' + ? sanitizeUserProperties(value) + : preWalk(value), + ]), + ); + }; + + /** arbitrary JSON: user-property bags strict, URLs de-queried, then field heuristics + pattern scrub */ + const sanitizeJson = (value: unknown): unknown => anonymizer.anonymizeUnknown(preWalk(value)); + + /** `key: value` lines in markdown/prose output get the same field-name heuristics as JSON */ + const sanitizeProseLine = (line: string): string => { + const match = PROSE_KEY_VALUE.exec(line); + if (match) { + const [, prefix, rawKey, separator, value] = match; + const key = rawKey.trim().toLowerCase().replace(/[\s.-]+/g, '_'); + const input = userPropertyKeys.test(key) ? { [key]: { value } } : { [key]: value }; + const output = (sanitizeJson(input) as Record)[key]; + const sanitized = isPlainObject(output) ? output.value : output; + if (typeof sanitized === 'string') { + return `${prefix}${rawKey}${separator}${sanitized}`; + } + } + + return anonymizer.scrub(redactUrls(line)) as string; + }; + + const sanitizeText = (text: string): string => { + const trimmed = text.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + const parsed: unknown = JSON.parse(trimmed); + const indent = trimmed.includes('\n') ? 2 : undefined; + + return JSON.stringify(sanitizeJson(parsed), null, indent); + } catch { + // not JSON -> treat as prose + } + } + + return text.split('\n').map(sanitizeProseLine).join('\n'); + }; + + const sanitizeContentItem = (item: ContentItem): ContentItem => { + switch (item.type) { + case 'text': + return { ...item, text: typeof item.text === 'string' ? sanitizeText(item.text) : item.text }; + case 'image': + case 'audio': + return allowBinaryContent ? item : { type: 'text', text: BINARY_PLACEHOLDER }; + case 'resource': { + const resource = isPlainObject(item.resource) ? item.resource : {}; + if (typeof resource.blob === 'string' && !allowBinaryContent) { + return { type: 'text', text: BINARY_PLACEHOLDER }; + } + + return { + ...item, + resource: { + ...resource, + ...(typeof resource.uri === 'string' && { uri: redactUrls(resource.uri) }), + ...(typeof resource.text === 'string' && { text: sanitizeText(resource.text) }), + }, + }; + } + case 'resource_link': + return sanitizeJson(item) as ContentItem; + default: + // unknown content type: fail safe + return { type: 'text', text: BINARY_PLACEHOLDER }; + } + }; + + const sanitizeContent = (content: unknown): unknown => + Array.isArray(content) ? content.map((item) => sanitizeContentItem(isPlainObject(item) ? item : {})) : content; + + const sanitizeResult = (result: Record): Record => { + const out: Record = { ...result }; + + // tools/list: hide blocked tools so the model never plans with them + if (Array.isArray(out.tools)) { + out.tools = out.tools.filter( + (tool) => !(isPlainObject(tool) && typeof tool.name === 'string' && options.isToolBlocked(tool.name)), + ); + } + // tools/call + if ('content' in out) { + out.content = sanitizeContent(out.content); + } + if ('structuredContent' in out) { + out.structuredContent = sanitizeJson(out.structuredContent); + } + // resources/read + if (Array.isArray(out.contents)) { + out.contents = out.contents.map((entry) => sanitizeContentItem({ type: 'resource', resource: entry }).resource); + } + // prompts/get + if (Array.isArray(out.messages)) { + out.messages = out.messages.map((message) => + isPlainObject(message) && isPlainObject(message.content) + ? { ...message, content: sanitizeContentItem(message.content) } + : message, + ); + } + + return out; + }; + + /** sanitize a single upstream -> client JSON-RPC message */ + const sanitizeMessage = (message: JsonRpcMessage): JsonRpcMessage => { + if (!isPlainObject(message)) { + return message; + } + if (isPlainObject(message.result)) { + return { ...message, result: sanitizeResult(message.result) }; + } + if (isPlainObject(message.error)) { + return { + ...message, + error: { + ...message.error, + message: sanitizeText(String(message.error.message ?? '')), + ...('data' in message.error && { data: sanitizeJson(message.error.data) }), + }, + }; + } + if (typeof message.method === 'string' && message.params && !PROTOCOL_ONLY_METHODS.has(message.method)) { + return { ...message, params: sanitizeJson(message.params) as Record }; + } + + return message; + }; + + const sanitizePayload = (payload: unknown): unknown => + Array.isArray(payload) + ? payload.map((message) => sanitizeMessage(message as JsonRpcMessage)) + : sanitizeMessage(payload as JsonRpcMessage); + + return { sanitizePayload, sanitizeMessage, sanitizeText, sanitizeJson, redacted: REDACTED_PLACEHOLDER }; +}; + +export type Sanitizer = ReturnType; diff --git a/mcp-proxy/src/server.ts b/mcp-proxy/src/server.ts new file mode 100644 index 0000000..9563367 --- /dev/null +++ b/mcp-proxy/src/server.ts @@ -0,0 +1,193 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; +import http from 'node:http'; +import { Readable } from 'node:stream'; +import type { ProxyConfig } from './config'; +import type { JsonRpcMessage } from './sanitize'; +import { transformSse } from './sse'; + +/** request headers forwarded upstream; everything else (cookies, client auth, …) is dropped */ +const FORWARD_REQUEST_HEADERS = ['content-type', 'accept', 'mcp-session-id', 'mcp-protocol-version', 'last-event-id']; +/** response headers forwarded to the client; `www-authenticate` is dropped so clients don't try OAuth against us */ +const FORWARD_RESPONSE_HEADERS = ['content-type', 'mcp-session-id', 'mcp-protocol-version', 'cache-control']; + +const digest = (value: string) => createHash('sha256').update(value).digest(); + +const sendJson = (res: http.ServerResponse, status: number, body: unknown, headers: Record = {}) => { + res.writeHead(status, { 'content-type': 'application/json', ...headers }); + res.end(JSON.stringify(body)); +}; + +const readBody = async (req: http.IncomingMessage, limit: number): Promise => { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + size += (chunk as Buffer).length; + if (size > limit) { + throw Object.assign(new Error('payload too large'), { status: 413 }); + } + chunks.push(chunk as Buffer); + } + + return Buffer.concat(chunks).toString('utf8'); +}; + +const blockedToolResult = (message: JsonRpcMessage, name: string): JsonRpcMessage => ({ + jsonrpc: '2.0', + id: message.id ?? null, + result: { + content: [ + { + type: 'text', + text: `Tool "${name}" is disabled by the epilot anonymizing proxy: its output cannot be anonymized reliably.`, + }, + ], + isError: true, + }, +}); + +export const createProxyServer = (config: ProxyConfig): http.Server => { + const tokenDigests = config.proxyTokens.map(digest); + + const isAuthorized = (req: http.IncomingMessage, pathToken: string | undefined): boolean => { + if (tokenDigests.length === 0) { + return true; // ALLOW_UNAUTHENTICATED + } + const header = req.headers.authorization; + const presented = header?.startsWith('Bearer ') ? header.slice(7) : pathToken; + if (!presented) { + return false; + } + const presentedDigest = digest(presented); + + return tokenDigests.some((expected) => timingSafeEqual(expected, presentedDigest)); + }; + + return http.createServer(async (req, res) => { + const started = Date.now(); + const url = new URL(req.url ?? '/', 'http://localhost'); + const logEntry: Record = { method: req.method, path: config.path }; + + try { + if (url.pathname === '/healthz') { + return sendJson(res, 200, { ok: true }); + } + + const isMcpPath = url.pathname === config.path || url.pathname.startsWith(`${config.path}/`); + if (!isMcpPath) { + return sendJson(res, 404, { error: 'not found' }); + } + const pathToken = url.pathname.length > config.path.length ? url.pathname.slice(config.path.length + 1) : undefined; + if (!isAuthorized(req, pathToken)) { + return sendJson(res, 401, { error: 'unauthorized' }); + } + if (!['POST', 'GET', 'DELETE'].includes(req.method ?? '')) { + return sendJson(res, 405, { error: 'method not allowed' }, { allow: 'GET, POST, DELETE' }); + } + + let body: string | undefined; + if (req.method === 'POST') { + body = await readBody(req, config.maxBodyBytes); + let parsed: JsonRpcMessage | JsonRpcMessage[]; + try { + parsed = JSON.parse(body); + } catch { + return sendJson(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); + } + const messages = Array.isArray(parsed) ? parsed : [parsed]; + logEntry.rpc = messages.map((m) => (m.method === 'tools/call' ? `tools/call:${m.params?.name}` : m.method)); + + const blocked = messages.filter( + (m) => m.method === 'tools/call' && typeof m.params?.name === 'string' && config.isToolBlocked(m.params.name), + ); + if (blocked.length > 0) { + // answer locally, never forward; batches containing a blocked call are rejected as a whole + const responses = messages + .filter((m) => m.id !== undefined) + .map((m) => + blocked.includes(m) + ? blockedToolResult(m, String(m.params?.name)) + : { jsonrpc: '2.0', id: m.id, error: { code: -32600, message: 'Batch rejected: contains a disabled tool' } }, + ); + logEntry.status = 200; + logEntry.blocked = blocked.map((m) => m.params?.name); + + return sendJson(res, 200, Array.isArray(parsed) ? responses : responses[0]); + } + } + + const headers: Record = { authorization: `Bearer ${config.upstreamApiKey}` }; + for (const name of FORWARD_REQUEST_HEADERS) { + const value = req.headers[name]; + if (typeof value === 'string') { + headers[name] = value; + } + } + + const upstream = await fetch(config.upstreamUrl, { method: req.method, headers, body }); + logEntry.status = upstream.status; + + const responseHeaders: Record = {}; + for (const name of FORWARD_RESPONSE_HEADERS) { + const value = upstream.headers.get(name); + if (value) { + responseHeaders[name] = value; + } + } + + if (upstream.status === 401 || upstream.status === 403) { + // upstream rejected OUR key: don't make the client think it must re-authenticate + return sendJson(res, 502, { error: 'upstream rejected the proxy credentials' }); + } + + const contentType = upstream.headers.get('content-type') ?? ''; + + if (contentType.includes('text/event-stream') && upstream.body) { + res.writeHead(upstream.status, responseHeaders); + const events = transformSse( + Readable.fromWeb(upstream.body as import('node:stream/web').ReadableStream), + config.sanitizer.sanitizeMessage, + ); + req.on('close', () => void events.return(undefined)); + for await (const event of events) { + res.write(event); + } + + return res.end(); + } + + const text = await upstream.text(); + + if (contentType.includes('application/json')) { + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + return sendJson(res, 502, { error: 'upstream returned invalid JSON' }); + } + + return sendJson(res, upstream.status, config.sanitizer.sanitizePayload(payload), responseHeaders); + } + + if (text.length === 0) { + res.writeHead(upstream.status, responseHeaders); + + return res.end(); + } + + // unexpected body type: fail safe, never pass through unsanitized + return sendJson(res, 502, { error: `unsupported upstream content-type: ${contentType || 'none'}` }); + } catch (error) { + const status = (error as { status?: number }).status ?? 502; + logEntry.status = status; + logEntry.error = (error as Error).message; + if (!res.headersSent) { + sendJson(res, status, { error: status === 413 ? 'payload too large' : 'upstream error' }); + } else { + res.end(); + } + } finally { + // never log bodies: they are exactly the data this proxy exists to keep out of places + config.log({ ...logEntry, ms: Date.now() - started }); + } + }); +}; diff --git a/mcp-proxy/src/sse.ts b/mcp-proxy/src/sse.ts new file mode 100644 index 0000000..4590ccb --- /dev/null +++ b/mcp-proxy/src/sse.ts @@ -0,0 +1,53 @@ +import type { JsonRpcMessage } from './sanitize'; + +/** + * Re-writes a `text/event-stream` of JSON-RPC messages, sanitizing each `data:` payload. + * Event framing (id / event / retry) is preserved so resumability (`Last-Event-ID`) keeps working. + * Unparseable data is dropped, never passed through. + */ +export async function* transformSse( + body: AsyncIterable, + sanitize: (message: JsonRpcMessage) => JsonRpcMessage, +): AsyncGenerator { + const decoder = new TextDecoder(); + let buffer = ''; + + const transformEvent = (raw: string): string => { + const lines = raw.split(/\r?\n/); + const data: string[] = []; + const meta: string[] = []; + for (const line of lines) { + if (line.startsWith('data:')) { + data.push(line.slice(5).replace(/^ /, '')); + } else if (line.length > 0) { + meta.push(line); + } + } + if (data.length === 0) { + return `${meta.join('\n')}\n\n`; + } + let payload: string; + try { + payload = JSON.stringify(sanitize(JSON.parse(data.join('\n')))); + } catch { + payload = ''; + meta.push(': non-JSON data dropped by anonymizing proxy'); + } + + return `${[...meta, ...(payload ? [`data: ${payload}`] : [])].join('\n')}\n\n`; + }; + + for await (const chunk of body) { + buffer += decoder.decode(chunk, { stream: true }); + let boundary: RegExpExecArray | null; + while ((boundary = /\r?\n\r?\n/.exec(buffer))) { + const raw = buffer.slice(0, boundary.index); + buffer = buffer.slice(boundary.index + boundary[0].length); + yield transformEvent(raw); + } + } + buffer += decoder.decode(); + if (buffer.trim()) { + yield transformEvent(buffer); + } +} diff --git a/mcp-proxy/tsconfig.json b/mcp-proxy/tsconfig.json new file mode 100644 index 0000000..4af99aa --- /dev/null +++ b/mcp-proxy/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2022", "DOM"], + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "dist", "node_modules"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..9277c8f --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { configDefaults, defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // mcp-proxy/ is a separate package with its own deps and test run + exclude: [...configDefaults.exclude, 'mcp-proxy/**'], + }, +});