From 9866926672e05a4a3f1530cae3e5c378e7658fb4 Mon Sep 17 00:00:00 2001 From: Vladislav Kartashov Date: Tue, 26 May 2026 15:18:30 +0300 Subject: [PATCH 01/10] =?UTF-8?q?PASTE-1994=20Pure-JS=20bridge:=20discover?= =?UTF-8?q?=20+=20OAuth=20+=20stdio=E2=86=94HTTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@pasteapp/mcp` is a stateless stdio↔HTTP MCP bridge written in TypeScript. It runs the full canonical OAuth dance against Paste's loopback MCP server (RFC 9728 + RFC 8414 + RFC 7591 dynamic client registration + PKCE authorization-code flow), caches the resulting access token on disk, and forwards JSON-RPC frames between stdio (Claude Desktop, Cursor, etc.) and Paste's HTTP `/mcp` endpoint. - `discover.ts` — reads `mcpPort` from Paste's UserDefaults via `defaults read` (App Store container plist or non-sandbox plist, probed in parallel), returns `http://127.0.0.1:/mcp` or null. - `oauth/{pkce,callback,store,client}.ts`: - PKCE per RFC 7636. - A one-shot HTTP callback listener whose `state` token is server-validated (rejects favicon, scans, and forged callbacks with the wrong state). `shutdown()` force-closes keep-alive sockets so the flow never hangs in its `finally`. - Atomic mode-0600 JSON token cache (writes via tmp+rename, per-call unique suffix; corrupt JSON is treated as absent, not fatal). Persists `expires_in` as an absolute `expiresAt` so we don't hand out a token that's about to die. - OAuthClient with injected `fetch`, `openBrowser`, and `startCallbackServer` for full test coverage. Runtime-validates DCR `client_id` and token `access_token` shapes (a 200 with `{}` no longer slips through). `defaultOpenBrowser` refuses any URL that isn't HTTP(S) on loopback — the AS metadata's `authorization_endpoint` is attacker-influenceable if the discovered port has been hijacked. - `transport.ts` — POSTs each NDJSON line with `Authorization: Bearer `, captures `Mcp-Session-Id`, splits SSE responses per the WHATWG spec (LF/CR/CRLF terminators, multi-`data:` concatenation with LF, BOM stripping, single-leading-SPACE strip, comments ignored), caps response bodies at 16 MiB (configurable), and on 401 invalidates the token cache and retries the same frame exactly once. Stops processing as soon as stdout is destroyed/ended — the bridge no longer crashes with `ERR_UNHANDLED_ERROR` when the host process dies. - `fallback.ts` — when discovery returns no port, serves a minimal MCP server (`paste_status` tool + `instructions` field) telling the user to start Paste and enable MCP. 74 vitest tests across 7 files cover PKCE shape, token-store roundtrip + mode 0600 + concurrent-save atomicity + corrupt-JSON + forward-compat back-fill, parallel discovery, callback timeout / favicon / empty-code / wrong-state filtering / shutdown keep-alive race, the full OAuth flow against a mock server (cache reuse, URL-mismatch invalidation, `expires_in` storage and expiry skip, runtime validation of DCR and token responses), URL scheme validation for the browser opener, WHATWG-compliant SSE parsing (CR / LF / CRLF / multi-`data:` / BOM / comments), session id capture, 202 silencing, 401 retry exactly-once, response-size cap, stdin EOF clean exit, and stdout-destroyed early termination. Paired with paste-repo PR #2594 which materializes `mcpPort` in UserDefaults on every MCP server bind so the discovery key is always present. --- package-lock.json | 3094 +++++++++++++++++++++++++++++++++++ package.json | 15 + src/discover.ts | 62 + src/fallback.ts | 65 + src/index.ts | 38 + src/oauth/callback.ts | 105 ++ src/oauth/client.ts | 265 +++ src/oauth/pkce.ts | 20 + src/oauth/store.ts | 80 + src/transport.ts | 172 ++ test/discover.test.ts | 44 + test/fallback.test.ts | 45 + test/oauth/callback.test.ts | 109 ++ test/oauth/client.test.ts | 301 ++++ test/oauth/pkce.test.ts | 38 + test/oauth/store.test.ts | 100 ++ test/transport.test.ts | 246 +++ tsconfig.json | 20 + 18 files changed, 4819 insertions(+) create mode 100644 package-lock.json create mode 100644 src/discover.ts create mode 100644 src/fallback.ts create mode 100644 src/index.ts create mode 100644 src/oauth/callback.ts create mode 100644 src/oauth/client.ts create mode 100644 src/oauth/pkce.ts create mode 100644 src/oauth/store.ts create mode 100644 src/transport.ts create mode 100644 test/discover.test.ts create mode 100644 test/fallback.test.ts create mode 100644 test/oauth/callback.test.ts create mode 100644 test/oauth/client.test.ts create mode 100644 test/oauth/pkce.test.ts create mode 100644 test/oauth/store.test.ts create mode 100644 test/transport.test.ts create mode 100644 tsconfig.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..eeea2d5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3094 @@ +{ + "name": "@pasteapp/mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@pasteapp/mcp", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.21.0" + }, + "bin": { + "paste-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "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==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "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/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "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/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "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/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/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/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/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/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/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/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "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": "2.1.9", + "@vitest/ui": "2.1.9", + "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==", + "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/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json index d38d47f..cf542a0 100644 --- a/package.json +++ b/package.json @@ -39,5 +39,20 @@ ], "engines": { "node": ">=18" + }, + "scripts": { + "build": "rm -rf dist && tsc && chmod +x dist/index.js", + "test": "vitest run", + "dev": "tsx src/index.ts", + "prepublishOnly": "npm run build && npm test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.21.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0" } } diff --git a/src/discover.ts b/src/discover.ts new file mode 100644 index 0000000..7bfb835 --- /dev/null +++ b/src/discover.ts @@ -0,0 +1,62 @@ +// Locate Paste's running MCP server by reading the `mcpPort` UserDefaults key +// from Paste's plist. We try both the sandboxed App Store container path and +// the unsandboxed Direct/Setapp path because Paste writes to either depending +// on the install channel. `defaults read` against an explicit path bypasses +// cfprefsd's cache so we always see the latest write. + +import { execFile } from 'node:child_process'; +import { homedir } from 'node:os'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +const BUNDLE_ID = 'com.wiheads.paste'; +const PORT_KEY = 'mcpPort'; + +function plistPaths(): string[] { + const home = homedir(); + return [ + `${home}/Library/Containers/${BUNDLE_ID}/Data/Library/Preferences/${BUNDLE_ID}`, + `${home}/Library/Preferences/${BUNDLE_ID}`, + ]; +} + +async function readDefault(plistPath: string, key: string): Promise { + try { + const { stdout } = await execFileAsync( + '/usr/bin/defaults', + ['read', plistPath, key], + { timeout: 1_000 }, + ); + const value = stdout.trim(); + return value.length > 0 ? value : null; + } catch { + return null; + } +} + +function parsePort(raw: string): number | null { + // `Number()` (not `parseInt`) rejects trailing junk, so a defaults value + // like "39725 oops" doesn't slip through as 39725. + const port = Number(raw); + return Number.isInteger(port) && port > 0 && port < 65_536 ? port : null; +} + +export interface DiscoverOptions { + paths?: string[]; + read?: (plistPath: string, key: string) => Promise; +} + +export async function discoverServerURL(opts: DiscoverOptions = {}): Promise { + const paths = opts.paths ?? plistPaths(); + const read = opts.read ?? readDefault; + // Read both candidates in parallel — startup latency matters because each + // bridge spawn waits on this before serving anything. + const results = await Promise.allSettled(paths.map((path) => read(path, PORT_KEY))); + for (const result of results) { + if (result.status !== 'fulfilled' || result.value === null) continue; + const port = parsePort(result.value); + if (port !== null) return new URL(`http://127.0.0.1:${port}/mcp`); + } + return null; +} diff --git a/src/fallback.ts b/src/fallback.ts new file mode 100644 index 0000000..1991798 --- /dev/null +++ b/src/fallback.ts @@ -0,0 +1,65 @@ +// Fallback MCP server served over stdio when Paste isn't reachable. Exposes +// a single tool whose only job is to tell the AI assistant (and the user) how +// to bring Paste online. We also set the server `instructions` so capable +// clients show the same hint without needing to call the tool. + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +export const SETUP_MESSAGE = + 'Paste is not running, or MCP is disabled. Start Paste and enable MCP in ' + + 'Settings → MCP & AI Tools, then restart this AI app.'; + +export const PASTE_STATUS_TOOL = { + name: 'paste_status', + description: + "Returns Paste's connection status. Call this if Paste-related tools are " + + 'missing — the response explains how to bring Paste online.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, +} as const; + +export function callPasteStatus(toolName: string): { + content: { type: 'text'; text: string }[]; + isError: boolean; +} { + if (toolName !== PASTE_STATUS_TOOL.name) { + throw new Error(`Unknown tool: ${toolName}`); + } + return { + content: [{ type: 'text', text: SETUP_MESSAGE }], + isError: false, + }; +} + +export function buildFallbackServer(): Server { + const server = new Server( + { name: 'paste-mcp', version: '0.1.0' }, + { + capabilities: { tools: {} }, + instructions: SETUP_MESSAGE, + }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [PASTE_STATUS_TOOL], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => + callPasteStatus(request.params.name), + ); + + return server; +} + +export async function serveFallback(): Promise { + const server = buildFallbackServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); + await new Promise((resolve) => { + transport.onclose = () => resolve(); + }); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..b17763e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import { discoverServerURL } from './discover.js'; +import { serveFallback } from './fallback.js'; +import { OAuthClient } from './oauth/client.js'; +import { Transport } from './transport.js'; + +// If the host (Claude / Cursor) dies, the stdout pipe goes EPIPE. Without a +// listener Node crashes the process with ERR_UNHANDLED_ERROR before the +// transport loop can react. Belt to the Transport's suspenders. +process.stdout.on('error', (err) => { + if ((err as NodeJS.ErrnoException).code === 'EPIPE') process.exit(0); +}); +process.stderr.on('error', () => { /* swallow */ }); + +async function main(): Promise { + const url = await discoverServerURL(); + if (url === null) { + await serveFallback(); + return 0; + } + const oauth = new OAuthClient(url); + const transport = new Transport({ + url, + tokenProvider: () => oauth.accessToken(), + onUnauthorized: () => oauth.invalidate(), + }); + await transport.run(process.stdin, process.stdout); + return 0; +} + +main().then( + (code) => process.exit(code), + (error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`paste-mcp: ${message}\n`); + process.exit(1); + }, +); diff --git a/src/oauth/callback.ts b/src/oauth/callback.ts new file mode 100644 index 0000000..2250d38 --- /dev/null +++ b/src/oauth/callback.ts @@ -0,0 +1,105 @@ +// Local HTTP listener that catches the OAuth authorization-code redirect. +// Binds to a random loopback port, accepts the redirect, hands back a friendly +// confirmation page, and resolves the awaiting promise with the parsed query. +// +// Defense layers against same-machine attackers who race-bind the loopback +// port: only requests that carry the per-flow `state` token AND a non-empty +// `code` or `error` settle the wait. Stray requests (favicon, scans, forged +// callbacks with the wrong state) get a benign HTML response but the wait +// stays open. The state is also exposed via `server.state` so the caller can +// embed it in the `/authorize` request. + +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { generateState } from './pkce.js'; + +export interface CallbackResult { + code: string | null; + state: string | null; + error: string | null; + errorDescription: string | null; +} + +export interface CallbackServer { + port: number; + state: string; + waitForCallback(opts?: { timeoutMs?: number }): Promise; + shutdown(): Promise; +} + +const PAGE_HTML = '' + + 'Paste connected' + + '' + + '

Paste is connected.

' + + '

You can close this tab and return to your AI app.

'; + +export async function startCallbackServer(): Promise { + const expectedState = generateState(); + let settled = false; + let resolveCallback: ((value: CallbackResult) => void) | null = null; + let rejectCallback: ((err: Error) => void) | null = null; + const pending = new Promise((res, rej) => { + resolveCallback = (value) => { + if (settled) return; + settled = true; + res(value); + }; + rejectCallback = (err) => { + if (settled) return; + settled = true; + rej(err); + }; + }); + // Avoid an unhandled rejection if nobody awaits `pending`. + pending.catch(() => {}); + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(PAGE_HTML); + const parsed = new URL(req.url ?? '/', 'http://127.0.0.1'); + const code = nullIfEmpty(parsed.searchParams.get('code')); + const state = parsed.searchParams.get('state'); + const error = nullIfEmpty(parsed.searchParams.get('error')); + const errorDescription = nullIfEmpty(parsed.searchParams.get('error_description')); + // Drop stray probes (favicon, scans) AND forged callbacks that don't carry + // our per-flow state — both classes can otherwise DoS the wait. + if (code === null && error === null) return; + if (state !== expectedState) return; + resolveCallback?.({ code, state, error, errorDescription }); + }); + + await new Promise((res) => server.listen(0, '127.0.0.1', res)); + const port = (server.address() as AddressInfo).port; + + return { + port, + state: expectedState, + async waitForCallback({ timeoutMs = 300_000 } = {}): Promise { + const timer = setTimeout(() => { + rejectCallback?.(new Error('OAuth callback timed out')); + }, timeoutMs); + try { + return await pending; + } finally { + clearTimeout(timer); + } + }, + async shutdown(): Promise { + // If the flow aborted before a callback arrived, settle the promise so + // any straggling awaiter unwinds instead of hanging forever. + rejectCallback?.(new Error('Callback server shut down')); + // Browsers keep the success-page connection alive — `server.close()` on + // its own waits for those sockets to time out. Force them shut so the + // shutdown returns promptly. + server.closeAllConnections(); + await new Promise((res) => server.close(() => res())); + }, + }; +} + +function nullIfEmpty(value: string | null): string | null { + return value === null || value === '' ? null : value; +} diff --git a/src/oauth/client.ts b/src/oauth/client.ts new file mode 100644 index 0000000..e1afbcd --- /dev/null +++ b/src/oauth/client.ts @@ -0,0 +1,265 @@ +// OAuth client for Paste's loopback MCP server. Implements the canonical MCP +// authorization dance: RFC 9728 protected-resource metadata → RFC 8414 auth +// server metadata → RFC 7591 dynamic client registration → authorization code +// + PKCE → token exchange. Tokens cached on disk between invocations. + +import { spawn } from 'node:child_process'; +import { startCallbackServer, type CallbackServer } from './callback.js'; +import { generatePKCE } from './pkce.js'; +import { TokenStore, type StoredToken } from './store.js'; + +interface AuthServerMetadata { + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint: string; +} + +interface ResourceMetadata { + authorization_servers?: string[]; +} + +interface RegistrationResponse { + client_id: string; +} + +interface TokenResponse { + access_token: string; + expires_in?: number; +} + +const CLIENT_NAME = 'Paste MCP Bridge'; +const TOKEN_ERROR_BODY_CAP = 500; + +export type StartCallbackServer = () => Promise; +export type OpenBrowser = (url: string) => void | Promise; + +export interface OAuthClientOptions { + fetch?: typeof fetch; + openBrowser?: OpenBrowser; + startCallbackServer?: StartCallbackServer; + now?: () => Date; +} + +export class OAuthClient { + private readonly fetchImpl: typeof fetch; + private readonly openBrowserImpl: OpenBrowser; + private readonly startCallbackServerImpl: StartCallbackServer; + private readonly nowImpl: () => Date; + + constructor( + public readonly serverURL: URL, + private readonly store: TokenStore = new TokenStore(), + opts: OAuthClientOptions = {}, + ) { + this.fetchImpl = opts.fetch ?? fetch; + this.openBrowserImpl = opts.openBrowser ?? defaultOpenBrowser; + this.startCallbackServerImpl = opts.startCallbackServer ?? startCallbackServer; + this.nowImpl = opts.now ?? (() => new Date()); + } + + async accessToken(): Promise { + const cached = await this.store.load(); + if (cached && cached.accessToken && cached.serverURL === this.serverURL.toString() && !this.isExpired(cached)) { + return cached.accessToken; + } + return await this.runFlow(); + } + + async invalidate(): Promise { + await this.store.clear(); + } + + private isExpired(token: StoredToken): boolean { + if (token.expiresAt === null) return false; + const expiresAt = Date.parse(token.expiresAt); + if (!Number.isFinite(expiresAt)) return false; + // 30-second skew so we don't hand out a token that's about to die in flight. + return expiresAt <= this.nowImpl().getTime() + 30_000; + } + + private async runFlow(): Promise { + const metadata = await discoverAuthServer(this.serverURL, this.fetchImpl); + const callback = await this.startCallbackServerImpl(); + const redirectUri = `http://127.0.0.1:${callback.port}/cb`; + try { + const clientId = await registerClient(metadata, redirectUri, this.fetchImpl); + const { accessToken, expiresIn } = await this.authorize(metadata, clientId, redirectUri, callback); + const expiresAt = expiresIn != null + ? new Date(this.nowImpl().getTime() + expiresIn * 1_000).toISOString() + : null; + const stored: StoredToken = { + serverURL: this.serverURL.toString(), + clientId, + accessToken, + expiresAt, + createdAt: this.nowImpl().toISOString(), + }; + await this.store.save(stored); + return accessToken; + } finally { + await callback.shutdown(); + } + } + + private async authorize( + metadata: AuthServerMetadata, + clientId: string, + redirectUri: string, + callback: CallbackServer, + ): Promise<{ accessToken: string; expiresIn?: number }> { + const pkce = generatePKCE(); + await this.openBrowserImpl(buildAuthURL(metadata, clientId, redirectUri, pkce.challenge, callback.state)); + + const result = await callback.waitForCallback(); + if (result.error) { + const detail = result.errorDescription ? ` — ${result.errorDescription}` : ''; + throw new Error(`OAuth error: ${result.error}${detail}`); + } + if (!result.code) throw new Error('OAuth callback missing `code`'); + // Belt-and-braces: CallbackServer enforces state too, but a mismatch here + // means the callback contract drifted — fail loud rather than proceed. + if (result.state !== callback.state) throw new Error('OAuth state mismatch'); + + return await exchangeCode(metadata, clientId, result.code, redirectUri, pkce.verifier, this.fetchImpl); + } +} + +async function discoverAuthServer(serverURL: URL, fetchImpl: typeof fetch): Promise { + const origin = serverURL.origin; + let authBase = origin; + try { + const meta = await fetchJSON( + `${origin}/.well-known/oauth-protected-resource`, + fetchImpl, + ); + const first = meta.authorization_servers?.[0]; + if (first) authBase = first.replace(/\/$/, ''); + } catch { + // Resource metadata is optional per the spec — fall back to assuming the + // server is its own authorization server (Paste's setup). + } + const metadata = await fetchJSON( + `${authBase}/.well-known/oauth-authorization-server`, + fetchImpl, + ); + if ( + typeof metadata.authorization_endpoint !== 'string' + || typeof metadata.token_endpoint !== 'string' + || typeof metadata.registration_endpoint !== 'string' + ) { + throw new Error('Authorization server metadata is missing required endpoints'); + } + return metadata; +} + +async function registerClient( + metadata: AuthServerMetadata, + redirectUri: string, + fetchImpl: typeof fetch, +): Promise { + const response = await fetchImpl(metadata.registration_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + client_name: CLIENT_NAME, + redirect_uris: [redirectUri], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code'], + response_types: ['code'], + }), + }); + if (!response.ok) { + throw new Error(`Dynamic client registration failed: HTTP ${response.status}`); + } + const data = (await response.json()) as RegistrationResponse; + if (typeof data.client_id !== 'string' || data.client_id.length === 0) { + throw new Error('Dynamic client registration response missing `client_id`'); + } + return data.client_id; +} + +async function exchangeCode( + metadata: AuthServerMetadata, + clientId: string, + code: string, + redirectUri: string, + verifier: string, + fetchImpl: typeof fetch, +): Promise<{ accessToken: string; expiresIn?: number }> { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: verifier, + }).toString(); + const response = await fetchImpl(metadata.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, + body, + }); + if (!response.ok) { + // Capping the error body keeps verifier/code material out of stderr if + // the AS ever echoes them back in a regression. + const detail = (await response.text()).slice(0, TOKEN_ERROR_BODY_CAP); + throw new Error(`Token exchange failed: HTTP ${response.status}: ${detail}`); + } + const data = (await response.json()) as TokenResponse; + if (typeof data.access_token !== 'string' || data.access_token.length === 0) { + throw new Error('Token response missing `access_token`'); + } + return { + accessToken: data.access_token, + ...(typeof data.expires_in === 'number' && data.expires_in > 0 + ? { expiresIn: data.expires_in } + : {}), + }; +} + +function buildAuthURL( + metadata: AuthServerMetadata, + clientId: string, + redirectUri: string, + challenge: string, + state: string, +): string { + const url = new URL(metadata.authorization_endpoint); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('code_challenge', challenge); + url.searchParams.set('code_challenge_method', 'S256'); + url.searchParams.set('state', state); + return url.toString(); +} + +// Refuse to launch anything but an HTTP(S) URL pointing at loopback. An +// attacker who took over the discovered port could otherwise feed us an +// arbitrary URL handler (file:, vscode:, javascript:, etc.) via the AS +// metadata's `authorization_endpoint`. Exported for direct unit testing. +export function assertLoopbackHTTPURL(url: string): URL { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Refusing to open non-HTTP(S) URL: ${parsed.protocol}`); + } + if (parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost' && parsed.hostname !== '::1') { + throw new Error(`Refusing to open non-loopback URL: ${parsed.hostname}`); + } + return parsed; +} + +function defaultOpenBrowser(url: string): void { + assertLoopbackHTTPURL(url); + const child = spawn('/usr/bin/open', [url], { stdio: 'ignore', detached: true }); + // Without this listener, a missing `/usr/bin/open` (non-macOS) would crash + // the process with ERR_UNHANDLED_ERROR; here it fails silently and the + // OAuth flow surfaces "callback timed out" instead. + child.on('error', () => { /* swallow */ }); + child.unref(); +} + +async function fetchJSON(url: string, fetchImpl: typeof fetch, init?: RequestInit): Promise { + const response = await fetchImpl(url, init); + if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${url}`); + return (await response.json()) as T; +} diff --git a/src/oauth/pkce.ts b/src/oauth/pkce.ts new file mode 100644 index 0000000..b10c6df --- /dev/null +++ b/src/oauth/pkce.ts @@ -0,0 +1,20 @@ +import { createHash, randomBytes } from 'node:crypto'; + +export interface PKCEPair { + verifier: string; + challenge: string; +} + +function base64URL(buf: Buffer): string { + return buf.toString('base64url'); +} + +export function generatePKCE(): PKCEPair { + const verifier = base64URL(randomBytes(32)); + const challenge = base64URL(createHash('sha256').update(verifier).digest()); + return { verifier, challenge }; +} + +export function generateState(): string { + return base64URL(randomBytes(16)); +} diff --git a/src/oauth/store.ts b/src/oauth/store.ts new file mode 100644 index 0000000..14bb604 --- /dev/null +++ b/src/oauth/store.ts @@ -0,0 +1,80 @@ +// On-disk cache of an OAuth client_id + access token, keyed implicitly to the +// server URL it was issued for. Mode 0600 — same convention as ~/.pgpass, +// ~/.aws/credentials, mcp-remote's ~/.mcp-auth, and ssh keys. + +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname } from 'node:path'; + +export interface StoredToken { + serverURL: string; + clientId: string; + accessToken: string; + /// ISO 8601 — when the AS-issued `expires_in` runs out. `null` if the AS + /// didn't return one (treat as non-expiring). + expiresAt: string | null; + createdAt: string; +} + +const DEFAULT_PATH = `${homedir()}/Library/Application Support/paste-mcp/tokens.json`; + +export class TokenStore { + constructor(public readonly path: string = DEFAULT_PATH) {} + + async load(): Promise { + let data: string; + try { + data = await fs.readFile(this.path, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } + // A corrupt cache (partial write, garbage on disk) should not crash the + // bridge — treat it as absent so the OAuth flow runs and overwrites. + let parsed: Partial; + try { + parsed = JSON.parse(data) as Partial; + } catch { + return null; + } + if ( + typeof parsed.serverURL !== 'string' + || typeof parsed.clientId !== 'string' + || typeof parsed.accessToken !== 'string' + ) { + return null; + } + return { + serverURL: parsed.serverURL, + clientId: parsed.clientId, + accessToken: parsed.accessToken, + expiresAt: typeof parsed.expiresAt === 'string' ? parsed.expiresAt : null, + createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : new Date().toISOString(), + }; + } + + async save(token: StoredToken): Promise { + await fs.mkdir(dirname(this.path), { recursive: true }); + // Atomic write: stage to a unique temp file then rename. POSIX rename is + // atomic within a filesystem, so a reader never sees a partial file. + // The suffix is per-call (not per-pid) so concurrent saves in the same + // process don't collide on the temp path. + const tmp = `${this.path}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`; + try { + await fs.writeFile(tmp, JSON.stringify(token, null, 2), { mode: 0o600 }); + await fs.chmod(tmp, 0o600); // umask-resistant + await fs.rename(tmp, this.path); + } catch (err) { + try { await fs.unlink(tmp); } catch { /* best effort */ } + throw err; + } + } + + async clear(): Promise { + try { + await fs.unlink(this.path); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + } +} diff --git a/src/transport.ts b/src/transport.ts new file mode 100644 index 0000000..18e5614 --- /dev/null +++ b/src/transport.ts @@ -0,0 +1,172 @@ +// Stateful stdio↔HTTP bridge with an injected token provider. Mirrors +// PasteMCP/Stdio/StdioAdapter.swift (Streamable HTTP transport): NDJSON in +// from stdin, POST each frame to /mcp with Bearer + Mcp-Session-Id, emit +// JSON or SSE-decoded payloads on stdout. On 401 we ask the caller to +// invalidate its token cache and retry the same frame once. + +import { createInterface } from 'node:readline'; +import type { Writable } from 'node:stream'; + +export interface TransportConfig { + url: URL; + tokenProvider: () => Promise; + onUnauthorized?: () => Promise; + fetch?: typeof fetch; + timeoutMs?: number; + /// Cap on a single response body — defense against a runaway server. Default + /// 16 MiB. We bail (without consuming the body) when Content-Length exceeds + /// the cap; servers that omit Content-Length get the body trusted as-is. + maxResponseBytes?: number; +} + +const DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024; + +export class Transport { + private sessionId: string | undefined; + private readonly fetchImpl: typeof fetch; + private readonly timeoutMs: number; + private readonly maxResponseBytes: number; + + constructor(private readonly config: TransportConfig) { + this.fetchImpl = config.fetch ?? fetch; + this.timeoutMs = config.timeoutMs ?? 30_000; + this.maxResponseBytes = config.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES; + } + + async run(input: NodeJS.ReadableStream, output: Writable): Promise { + // When the host (Claude, Cursor) dies, the stdout pipe goes EPIPE. The + // listener prevents `ERR_UNHANDLED_ERROR`; `output.destroyed` (set + // synchronously by Node when the stream tears down) is what we actually + // check between frames to exit promptly. + output.on('error', () => { /* swallow; we read `destroyed` instead */ }); + + const rl = createInterface({ input, terminal: false }); + for await (const line of rl) { + if (output.destroyed || output.writableEnded) { + rl.close(); + return; + } + if (line.length === 0) continue; + const responses = await this.forward(line); + for (const payload of responses) { + if (output.destroyed || output.writableEnded) return; + output.write(payload + '\n'); + } + } + } + + async forward(line: string): Promise { + try { + const first = await this.sendOnce(line, await this.config.tokenProvider()); + if (first.status === 401 && this.config.onUnauthorized) { + await this.config.onUnauthorized(); + const second = await this.sendOnce(line, await this.config.tokenProvider()); + return this.emit(second); + } + return this.emit(first); + } catch (error) { + return [transportErrorEnvelope(line, error)]; + } + } + + private async sendOnce(line: string, token: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Authorization: `Bearer ${token}`, + }; + if (this.sessionId) headers['Mcp-Session-Id'] = this.sessionId; + const response = await this.fetchImpl(this.config.url, { + method: 'POST', + headers, + body: line, + signal: controller.signal, + }); + this.captureSessionId(response.headers); + const declaredLength = Number.parseInt(response.headers.get('content-length') ?? '', 10); + if (Number.isFinite(declaredLength) && declaredLength > this.maxResponseBytes) { + throw new Error(`Response body declares ${declaredLength} bytes (cap ${this.maxResponseBytes})`); + } + const body = await response.text(); + return { + status: response.status, + contentType: response.headers.get('content-type') ?? '', + body, + }; + } finally { + clearTimeout(timer); + } + } + + private captureSessionId(headers: Headers): void { + if (this.sessionId) return; + const returned = headers.get('Mcp-Session-Id'); + if (returned && returned.length > 0) this.sessionId = returned; + } + + private emit({ status, contentType, body }: TransportResponse): string[] { + // 202 Accepted is a one-way notification ack — emitting anything on stdout + // would confuse the host's NDJSON parser. + if (status === 202) return []; + if (body.length === 0) return []; + if (!contentType.toLowerCase().startsWith('text/event-stream')) { + return [body]; + } + return parseSSE(body); + } +} + +interface TransportResponse { + status: number; + contentType: string; + body: string; +} + +// WHATWG-compliant SSE decoder: handles CR/LF/CRLF terminators, multi-`data:` +// concatenation with U+000A, a leading-SPACE strip per line, BOM stripping, +// and ignores `event:`/`id:`/`retry:`/comment lines. Each event with a +// non-empty data buffer becomes one stdout line. +export function parseSSE(body: string): string[] { + let stream = body; + if (stream.charCodeAt(0) === 0xFEFF) stream = stream.slice(1); + // Normalize line terminators so an event boundary is always `\n\n`. + const normalized = stream.replace(/\r\n|\r/g, '\n'); + const messages: string[] = []; + for (const event of normalized.split('\n\n')) { + if (event === '') continue; + const dataLines: string[] = []; + for (const rawLine of event.split('\n')) { + if (rawLine === '' || rawLine.startsWith(':')) continue; + const colon = rawLine.indexOf(':'); + const field = colon === -1 ? rawLine : rawLine.slice(0, colon); + let value = colon === -1 ? '' : rawLine.slice(colon + 1); + if (value.startsWith(' ')) value = value.slice(1); + if (field === 'data') dataLines.push(value); + } + if (dataLines.length === 0) continue; + messages.push(dataLines.join('\n')); + } + return messages; +} + +function transportErrorEnvelope(requestLine: string, error: unknown): string { + const id = extractId(requestLine); + const message = error instanceof Error ? error.message : String(error); + return JSON.stringify({ + jsonrpc: '2.0', + id: id ?? null, + error: { code: -32603, message: `Transport error: ${message}` }, + }); +} + +function extractId(line: string): unknown { + try { + const parsed = JSON.parse(line) as Record; + return parsed.id; + } catch { + return undefined; + } +} diff --git a/test/discover.test.ts b/test/discover.test.ts new file mode 100644 index 0000000..db5d23b --- /dev/null +++ b/test/discover.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { discoverServerURL } from '../src/discover.js'; + +describe('discoverServerURL', () => { + it('returns http://127.0.0.1:/mcp from the first path that yields a port', async () => { + const url = await discoverServerURL({ + paths: ['/container.plist', '/preferences.plist'], + read: async (path) => (path === '/container.plist' ? '54321' : null), + }); + expect(url?.toString()).toBe('http://127.0.0.1:54321/mcp'); + }); + + it('falls through to the next path when the first is absent', async () => { + const url = await discoverServerURL({ + paths: ['/container.plist', '/preferences.plist'], + read: async (path) => (path === '/preferences.plist' ? '39725' : null), + }); + expect(url?.toString()).toBe('http://127.0.0.1:39725/mcp'); + }); + + it('returns null when no path yields a port', async () => { + const url = await discoverServerURL({ + paths: ['/a', '/b'], + read: async () => null, + }); + expect(url).toBeNull(); + }); + + it('rejects out-of-range ports', async () => { + const url = await discoverServerURL({ + paths: ['/a'], + read: async () => '99999', + }); + expect(url).toBeNull(); + }); + + it('rejects non-numeric port values', async () => { + const url = await discoverServerURL({ + paths: ['/a'], + read: async () => 'not-a-port', + }); + expect(url).toBeNull(); + }); +}); diff --git a/test/fallback.test.ts b/test/fallback.test.ts new file mode 100644 index 0000000..c2ea2d5 --- /dev/null +++ b/test/fallback.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { + PASTE_STATUS_TOOL, + SETUP_MESSAGE, + buildFallbackServer, + callPasteStatus, +} from '../src/fallback.js'; + +describe('callPasteStatus', () => { + it('returns the setup message for paste_status', () => { + const result = callPasteStatus(PASTE_STATUS_TOOL.name); + expect(result.isError).toBe(false); + expect(result.content).toHaveLength(1); + expect(result.content[0]).toEqual({ type: 'text', text: SETUP_MESSAGE }); + }); + + it('tells the user to start Paste and enable MCP', () => { + expect(SETUP_MESSAGE.toLowerCase()).toContain('start paste'); + expect(SETUP_MESSAGE.toLowerCase()).toContain('enable mcp'); + }); + + it('throws on unknown tool names', () => { + expect(() => callPasteStatus('paste_unknown')).toThrow(/Unknown tool/); + }); +}); + +describe('PASTE_STATUS_TOOL', () => { + it('is named paste_status with an empty object schema', () => { + expect(PASTE_STATUS_TOOL.name).toBe('paste_status'); + expect(PASTE_STATUS_TOOL.inputSchema.type).toBe('object'); + expect(PASTE_STATUS_TOOL.inputSchema.properties).toEqual({}); + expect(PASTE_STATUS_TOOL.inputSchema.additionalProperties).toBe(false); + }); + + it('has a description that mentions Paste', () => { + expect(PASTE_STATUS_TOOL.description.toLowerCase()).toContain('paste'); + }); +}); + +describe('buildFallbackServer', () => { + it('constructs a server without throwing', () => { + const server = buildFallbackServer(); + expect(server).toBeDefined(); + }); +}); diff --git a/test/oauth/callback.test.ts b/test/oauth/callback.test.ts new file mode 100644 index 0000000..99c3631 --- /dev/null +++ b/test/oauth/callback.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { startCallbackServer } from '../../src/oauth/callback.js'; + +async function hit(port: number, path: string): Promise { + return await fetch(`http://127.0.0.1:${port}${path}`); +} + +describe('startCallbackServer', () => { + it('exposes a per-flow state and resolves only when the callback carries it', async () => { + const cb = await startCallbackServer(); + expect(cb.state).toMatch(/^[A-Za-z0-9_-]+$/); + const fetched = hit(cb.port, `/cb?code=abc123&state=${encodeURIComponent(cb.state)}`); + const result = await cb.waitForCallback(); + await fetched; + expect(result).toEqual({ + code: 'abc123', + state: cb.state, + error: null, + errorDescription: null, + }); + await cb.shutdown(); + }); + + it('resolves with error when the user denies consent (with matching state)', async () => { + const cb = await startCallbackServer(); + const fetched = hit( + cb.port, + `/cb?error=access_denied&error_description=user+said+no&state=${encodeURIComponent(cb.state)}`, + ); + const result = await cb.waitForCallback(); + await fetched; + expect(result.error).toBe('access_denied'); + expect(result.errorDescription).toBe('user said no'); + expect(result.code).toBeNull(); + await cb.shutdown(); + }); + + it('ignores favicon / scan requests', async () => { + const cb = await startCallbackServer(); + const faviconResp = await hit(cb.port, '/favicon.ico'); + expect(faviconResp.status).toBe(200); + // The wait should still be pending. + const racer = Promise.race([ + cb.waitForCallback({ timeoutMs: 5_000 }).then(() => 'resolved'), + new Promise((res) => setTimeout(() => res('still-waiting'), 100)), + ]); + expect(await racer).toBe('still-waiting'); + await hit(cb.port, `/cb?code=ok&state=${encodeURIComponent(cb.state)}`); + await cb.shutdown(); + }); + + it('ignores forged callbacks that omit `code` (e.g. `?code=&state=…`)', async () => { + const cb = await startCallbackServer(); + await hit(cb.port, `/cb?code=&state=${encodeURIComponent(cb.state)}`); + const racer = Promise.race([ + cb.waitForCallback({ timeoutMs: 5_000 }).then(() => 'resolved'), + new Promise((res) => setTimeout(() => res('still-waiting'), 100)), + ]); + expect(await racer).toBe('still-waiting'); + await hit(cb.port, `/cb?code=real&state=${encodeURIComponent(cb.state)}`); + await cb.shutdown(); + }); + + it('ignores callbacks with the wrong state (forged by a loopback attacker)', async () => { + const cb = await startCallbackServer(); + await hit(cb.port, `/cb?code=attacker&state=guessed-wrong`); + const racer = Promise.race([ + cb.waitForCallback({ timeoutMs: 5_000 }).then(() => 'resolved'), + new Promise((res) => setTimeout(() => res('still-waiting'), 100)), + ]); + expect(await racer).toBe('still-waiting'); + await hit(cb.port, `/cb?code=real&state=${encodeURIComponent(cb.state)}`); + await cb.shutdown(); + }); + + it('times out when no callback arrives within timeoutMs', async () => { + const cb = await startCallbackServer(); + await expect(cb.waitForCallback({ timeoutMs: 50 })).rejects.toThrow(/timed out/i); + await cb.shutdown(); + }); + + it('shutdown before any request settles a pending awaiter', async () => { + const cb = await startCallbackServer(); + const wait = cb.waitForCallback({ timeoutMs: 60_000 }); + await cb.shutdown(); + await expect(wait).rejects.toThrow(/shut down/i); + }); + + it('shutdown closes keep-alive connections promptly (no hang)', async () => { + const cb = await startCallbackServer(); + const fetched = hit(cb.port, `/cb?code=ok&state=${encodeURIComponent(cb.state)}`); + await cb.waitForCallback(); + await fetched; + // Without `closeAllConnections()` this would block until the kept-alive + // socket's idle timer fires. Cap with a fail-fast race. + const shutdownDone = cb.shutdown().then(() => 'shutdown'); + const ticked = new Promise((res) => setTimeout(() => res('timed-out'), 2_000)); + expect(await Promise.race([shutdownDone, ticked])).toBe('shutdown'); + }); + + it('shutdown is idempotent', async () => { + const cb = await startCallbackServer(); + const fetched = hit(cb.port, `/cb?code=ok&state=${encodeURIComponent(cb.state)}`); + await cb.waitForCallback(); + await fetched; + await expect(cb.shutdown()).resolves.toBeUndefined(); + await expect(cb.shutdown()).resolves.toBeUndefined(); + }); +}); diff --git a/test/oauth/client.test.ts b/test/oauth/client.test.ts new file mode 100644 index 0000000..69b7e42 --- /dev/null +++ b/test/oauth/client.test.ts @@ -0,0 +1,301 @@ +import { promises as fs } from 'node:fs'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { OAuthClient, assertLoopbackHTTPURL } from '../../src/oauth/client.js'; +import { TokenStore, type StoredToken } from '../../src/oauth/store.js'; + +interface MockOAuthServer { + url: string; + registerCalls: number; + tokenCalls: number; + lastTokenForm: URLSearchParams | null; + setNextCode(code: string): void; + setTokenResponse(body: object): void; + setRegisterResponse(body: object): void; + shutdown(): Promise; +} + +async function startMockOAuthServer(): Promise { + const state = { + nextCode: 'mock-code-1', + registerCalls: 0, + tokenCalls: 0, + lastTokenForm: null as URLSearchParams | null, + tokenResponse: null as object | null, + registerResponse: null as object | null, + }; + + const server = createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host}`); + const base = `http://${req.headers.host}`; + + if (req.method === 'GET' && url.pathname === '/.well-known/oauth-protected-resource') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ resource: base, authorization_servers: [base] })); + return; + } + if (req.method === 'GET' && url.pathname === '/.well-known/oauth-authorization-server') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + issuer: base, + authorization_endpoint: `${base}/authorize`, + token_endpoint: `${base}/token`, + registration_endpoint: `${base}/register`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code'], + code_challenge_methods_supported: ['S256'], + })); + return; + } + if (req.method === 'POST' && url.pathname === '/register') { + state.registerCalls += 1; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(state.registerResponse ?? { client_id: `mock-client-${state.registerCalls}` })); + return; + } + if (req.method === 'GET' && url.pathname === '/authorize') { + const redirectUri = url.searchParams.get('redirect_uri')!; + const stateParam = url.searchParams.get('state') ?? ''; + const target = new URL(redirectUri); + target.searchParams.set('code', state.nextCode); + target.searchParams.set('state', stateParam); + res.writeHead(302, { Location: target.toString() }); + res.end(); + return; + } + if (req.method === 'POST' && url.pathname === '/token') { + state.tokenCalls += 1; + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + state.lastTokenForm = new URLSearchParams(Buffer.concat(chunks).toString('utf8')); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(state.tokenResponse ?? { + access_token: `tok-for-${state.lastTokenForm.get('code')}`, + token_type: 'Bearer', + })); + return; + } + res.writeHead(404).end(); + }); + + await new Promise((res) => server.listen(0, '127.0.0.1', res)); + const port = (server.address() as AddressInfo).port; + + return { + url: `http://127.0.0.1:${port}`, + get registerCalls() { return state.registerCalls; }, + get tokenCalls() { return state.tokenCalls; }, + get lastTokenForm() { return state.lastTokenForm; }, + setNextCode(code) { state.nextCode = code; }, + setTokenResponse(body) { state.tokenResponse = body; }, + setRegisterResponse(body) { state.registerResponse = body; }, + async shutdown() { + await new Promise((res) => server.close(() => res())); + }, + }; +} + +function simulatedBrowser(): (url: string) => Promise { + return async (url: string) => { + const response = await fetch(url, { redirect: 'manual' }); + const location = response.headers.get('location'); + if (location) await fetch(location); + }; +} + +function makeStored(overrides: Partial = {}): StoredToken { + return { + serverURL: 'http://127.0.0.1:99999/mcp', + clientId: 'old', + accessToken: 'old-token', + expiresAt: null, + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +describe('OAuthClient', () => { + let dir: string; + let store: TokenStore; + let mock: Awaited>; + + beforeEach(async () => { + dir = await fs.mkdtemp(join(tmpdir(), 'paste-mcp-oauth-')); + store = new TokenStore(join(dir, 'tokens.json')); + mock = await startMockOAuthServer(); + }); + + afterEach(async () => { + await mock.shutdown(); + await fs.rm(dir, { recursive: true, force: true }); + }); + + it('runs the full DCR + PKCE flow on first call, caches the token', async () => { + const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { + openBrowser: simulatedBrowser(), + }); + const token = await client.accessToken(); + expect(token).toBe('tok-for-mock-code-1'); + expect(mock.registerCalls).toBe(1); + expect(mock.tokenCalls).toBe(1); + + const cached = await store.load(); + expect(cached?.serverURL).toBe(`${mock.url}/mcp`); + expect(cached?.accessToken).toBe('tok-for-mock-code-1'); + expect(cached?.clientId).toBe('mock-client-1'); + expect(cached?.expiresAt).toBeNull(); // no expires_in in default response + const stat = await fs.stat(store.path); + expect(stat.mode & 0o777).toBe(0o600); + + const verifier = mock.lastTokenForm?.get('code_verifier') ?? ''; + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); + expect(verifier.length).toBeGreaterThanOrEqual(43); + }); + + it('reuses the cached token on subsequent calls — no second OAuth round-trip', async () => { + const open = vi.fn(simulatedBrowser()); + const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { openBrowser: open }); + await client.accessToken(); + expect(open).toHaveBeenCalledTimes(1); + expect(mock.tokenCalls).toBe(1); + + const second = await client.accessToken(); + expect(second).toBe('tok-for-mock-code-1'); + expect(open).toHaveBeenCalledTimes(1); + expect(mock.tokenCalls).toBe(1); + }); + + it('ignores a cached token that was issued for a different server URL', async () => { + await store.save(makeStored()); + const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { + openBrowser: simulatedBrowser(), + }); + const token = await client.accessToken(); + expect(token).toBe('tok-for-mock-code-1'); + expect((await store.load())?.serverURL).toBe(`${mock.url}/mcp`); + }); + + it('invalidate() clears the cache so the next call re-runs OAuth', async () => { + const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { + openBrowser: simulatedBrowser(), + }); + await client.accessToken(); + await client.invalidate(); + expect(await store.load()).toBeNull(); + + mock.setNextCode('mock-code-2'); + const token = await client.accessToken(); + expect(token).toBe('tok-for-mock-code-2'); + expect(mock.registerCalls).toBe(2); + }); + + it('throws when the OAuth provider returns an error in the callback', async () => { + const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { + openBrowser: async (authUrlStr) => { + // Skip /authorize entirely — hit the callback directly with `error`. + const authURL = new URL(authUrlStr); + const redirectUri = authURL.searchParams.get('redirect_uri')!; + const stateParam = authURL.searchParams.get('state') ?? ''; + const cb = new URL(redirectUri); + cb.searchParams.set('error', 'access_denied'); + cb.searchParams.set('error_description', 'user denied'); + cb.searchParams.set('state', stateParam); + await fetch(cb.toString()); + }, + }); + await expect(client.accessToken()).rejects.toThrow(/access_denied/); + expect(await store.load()).toBeNull(); + }); + + it('persists expires_in from the token response as an absolute expiresAt', async () => { + mock.setTokenResponse({ access_token: 'tok', expires_in: 3600, token_type: 'Bearer' }); + const fixedNow = new Date('2026-05-26T12:00:00Z'); + const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { + openBrowser: simulatedBrowser(), + now: () => fixedNow, + }); + await client.accessToken(); + const cached = await store.load(); + expect(cached?.expiresAt).toBe('2026-05-26T13:00:00.000Z'); + }); + + it('treats an expired cached token as absent and re-runs OAuth', async () => { + let now = new Date('2026-05-26T12:00:00Z'); + const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { + openBrowser: simulatedBrowser(), + now: () => now, + }); + mock.setTokenResponse({ access_token: 'first', expires_in: 3600, token_type: 'Bearer' }); + const first = await client.accessToken(); + expect(first).toBe('first'); + + // Advance past expiry (with the 30s skew). + now = new Date('2026-05-26T14:00:00Z'); + mock.setTokenResponse({ access_token: 'second', expires_in: 3600, token_type: 'Bearer' }); + const second = await client.accessToken(); + expect(second).toBe('second'); + expect(mock.tokenCalls).toBe(2); + }); + + it('still uses an unexpired cached token', async () => { + let now = new Date('2026-05-26T12:00:00Z'); + const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { + openBrowser: simulatedBrowser(), + now: () => now, + }); + mock.setTokenResponse({ access_token: 'fresh', expires_in: 3600, token_type: 'Bearer' }); + await client.accessToken(); + now = new Date('2026-05-26T12:30:00Z'); // 30 min in, well within expiry + expect(await client.accessToken()).toBe('fresh'); + expect(mock.tokenCalls).toBe(1); + }); + + it('throws when DCR returns 200 with no client_id', async () => { + mock.setRegisterResponse({}); + const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { + openBrowser: simulatedBrowser(), + }); + await expect(client.accessToken()).rejects.toThrow(/client_id/); + }); + + it('throws when /token returns 200 with no access_token', async () => { + mock.setTokenResponse({}); + const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { + openBrowser: simulatedBrowser(), + }); + await expect(client.accessToken()).rejects.toThrow(/access_token/); + }); +}); + +describe('assertLoopbackHTTPURL', () => { + it('accepts http://127.0.0.1', () => { + expect(() => assertLoopbackHTTPURL('http://127.0.0.1:5454/authorize')).not.toThrow(); + }); + + it('accepts http://localhost', () => { + expect(() => assertLoopbackHTTPURL('http://localhost:5454/authorize')).not.toThrow(); + }); + + it('rejects javascript: URLs (attacker-supplied auth metadata)', () => { + expect(() => assertLoopbackHTTPURL('javascript:alert(1)')).toThrow(/non-HTTP/); + }); + + it('rejects file: URLs', () => { + expect(() => assertLoopbackHTTPURL('file:///etc/passwd')).toThrow(/non-HTTP/); + }); + + it('rejects vscode: and other custom URL handlers', () => { + expect(() => assertLoopbackHTTPURL('vscode://settings')).toThrow(/non-HTTP/); + }); + + it('rejects HTTPS to a non-loopback host', () => { + expect(() => assertLoopbackHTTPURL('https://evil.example.com/authorize')).toThrow(/non-loopback/); + }); + + it('rejects http://0.0.0.0 (not loopback)', () => { + expect(() => assertLoopbackHTTPURL('http://0.0.0.0/authorize')).toThrow(/non-loopback/); + }); +}); diff --git a/test/oauth/pkce.test.ts b/test/oauth/pkce.test.ts new file mode 100644 index 0000000..83ac7f1 --- /dev/null +++ b/test/oauth/pkce.test.ts @@ -0,0 +1,38 @@ +import { createHash } from 'node:crypto'; +import { describe, it, expect } from 'vitest'; +import { generatePKCE, generateState } from '../../src/oauth/pkce.js'; + +describe('generatePKCE', () => { + it('produces a verifier within the RFC 7636 length range', () => { + const { verifier } = generatePKCE(); + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(verifier.length).toBeLessThanOrEqual(128); + }); + + it('produces a base64url verifier (no +/=)', () => { + const { verifier } = generatePKCE(); + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('produces a challenge that is the base64url SHA-256 of the verifier', () => { + const { verifier, challenge } = generatePKCE(); + const expected = createHash('sha256').update(verifier).digest().toString('base64url'); + expect(challenge).toBe(expected); + }); + + it('generates distinct pairs each call', () => { + const a = generatePKCE(); + const b = generatePKCE(); + expect(a.verifier).not.toBe(b.verifier); + }); +}); + +describe('generateState', () => { + it('produces a base64url state token', () => { + expect(generateState()).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('produces distinct tokens', () => { + expect(generateState()).not.toBe(generateState()); + }); +}); diff --git a/test/oauth/store.test.ts b/test/oauth/store.test.ts new file mode 100644 index 0000000..3488cfb --- /dev/null +++ b/test/oauth/store.test.ts @@ -0,0 +1,100 @@ +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TokenStore, type StoredToken } from '../../src/oauth/store.js'; + +function makeToken(overrides: Partial = {}): StoredToken { + return { + serverURL: 'http://127.0.0.1:39725/mcp', + clientId: 'cid_abc', + accessToken: 'tok_xyz', + expiresAt: null, + createdAt: '2026-05-26T12:00:00Z', + ...overrides, + }; +} + +describe('TokenStore', () => { + let dir: string; + let store: TokenStore; + + beforeEach(async () => { + dir = await fs.mkdtemp(join(tmpdir(), 'paste-mcp-test-')); + store = new TokenStore(join(dir, 'tokens.json')); + }); + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + it('returns null when no file exists', async () => { + expect(await store.load()).toBeNull(); + }); + + it('round-trips a stored token', async () => { + const token = makeToken(); + await store.save(token); + expect(await store.load()).toEqual(token); + }); + + it('round-trips a stored token with expiresAt', async () => { + const token = makeToken({ expiresAt: '2026-12-31T00:00:00Z' }); + await store.save(token); + const loaded = await store.load(); + expect(loaded?.expiresAt).toBe('2026-12-31T00:00:00Z'); + }); + + it('writes the file with mode 0600', async () => { + await store.save(makeToken()); + const stat = await fs.stat(store.path); + expect(stat.mode & 0o777).toBe(0o600); + }); + + it('clear() removes the file', async () => { + await store.save(makeToken()); + await store.clear(); + expect(await store.load()).toBeNull(); + }); + + it('clear() is a no-op when the file does not exist', async () => { + await expect(store.clear()).resolves.toBeUndefined(); + }); + + it('load() returns null for malformed JSON', async () => { + await fs.writeFile(store.path, 'not json', { mode: 0o600 }); + expect(await store.load()).toBeNull(); + }); + + it('load() returns null for JSON missing required fields', async () => { + await fs.writeFile(store.path, JSON.stringify({ foo: 'bar' }), { mode: 0o600 }); + expect(await store.load()).toBeNull(); + }); + + it('load() back-fills missing expiresAt and createdAt for forward-compat', async () => { + // A token written by a pre-expiresAt version of the bridge. + await fs.writeFile(store.path, JSON.stringify({ + serverURL: 'http://127.0.0.1:39725/mcp', + clientId: 'old', + accessToken: 'tok', + }), { mode: 0o600 }); + const loaded = await store.load(); + expect(loaded?.clientId).toBe('old'); + expect(loaded?.expiresAt).toBeNull(); + expect(typeof loaded?.createdAt).toBe('string'); + }); + + it('save() is atomic — readers never see a partial file', async () => { + const writes = Array.from({ length: 20 }, (_, i) => store.save(makeToken({ + clientId: `cid-${i}`, + accessToken: `tok-${i}`, + }))); + const reads = Array.from({ length: 20 }, () => store.load()); + const [, results] = await Promise.all([Promise.all(writes), Promise.all(reads)]); + for (const r of results) { + if (r === null) continue; + expect(r.clientId).toMatch(/^cid-\d+$/); + expect(r.accessToken).toMatch(/^tok-\d+$/); + } + }); +}); diff --git a/test/transport.test.ts b/test/transport.test.ts new file mode 100644 index 0000000..6f741fb --- /dev/null +++ b/test/transport.test.ts @@ -0,0 +1,246 @@ +import { PassThrough } from 'node:stream'; +import { describe, it, expect } from 'vitest'; +import { Transport, parseSSE } from '../src/transport.js'; + +function makeTransport( + fetchImpl: typeof fetch, + opts: { onUnauthorized?: () => Promise; tokens?: string[]; timeoutMs?: number } = {}, +) { + const tokens = opts.tokens ?? ['token-1']; + let index = 0; + return new Transport({ + url: new URL('http://example.test/mcp'), + tokenProvider: async () => tokens[Math.min(index++, tokens.length - 1)]!, + fetch: fetchImpl, + ...(opts.onUnauthorized ? { onUnauthorized: opts.onUnauthorized } : {}), + ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}), + }); +} + +describe('Transport.forward', () => { + it('emits a JSON response as a single stdout line', async () => { + const transport = makeTransport(async () => new Response( + '{"jsonrpc":"2.0","id":1,"result":{}}', + { status: 200, headers: { 'content-type': 'application/json' } }, + )); + const out = await transport.forward('{"jsonrpc":"2.0","id":1,"method":"x"}'); + expect(out).toEqual(['{"jsonrpc":"2.0","id":1,"result":{}}']); + }); + + it('splits SSE responses into one line per data payload', async () => { + const sse = 'event: message\n' + + 'data: {"jsonrpc":"2.0","id":1,"result":1}\n\n' + + 'data: {"jsonrpc":"2.0","id":2,"result":2}\n\n'; + const transport = makeTransport(async () => new Response(sse, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + const out = await transport.forward('req'); + expect(out).toEqual([ + '{"jsonrpc":"2.0","id":1,"result":1}', + '{"jsonrpc":"2.0","id":2,"result":2}', + ]); + }); + + it('emits nothing on 202 Accepted', async () => { + const transport = makeTransport(async () => new Response('', { status: 202 })); + expect(await transport.forward('req')).toEqual([]); + }); + + it('captures Mcp-Session-Id on first response and echoes it thereafter', async () => { + const observed: (string | null)[] = []; + let count = 0; + const transport = makeTransport(async (_url, init) => { + const headers = new Headers(init?.headers); + observed.push(headers.get('Mcp-Session-Id')); + count++; + return new Response('{}', { + status: 200, + headers: count === 1 + ? { 'content-type': 'application/json', 'mcp-session-id': 'sess-42' } + : { 'content-type': 'application/json' }, + }); + }); + await transport.forward('a'); + await transport.forward('b'); + await transport.forward('c'); + expect(observed).toEqual([null, 'sess-42', 'sess-42']); + }); + + it('sends Bearer token and required transport headers', async () => { + let observed: Headers | undefined; + const transport = makeTransport(async (_url, init) => { + observed = new Headers(init?.headers); + return new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + await transport.forward('x'); + expect(observed?.get('authorization')).toBe('Bearer token-1'); + expect(observed?.get('content-type')).toBe('application/json'); + expect(observed?.get('accept')).toBe('application/json, text/event-stream'); + }); + + it('on 401 invalidates the cache, refreshes the token, and retries once', async () => { + let invalidated = false; + let call = 0; + const transport = makeTransport( + async (_url, init) => { + call++; + const headers = new Headers(init?.headers); + const auth = headers.get('authorization'); + if (auth === 'Bearer stale') { + return new Response('Unauthorized', { status: 401 }); + } + return new Response('{"ok":1}', { status: 200, headers: { 'content-type': 'application/json' } }); + }, + { + tokens: ['stale', 'fresh'], + onUnauthorized: async () => { invalidated = true; }, + }, + ); + const out = await transport.forward('req'); + expect(invalidated).toBe(true); + expect(call).toBe(2); + expect(out).toEqual(['{"ok":1}']); + }); + + it('on persistent 401 returns the unauthorized body, not an error envelope', async () => { + let calls = 0; + const transport = makeTransport( + async () => { + calls += 1; + return new Response('still bad', { status: 401, headers: { 'content-type': 'text/plain' } }); + }, + { + tokens: ['stale', 'still-stale'], + onUnauthorized: async () => { /* noop */ }, + }, + ); + const out = await transport.forward('req'); + expect(out).toEqual(['still bad']); + // We must retry exactly once (no infinite loop, no third try). + expect(calls).toBe(2); + }); + + it('emits a JSON-RPC error envelope on transport failure', async () => { + const transport = makeTransport(async () => { throw new Error('boom'); }); + const out = await transport.forward('{"jsonrpc":"2.0","id":42,"method":"x"}'); + expect(out).toHaveLength(1); + const parsed = JSON.parse(out[0]!); + expect(parsed.id).toBe(42); + expect(parsed.error.code).toBe(-32603); + expect(parsed.error.message).toContain('boom'); + }); + + it('rejects a response whose Content-Length exceeds the configured cap', async () => { + // `new Response(body)` in Node doesn't set Content-Length on the headers + // map, so we hand-craft a Response-like with the header explicitly set. + const fake = { + status: 200, + headers: new Headers({ + 'content-type': 'application/json', + 'content-length': '999999', + }), + text: async () => 'x', + } as Response; + const transport = new Transport({ + url: new URL('http://example.test/mcp'), + tokenProvider: async () => 'tok', + fetch: async () => fake, + maxResponseBytes: 1024, + }); + const out = await transport.forward('{"id":7}'); + const parsed = JSON.parse(out[0]!); + expect(parsed.id).toBe(7); + expect(parsed.error.message).toMatch(/exceeds|cap/i); + }); +}); + +describe('Transport.run', () => { + it('forwards each stdin line and stops on EOF', async () => { + const transport = makeTransport(async () => new Response( + '{"ok":true}', + { status: 200, headers: { 'content-type': 'application/json' } }, + )); + const input = new PassThrough(); + const output = new PassThrough(); + const collected: string[] = []; + output.on('data', (chunk: Buffer) => collected.push(chunk.toString('utf8'))); + const done = transport.run(input, output); + input.write('{"id":1}\n{"id":2}\n'); + input.end(); + await done; + expect(collected.join('').trim().split('\n')).toEqual(['{"ok":true}', '{"ok":true}']); + }); + + it('stops processing when the output stream closes (parent died)', async () => { + let calls = 0; + const transport = makeTransport(async () => { + calls += 1; + return new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + const input = new PassThrough(); + const output = new PassThrough(); + const done = transport.run(input, output); + // Close output immediately — simulates parent process dying. + output.destroy(); + input.write('{"id":1}\n{"id":2}\n{"id":3}\n'); + input.end(); + await done; + // Should have stopped early, not POSTed all three frames. + expect(calls).toBeLessThan(3); + }); +}); + +describe('parseSSE (WHATWG)', () => { + it('decodes a single-event JSON `data:` line', () => { + expect(parseSSE('data: {"a":1}\n\n')).toEqual(['{"a":1}']); + }); + + it('handles CRLF terminators', () => { + expect(parseSSE('data: {"a":1}\r\n\r\ndata: {"b":2}\r\n\r\n')) + .toEqual(['{"a":1}', '{"b":2}']); + }); + + it('handles bare CR terminators', () => { + expect(parseSSE('data: {"a":1}\r\rdata: {"b":2}\r\r')) + .toEqual(['{"a":1}', '{"b":2}']); + }); + + it('concatenates multi-line `data:` within one event with LF', () => { + // Per WHATWG spec: two `data:` lines in one event = one message joined by `\n`. + expect(parseSSE('data: line1\ndata: line2\n\n')).toEqual(['line1\nline2']); + }); + + it('strips a single leading SPACE after `data:` only', () => { + // First line: leading space stripped → "x". Second line: no leading space + // (just leading SPACE-SPACE after colon → strip one → " y"). + expect(parseSSE('data: x\ndata: y\n\n')).toEqual(['x\n y']); + }); + + it('ignores comment lines (`:` prefix)', () => { + expect(parseSSE(': keepalive\ndata: {"k":1}\n\n')).toEqual(['{"k":1}']); + }); + + it('ignores `event:`, `id:`, `retry:` fields', () => { + const sse = 'event: message\nid: 42\nretry: 5000\ndata: {"x":1}\n\n'; + expect(parseSSE(sse)).toEqual(['{"x":1}']); + }); + + it('skips events with no `data:` lines', () => { + expect(parseSSE('event: ping\n\ndata: {"x":1}\n\n')).toEqual(['{"x":1}']); + }); + + it('strips a leading UTF-8 BOM', () => { + expect(parseSSE('data: {"x":1}\n\n')).toEqual(['{"x":1}']); + }); + + it('returns empty for an empty stream', () => { + expect(parseSSE('')).toEqual([]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..32c62a4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "outDir": "dist", + "rootDir": "src", + "declaration": false, + "sourceMap": true, + "newLine": "lf" + }, + "include": ["src/**/*"] +} From 3a61fd8392c4cbf90234c5f98ce7df0761b43bf3 Mon Sep 17 00:00:00 2001 From: Dmitry Obukhov <429275+stel@users.noreply.github.com> Date: Wed, 27 May 2026 09:00:59 +0200 Subject: [PATCH 02/10] Add CI workflow: build + test on Node 18/20/22 Runs `npm ci`, `npm run build`, and `npm test` (74 vitest tests) on every push to main and every pull request, across a Node 18/20/22 matrix on ubuntu-latest. Tests inject mocks for the macOS-only `defaults`/`open` calls, so Linux runners suffice. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..01b7d0f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: ['18', '20', '22'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - run: npm run build + - run: npm test From 62db53bc09d27892f9ed8700b1fead2ab1204229 Mon Sep 17 00:00:00 2001 From: Dmitry Obukhov <429275+stel@users.noreply.github.com> Date: Wed, 27 May 2026 09:10:24 +0200 Subject: [PATCH 03/10] CI: run tests on a single Node version (20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the 18/20/22 matrix to one job — the bridge uses stable Node APIs, so cross-version coverage wasn't earning its keep. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01b7d0f..56344ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,15 +8,11 @@ on: jobs: test: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node-version: ['18', '20', '22'] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} + node-version: '20' cache: npm - run: npm ci - run: npm run build From f4360d7bda9eab4bc5465488ca1189c7739b69be Mon Sep 17 00:00:00 2001 From: Vladislav Kartashov Date: Wed, 27 May 2026 17:18:13 +0300 Subject: [PATCH 04/10] PASTE-1994 Use SDK auth() for the OAuth handshake Replace the hand-rolled metadata discovery + DCR + PKCE + token exchange with `@modelcontextprotocol/sdk`'s `auth()` function. The SDK covers RFC 9728 protected-resource metadata, RFC 8414 / OIDC authorization-server discovery, RFC 7591 dynamic client registration, RFC 7636 PKCE, RFC 8707 `resource` indicator, RFC 9207 `iss` validation, and refresh-token rotation. The bridge owns the `OAuthClientProvider` adapter (mode-0600 JSON cache + loopback callback listener + `/usr/bin/open` wrapper). - `oauth/client.ts` shrinks ~80 lines and gains all the spec compliance items above for free. - `oauth/store.ts` now persists SDK shapes (`OAuthTokens`, `OAuthClientInformationMixed`, transient `codeVerifier`) keyed to `serverURL`. - `oauth/callback.ts` keeps generating its own state and validating it server-side; the provider exposes that state via `state()`. - `oauth/pkce.ts` is gone (SDK generates PKCE itself). - Short-circuits to the cached access token before invoking `auth()`: Paste issues long-lived tokens without refresh, so SDK would otherwise fall through to a fresh authorization on every spawn. Transport's 401 retry calls `invalidate()` when the token actually goes stale. 63 vitest tests pass across the touched files. --- src/oauth/callback.ts | 6 +- src/oauth/client.ts | 321 ++++++++++++++++---------------------- src/oauth/pkce.ts | 20 --- src/oauth/store.ts | 46 +++--- test/oauth/client.test.ts | 135 ++++++---------- test/oauth/pkce.test.ts | 38 ----- test/oauth/store.test.ts | 57 +++---- 7 files changed, 236 insertions(+), 387 deletions(-) delete mode 100644 src/oauth/pkce.ts delete mode 100644 test/oauth/pkce.test.ts diff --git a/src/oauth/callback.ts b/src/oauth/callback.ts index 2250d38..17fa746 100644 --- a/src/oauth/callback.ts +++ b/src/oauth/callback.ts @@ -9,9 +9,13 @@ // stays open. The state is also exposed via `server.state` so the caller can // embed it in the `/authorize` request. +import { randomBytes } from 'node:crypto'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import type { AddressInfo } from 'node:net'; -import { generateState } from './pkce.js'; + +function generateState(): string { + return randomBytes(16).toString('base64url'); +} export interface CallbackResult { code: string | null; diff --git a/src/oauth/client.ts b/src/oauth/client.ts index e1afbcd..b161991 100644 --- a/src/oauth/client.ts +++ b/src/oauth/client.ts @@ -1,34 +1,26 @@ -// OAuth client for Paste's loopback MCP server. Implements the canonical MCP -// authorization dance: RFC 9728 protected-resource metadata → RFC 8414 auth -// server metadata → RFC 7591 dynamic client registration → authorization code -// + PKCE → token exchange. Tokens cached on disk between invocations. +// OAuth client for Paste's loopback MCP server, built on the official +// `@modelcontextprotocol/sdk` `auth()` function. The SDK handles metadata +// discovery (RFC 9728 protected-resource + RFC 8414 + OIDC fallback), +// dynamic client registration (RFC 7591), PKCE generation/verification, +// the `resource` indicator (RFC 8707), `iss` validation (RFC 9207), token +// exchange, and refresh-token rotation. We provide the storage backing +// and the side effects (open browser, wait on the loopback callback). import { spawn } from 'node:child_process'; +import { + auth, + type OAuthClientProvider, +} from '@modelcontextprotocol/sdk/client/auth.js'; +import type { + OAuthClientInformationFull, + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js'; import { startCallbackServer, type CallbackServer } from './callback.js'; -import { generatePKCE } from './pkce.js'; -import { TokenStore, type StoredToken } from './store.js'; - -interface AuthServerMetadata { - authorization_endpoint: string; - token_endpoint: string; - registration_endpoint: string; -} - -interface ResourceMetadata { - authorization_servers?: string[]; -} - -interface RegistrationResponse { - client_id: string; -} - -interface TokenResponse { - access_token: string; - expires_in?: number; -} +import { TokenStore } from './store.js'; const CLIENT_NAME = 'Paste MCP Bridge'; -const TOKEN_ERROR_BODY_CAP = 500; export type StartCallbackServer = () => Promise; export type OpenBrowser = (url: string) => void | Promise; @@ -37,14 +29,12 @@ export interface OAuthClientOptions { fetch?: typeof fetch; openBrowser?: OpenBrowser; startCallbackServer?: StartCallbackServer; - now?: () => Date; } export class OAuthClient { private readonly fetchImpl: typeof fetch; private readonly openBrowserImpl: OpenBrowser; private readonly startCallbackServerImpl: StartCallbackServer; - private readonly nowImpl: () => Date; constructor( public readonly serverURL: URL, @@ -54,183 +44,154 @@ export class OAuthClient { this.fetchImpl = opts.fetch ?? fetch; this.openBrowserImpl = opts.openBrowser ?? defaultOpenBrowser; this.startCallbackServerImpl = opts.startCallbackServer ?? startCallbackServer; - this.nowImpl = opts.now ?? (() => new Date()); } async accessToken(): Promise { const cached = await this.store.load(); - if (cached && cached.accessToken && cached.serverURL === this.serverURL.toString() && !this.isExpired(cached)) { - return cached.accessToken; + if (cached && cached.serverURL === this.serverURL.toString() && cached.tokens?.access_token) { + // Short-circuit: Paste issues long-lived access tokens without + // refresh_tokens, so SDK's `auth()` would always fall through to a + // fresh authorization. The bridge's transport will call `invalidate()` + // on 401 if the token actually went stale. + return cached.tokens.access_token; + } + // Different server URL (port changed, channel switched) → drop the cache. + if (cached && cached.serverURL !== this.serverURL.toString()) { + await this.store.clear(); + } + const callback = await this.startCallbackServerImpl(); + const provider = new BridgeProvider({ + serverURL: this.serverURL.toString(), + redirectUrl: `http://127.0.0.1:${callback.port}/cb`, + state: callback.state, + store: this.store, + openBrowser: this.openBrowserImpl, + }); + try { + const first = await auth(provider, { + serverUrl: this.serverURL, + fetchFn: this.fetchImpl, + }); + if (first === 'AUTHORIZED') return await readToken(provider); + + const cb = await callback.waitForCallback(); + if (cb.error) { + const detail = cb.errorDescription ? ` — ${cb.errorDescription}` : ''; + throw new Error(`OAuth error: ${cb.error}${detail}`); + } + if (!cb.code) throw new Error('OAuth callback missing `code`'); + + const second = await auth(provider, { + serverUrl: this.serverURL, + authorizationCode: cb.code, + fetchFn: this.fetchImpl, + }); + if (second !== 'AUTHORIZED') { + throw new Error(`OAuth handshake did not authorize (returned ${second})`); + } + return await readToken(provider); + } finally { + await callback.shutdown(); } - return await this.runFlow(); } async invalidate(): Promise { await this.store.clear(); } +} + +async function readToken(provider: OAuthClientProvider): Promise { + const tokens = await provider.tokens(); + if (!tokens?.access_token) throw new Error('OAuth flow completed but no access token was returned'); + return tokens.access_token; +} + +interface BridgeProviderOptions { + serverURL: string; + redirectUrl: string; + state: string; + store: TokenStore; + openBrowser: OpenBrowser; +} - private isExpired(token: StoredToken): boolean { - if (token.expiresAt === null) return false; - const expiresAt = Date.parse(token.expiresAt); - if (!Number.isFinite(expiresAt)) return false; - // 30-second skew so we don't hand out a token that's about to die in flight. - return expiresAt <= this.nowImpl().getTime() + 30_000; +class BridgeProvider implements OAuthClientProvider { + constructor(private readonly opts: BridgeProviderOptions) {} + + get redirectUrl(): string { + return this.opts.redirectUrl; } - private async runFlow(): Promise { - const metadata = await discoverAuthServer(this.serverURL, this.fetchImpl); - const callback = await this.startCallbackServerImpl(); - const redirectUri = `http://127.0.0.1:${callback.port}/cb`; - try { - const clientId = await registerClient(metadata, redirectUri, this.fetchImpl); - const { accessToken, expiresIn } = await this.authorize(metadata, clientId, redirectUri, callback); - const expiresAt = expiresIn != null - ? new Date(this.nowImpl().getTime() + expiresIn * 1_000).toISOString() - : null; - const stored: StoredToken = { - serverURL: this.serverURL.toString(), - clientId, - accessToken, - expiresAt, - createdAt: this.nowImpl().toISOString(), - }; - await this.store.save(stored); - return accessToken; - } finally { - await callback.shutdown(); - } + get clientMetadata(): OAuthClientMetadata { + return { + client_name: CLIENT_NAME, + redirect_uris: [this.opts.redirectUrl], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + }; } - private async authorize( - metadata: AuthServerMetadata, - clientId: string, - redirectUri: string, - callback: CallbackServer, - ): Promise<{ accessToken: string; expiresIn?: number }> { - const pkce = generatePKCE(); - await this.openBrowserImpl(buildAuthURL(metadata, clientId, redirectUri, pkce.challenge, callback.state)); + // Per-flow state for CSRF defense. Our callback server validates this on + // its side too — defense in depth. + state(): string { + return this.opts.state; + } - const result = await callback.waitForCallback(); - if (result.error) { - const detail = result.errorDescription ? ` — ${result.errorDescription}` : ''; - throw new Error(`OAuth error: ${result.error}${detail}`); - } - if (!result.code) throw new Error('OAuth callback missing `code`'); - // Belt-and-braces: CallbackServer enforces state too, but a mismatch here - // means the callback contract drifted — fail loud rather than proceed. - if (result.state !== callback.state) throw new Error('OAuth state mismatch'); + async clientInformation(): Promise { + return (await this.loadCache()).clientInformation; + } - return await exchangeCode(metadata, clientId, result.code, redirectUri, pkce.verifier, this.fetchImpl); + async saveClientInformation(info: OAuthClientInformationFull): Promise { + await this.mergeCache({ clientInformation: info }); } -} -async function discoverAuthServer(serverURL: URL, fetchImpl: typeof fetch): Promise { - const origin = serverURL.origin; - let authBase = origin; - try { - const meta = await fetchJSON( - `${origin}/.well-known/oauth-protected-resource`, - fetchImpl, - ); - const first = meta.authorization_servers?.[0]; - if (first) authBase = first.replace(/\/$/, ''); - } catch { - // Resource metadata is optional per the spec — fall back to assuming the - // server is its own authorization server (Paste's setup). + async tokens(): Promise { + return (await this.loadCache()).tokens; } - const metadata = await fetchJSON( - `${authBase}/.well-known/oauth-authorization-server`, - fetchImpl, - ); - if ( - typeof metadata.authorization_endpoint !== 'string' - || typeof metadata.token_endpoint !== 'string' - || typeof metadata.registration_endpoint !== 'string' - ) { - throw new Error('Authorization server metadata is missing required endpoints'); + + async saveTokens(tokens: OAuthTokens): Promise { + await this.mergeCache({ tokens }); } - return metadata; -} -async function registerClient( - metadata: AuthServerMetadata, - redirectUri: string, - fetchImpl: typeof fetch, -): Promise { - const response = await fetchImpl(metadata.registration_endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ - client_name: CLIENT_NAME, - redirect_uris: [redirectUri], - token_endpoint_auth_method: 'none', - grant_types: ['authorization_code'], - response_types: ['code'], - }), - }); - if (!response.ok) { - throw new Error(`Dynamic client registration failed: HTTP ${response.status}`); + async saveCodeVerifier(verifier: string): Promise { + await this.mergeCache({ codeVerifier: verifier }); + } + + async codeVerifier(): Promise { + const cached = await this.loadCache(); + if (!cached.codeVerifier) { + throw new Error('No PKCE code verifier in cache — OAuth state was cleared between auth() calls'); + } + return cached.codeVerifier; } - const data = (await response.json()) as RegistrationResponse; - if (typeof data.client_id !== 'string' || data.client_id.length === 0) { - throw new Error('Dynamic client registration response missing `client_id`'); + + async redirectToAuthorization(authorizationUrl: URL): Promise { + await this.opts.openBrowser(authorizationUrl.toString()); } - return data.client_id; -} -async function exchangeCode( - metadata: AuthServerMetadata, - clientId: string, - code: string, - redirectUri: string, - verifier: string, - fetchImpl: typeof fetch, -): Promise<{ accessToken: string; expiresIn?: number }> { - const body = new URLSearchParams({ - grant_type: 'authorization_code', - code, - redirect_uri: redirectUri, - client_id: clientId, - code_verifier: verifier, - }).toString(); - const response = await fetchImpl(metadata.token_endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, - body, - }); - if (!response.ok) { - // Capping the error body keeps verifier/code material out of stderr if - // the AS ever echoes them back in a regression. - const detail = (await response.text()).slice(0, TOKEN_ERROR_BODY_CAP); - throw new Error(`Token exchange failed: HTTP ${response.status}: ${detail}`); + async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): Promise { + if (scope === 'all') { + await this.opts.store.clear(); + return; + } + const cached = await this.opts.store.load(); + if (!cached) return; + const next = { ...cached }; + if (scope === 'client') delete next.clientInformation; + if (scope === 'tokens') delete next.tokens; + if (scope === 'verifier') delete next.codeVerifier; + // 'discovery' — we don't cache discovery state, nothing to do. + await this.opts.store.save(next); } - const data = (await response.json()) as TokenResponse; - if (typeof data.access_token !== 'string' || data.access_token.length === 0) { - throw new Error('Token response missing `access_token`'); + + private async loadCache() { + return (await this.opts.store.load()) ?? { serverURL: this.opts.serverURL }; } - return { - accessToken: data.access_token, - ...(typeof data.expires_in === 'number' && data.expires_in > 0 - ? { expiresIn: data.expires_in } - : {}), - }; -} -function buildAuthURL( - metadata: AuthServerMetadata, - clientId: string, - redirectUri: string, - challenge: string, - state: string, -): string { - const url = new URL(metadata.authorization_endpoint); - url.searchParams.set('response_type', 'code'); - url.searchParams.set('client_id', clientId); - url.searchParams.set('redirect_uri', redirectUri); - url.searchParams.set('code_challenge', challenge); - url.searchParams.set('code_challenge_method', 'S256'); - url.searchParams.set('state', state); - return url.toString(); + private async mergeCache(patch: Partial> & object>): Promise { + const cached = await this.loadCache(); + await this.opts.store.save({ ...cached, ...patch, serverURL: this.opts.serverURL }); + } } // Refuse to launch anything but an HTTP(S) URL pointing at loopback. An @@ -257,9 +218,3 @@ function defaultOpenBrowser(url: string): void { child.on('error', () => { /* swallow */ }); child.unref(); } - -async function fetchJSON(url: string, fetchImpl: typeof fetch, init?: RequestInit): Promise { - const response = await fetchImpl(url, init); - if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${url}`); - return (await response.json()) as T; -} diff --git a/src/oauth/pkce.ts b/src/oauth/pkce.ts deleted file mode 100644 index b10c6df..0000000 --- a/src/oauth/pkce.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createHash, randomBytes } from 'node:crypto'; - -export interface PKCEPair { - verifier: string; - challenge: string; -} - -function base64URL(buf: Buffer): string { - return buf.toString('base64url'); -} - -export function generatePKCE(): PKCEPair { - const verifier = base64URL(randomBytes(32)); - const challenge = base64URL(createHash('sha256').update(verifier).digest()); - return { verifier, challenge }; -} - -export function generateState(): string { - return base64URL(randomBytes(16)); -} diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 14bb604..8449ab3 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -1,19 +1,22 @@ -// On-disk cache of an OAuth client_id + access token, keyed implicitly to the -// server URL it was issued for. Mode 0600 — same convention as ~/.pgpass, +// On-disk cache of an OAuth flow's state — client registration (DCR), the +// access/refresh tokens, and the transient PKCE code verifier the SDK needs +// to bridge two `auth()` calls. Mode 0600 — same convention as ~/.pgpass, // ~/.aws/credentials, mcp-remote's ~/.mcp-auth, and ssh keys. import { promises as fs } from 'node:fs'; import { homedir } from 'node:os'; import { dirname } from 'node:path'; +import type { + OAuthClientInformationMixed, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js'; -export interface StoredToken { +export interface CachedOAuthState { + /// Cache key — when Paste's port changes we drop the whole cache. serverURL: string; - clientId: string; - accessToken: string; - /// ISO 8601 — when the AS-issued `expires_in` runs out. `null` if the AS - /// didn't return one (treat as non-expiring). - expiresAt: string | null; - createdAt: string; + clientInformation?: OAuthClientInformationMixed; + tokens?: OAuthTokens; + codeVerifier?: string; } const DEFAULT_PATH = `${homedir()}/Library/Application Support/paste-mcp/tokens.json`; @@ -21,7 +24,7 @@ const DEFAULT_PATH = `${homedir()}/Library/Application Support/paste-mcp/tokens. export class TokenStore { constructor(public readonly path: string = DEFAULT_PATH) {} - async load(): Promise { + async load(): Promise { let data: string; try { data = await fs.readFile(this.path, 'utf8'); @@ -31,29 +34,22 @@ export class TokenStore { } // A corrupt cache (partial write, garbage on disk) should not crash the // bridge — treat it as absent so the OAuth flow runs and overwrites. - let parsed: Partial; + let parsed: Partial; try { - parsed = JSON.parse(data) as Partial; + parsed = JSON.parse(data) as Partial; } catch { return null; } - if ( - typeof parsed.serverURL !== 'string' - || typeof parsed.clientId !== 'string' - || typeof parsed.accessToken !== 'string' - ) { - return null; - } + if (typeof parsed.serverURL !== 'string') return null; return { serverURL: parsed.serverURL, - clientId: parsed.clientId, - accessToken: parsed.accessToken, - expiresAt: typeof parsed.expiresAt === 'string' ? parsed.expiresAt : null, - createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : new Date().toISOString(), + ...(parsed.clientInformation ? { clientInformation: parsed.clientInformation } : {}), + ...(parsed.tokens ? { tokens: parsed.tokens } : {}), + ...(parsed.codeVerifier ? { codeVerifier: parsed.codeVerifier } : {}), }; } - async save(token: StoredToken): Promise { + async save(state: CachedOAuthState): Promise { await fs.mkdir(dirname(this.path), { recursive: true }); // Atomic write: stage to a unique temp file then rename. POSIX rename is // atomic within a filesystem, so a reader never sees a partial file. @@ -61,7 +57,7 @@ export class TokenStore { // process don't collide on the temp path. const tmp = `${this.path}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`; try { - await fs.writeFile(tmp, JSON.stringify(token, null, 2), { mode: 0o600 }); + await fs.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 }); await fs.chmod(tmp, 0o600); // umask-resistant await fs.rename(tmp, this.path); } catch (err) { diff --git a/test/oauth/client.test.ts b/test/oauth/client.test.ts index 69b7e42..3cff7af 100644 --- a/test/oauth/client.test.ts +++ b/test/oauth/client.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { OAuthClient, assertLoopbackHTTPURL } from '../../src/oauth/client.js'; -import { TokenStore, type StoredToken } from '../../src/oauth/store.js'; +import { TokenStore, type CachedOAuthState } from '../../src/oauth/store.js'; interface MockOAuthServer { url: string; @@ -14,10 +14,15 @@ interface MockOAuthServer { lastTokenForm: URLSearchParams | null; setNextCode(code: string): void; setTokenResponse(body: object): void; - setRegisterResponse(body: object): void; shutdown(): Promise; } +async function readBody(req: import('node:http').IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString('utf8'); +} + async function startMockOAuthServer(): Promise { const state = { nextCode: 'mock-code-1', @@ -25,7 +30,6 @@ async function startMockOAuthServer(): Promise { tokenCalls: 0, lastTokenForm: null as URLSearchParams | null, tokenResponse: null as object | null, - registerResponse: null as object | null, }; const server = createServer(async (req, res) => { @@ -45,15 +49,27 @@ async function startMockOAuthServer(): Promise { token_endpoint: `${base}/token`, registration_endpoint: `${base}/register`, response_types_supported: ['code'], - grant_types_supported: ['authorization_code'], + grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], })); return; } if (req.method === 'POST' && url.pathname === '/register') { state.registerCalls += 1; + const body = await readBody(req); + let parsed: { redirect_uris?: string[]; client_name?: string }; + try { parsed = JSON.parse(body); } catch { parsed = {}; } res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(state.registerResponse ?? { client_id: `mock-client-${state.registerCalls}` })); + res.end(JSON.stringify({ + client_id: `mock-client-${state.registerCalls}`, + client_id_issued_at: Math.floor(Date.now() / 1000), + client_name: parsed.client_name ?? 'Test Client', + redirect_uris: parsed.redirect_uris ?? [], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + })); return; } if (req.method === 'GET' && url.pathname === '/authorize') { @@ -68,9 +84,7 @@ async function startMockOAuthServer(): Promise { } if (req.method === 'POST' && url.pathname === '/token') { state.tokenCalls += 1; - const chunks: Buffer[] = []; - for await (const chunk of req) chunks.push(chunk as Buffer); - state.lastTokenForm = new URLSearchParams(Buffer.concat(chunks).toString('utf8')); + state.lastTokenForm = new URLSearchParams(await readBody(req)); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(state.tokenResponse ?? { access_token: `tok-for-${state.lastTokenForm.get('code')}`, @@ -91,7 +105,6 @@ async function startMockOAuthServer(): Promise { get lastTokenForm() { return state.lastTokenForm; }, setNextCode(code) { state.nextCode = code; }, setTokenResponse(body) { state.tokenResponse = body; }, - setRegisterResponse(body) { state.registerResponse = body; }, async shutdown() { await new Promise((res) => server.close(() => res())); }, @@ -106,17 +119,6 @@ function simulatedBrowser(): (url: string) => Promise { }; } -function makeStored(overrides: Partial = {}): StoredToken { - return { - serverURL: 'http://127.0.0.1:99999/mcp', - clientId: 'old', - accessToken: 'old-token', - expiresAt: null, - createdAt: new Date().toISOString(), - ...overrides, - }; -} - describe('OAuthClient', () => { let dir: string; let store: TokenStore; @@ -133,7 +135,7 @@ describe('OAuthClient', () => { await fs.rm(dir, { recursive: true, force: true }); }); - it('runs the full DCR + PKCE flow on first call, caches the token', async () => { + it('runs the full DCR + PKCE flow on first call and caches state', async () => { const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { openBrowser: simulatedBrowser(), }); @@ -144,15 +146,18 @@ describe('OAuthClient', () => { const cached = await store.load(); expect(cached?.serverURL).toBe(`${mock.url}/mcp`); - expect(cached?.accessToken).toBe('tok-for-mock-code-1'); - expect(cached?.clientId).toBe('mock-client-1'); - expect(cached?.expiresAt).toBeNull(); // no expires_in in default response - const stat = await fs.stat(store.path); - expect(stat.mode & 0o777).toBe(0o600); + expect(cached?.tokens?.access_token).toBe('tok-for-mock-code-1'); + expect(cached?.clientInformation?.client_id).toBe('mock-client-1'); + // PKCE: verifier sent to /token must be RFC 7636 unreserved-shape + // (alpha-num plus `-`, `.`, `_`, `~`). const verifier = mock.lastTokenForm?.get('code_verifier') ?? ''; - expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); + expect(verifier).toMatch(/^[A-Za-z0-9._~-]+$/); expect(verifier.length).toBeGreaterThanOrEqual(43); + + // 0600 file permissions. + const stat = await fs.stat(store.path); + expect(stat.mode & 0o777).toBe(0o600); }); it('reuses the cached token on subsequent calls — no second OAuth round-trip', async () => { @@ -168,8 +173,14 @@ describe('OAuthClient', () => { expect(mock.tokenCalls).toBe(1); }); - it('ignores a cached token that was issued for a different server URL', async () => { - await store.save(makeStored()); + it('drops a cached state that was issued for a different server URL', async () => { + const stale: CachedOAuthState = { + serverURL: 'http://127.0.0.1:99999/mcp', + clientInformation: { client_id: 'old', redirect_uris: ['http://127.0.0.1:0/cb'] }, + tokens: { access_token: 'old-token', token_type: 'Bearer' }, + }; + await store.save(stale); + const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { openBrowser: simulatedBrowser(), }); @@ -195,7 +206,6 @@ describe('OAuthClient', () => { it('throws when the OAuth provider returns an error in the callback', async () => { const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { openBrowser: async (authUrlStr) => { - // Skip /authorize entirely — hit the callback directly with `error`. const authURL = new URL(authUrlStr); const redirectUri = authURL.searchParams.get('redirect_uri')!; const stateParam = authURL.searchParams.get('state') ?? ''; @@ -207,66 +217,25 @@ describe('OAuthClient', () => { }, }); await expect(client.accessToken()).rejects.toThrow(/access_denied/); - expect(await store.load()).toBeNull(); - }); - - it('persists expires_in from the token response as an absolute expiresAt', async () => { - mock.setTokenResponse({ access_token: 'tok', expires_in: 3600, token_type: 'Bearer' }); - const fixedNow = new Date('2026-05-26T12:00:00Z'); - const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { - openBrowser: simulatedBrowser(), - now: () => fixedNow, - }); - await client.accessToken(); - const cached = await store.load(); - expect(cached?.expiresAt).toBe('2026-05-26T13:00:00.000Z'); + // Cache may have partial state (DCR'd client + verifier); the access token + // itself never landed, so the next attempt will re-auth. + expect((await store.load())?.tokens).toBeUndefined(); }); - it('treats an expired cached token as absent and re-runs OAuth', async () => { - let now = new Date('2026-05-26T12:00:00Z'); - const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { - openBrowser: simulatedBrowser(), - now: () => now, + it('persists refresh_token from the token response', async () => { + mock.setTokenResponse({ + access_token: 'tok', + token_type: 'Bearer', + refresh_token: 'rt-1', + expires_in: 3600, }); - mock.setTokenResponse({ access_token: 'first', expires_in: 3600, token_type: 'Bearer' }); - const first = await client.accessToken(); - expect(first).toBe('first'); - - // Advance past expiry (with the 30s skew). - now = new Date('2026-05-26T14:00:00Z'); - mock.setTokenResponse({ access_token: 'second', expires_in: 3600, token_type: 'Bearer' }); - const second = await client.accessToken(); - expect(second).toBe('second'); - expect(mock.tokenCalls).toBe(2); - }); - - it('still uses an unexpired cached token', async () => { - let now = new Date('2026-05-26T12:00:00Z'); const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { openBrowser: simulatedBrowser(), - now: () => now, }); - mock.setTokenResponse({ access_token: 'fresh', expires_in: 3600, token_type: 'Bearer' }); await client.accessToken(); - now = new Date('2026-05-26T12:30:00Z'); // 30 min in, well within expiry - expect(await client.accessToken()).toBe('fresh'); - expect(mock.tokenCalls).toBe(1); - }); - - it('throws when DCR returns 200 with no client_id', async () => { - mock.setRegisterResponse({}); - const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { - openBrowser: simulatedBrowser(), - }); - await expect(client.accessToken()).rejects.toThrow(/client_id/); - }); - - it('throws when /token returns 200 with no access_token', async () => { - mock.setTokenResponse({}); - const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { - openBrowser: simulatedBrowser(), - }); - await expect(client.accessToken()).rejects.toThrow(/access_token/); + const cached = await store.load(); + expect(cached?.tokens?.refresh_token).toBe('rt-1'); + expect(cached?.tokens?.expires_in).toBe(3600); }); }); diff --git a/test/oauth/pkce.test.ts b/test/oauth/pkce.test.ts deleted file mode 100644 index 83ac7f1..0000000 --- a/test/oauth/pkce.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { createHash } from 'node:crypto'; -import { describe, it, expect } from 'vitest'; -import { generatePKCE, generateState } from '../../src/oauth/pkce.js'; - -describe('generatePKCE', () => { - it('produces a verifier within the RFC 7636 length range', () => { - const { verifier } = generatePKCE(); - expect(verifier.length).toBeGreaterThanOrEqual(43); - expect(verifier.length).toBeLessThanOrEqual(128); - }); - - it('produces a base64url verifier (no +/=)', () => { - const { verifier } = generatePKCE(); - expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); - }); - - it('produces a challenge that is the base64url SHA-256 of the verifier', () => { - const { verifier, challenge } = generatePKCE(); - const expected = createHash('sha256').update(verifier).digest().toString('base64url'); - expect(challenge).toBe(expected); - }); - - it('generates distinct pairs each call', () => { - const a = generatePKCE(); - const b = generatePKCE(); - expect(a.verifier).not.toBe(b.verifier); - }); -}); - -describe('generateState', () => { - it('produces a base64url state token', () => { - expect(generateState()).toMatch(/^[A-Za-z0-9_-]+$/); - }); - - it('produces distinct tokens', () => { - expect(generateState()).not.toBe(generateState()); - }); -}); diff --git a/test/oauth/store.test.ts b/test/oauth/store.test.ts index 3488cfb..acc1937 100644 --- a/test/oauth/store.test.ts +++ b/test/oauth/store.test.ts @@ -2,15 +2,14 @@ import { promises as fs } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TokenStore, type StoredToken } from '../../src/oauth/store.js'; +import { TokenStore, type CachedOAuthState } from '../../src/oauth/store.js'; -function makeToken(overrides: Partial = {}): StoredToken { +function makeState(overrides: Partial = {}): CachedOAuthState { return { serverURL: 'http://127.0.0.1:39725/mcp', - clientId: 'cid_abc', - accessToken: 'tok_xyz', - expiresAt: null, - createdAt: '2026-05-26T12:00:00Z', + clientInformation: { client_id: 'cid_abc', redirect_uris: ['http://127.0.0.1:0/cb'] }, + tokens: { access_token: 'tok_xyz', token_type: 'Bearer' }, + codeVerifier: 'verifier_123', ...overrides, }; } @@ -32,27 +31,26 @@ describe('TokenStore', () => { expect(await store.load()).toBeNull(); }); - it('round-trips a stored token', async () => { - const token = makeToken(); - await store.save(token); - expect(await store.load()).toEqual(token); + it('round-trips a fully-populated state', async () => { + const state = makeState(); + await store.save(state); + expect(await store.load()).toEqual(state); }); - it('round-trips a stored token with expiresAt', async () => { - const token = makeToken({ expiresAt: '2026-12-31T00:00:00Z' }); - await store.save(token); - const loaded = await store.load(); - expect(loaded?.expiresAt).toBe('2026-12-31T00:00:00Z'); + it('round-trips a sparse state (just serverURL + verifier)', async () => { + const state = { serverURL: 'http://127.0.0.1:5454/mcp', codeVerifier: 'v' }; + await store.save(state); + expect(await store.load()).toEqual(state); }); it('writes the file with mode 0600', async () => { - await store.save(makeToken()); + await store.save(makeState()); const stat = await fs.stat(store.path); expect(stat.mode & 0o777).toBe(0o600); }); it('clear() removes the file', async () => { - await store.save(makeToken()); + await store.save(makeState()); await store.clear(); expect(await store.load()).toBeNull(); }); @@ -66,35 +64,20 @@ describe('TokenStore', () => { expect(await store.load()).toBeNull(); }); - it('load() returns null for JSON missing required fields', async () => { - await fs.writeFile(store.path, JSON.stringify({ foo: 'bar' }), { mode: 0o600 }); + it('load() returns null for JSON missing serverURL', async () => { + await fs.writeFile(store.path, JSON.stringify({ tokens: { access_token: 't' } }), { mode: 0o600 }); expect(await store.load()).toBeNull(); }); - it('load() back-fills missing expiresAt and createdAt for forward-compat', async () => { - // A token written by a pre-expiresAt version of the bridge. - await fs.writeFile(store.path, JSON.stringify({ - serverURL: 'http://127.0.0.1:39725/mcp', - clientId: 'old', - accessToken: 'tok', - }), { mode: 0o600 }); - const loaded = await store.load(); - expect(loaded?.clientId).toBe('old'); - expect(loaded?.expiresAt).toBeNull(); - expect(typeof loaded?.createdAt).toBe('string'); - }); - it('save() is atomic — readers never see a partial file', async () => { - const writes = Array.from({ length: 20 }, (_, i) => store.save(makeToken({ - clientId: `cid-${i}`, - accessToken: `tok-${i}`, + const writes = Array.from({ length: 20 }, (_, i) => store.save(makeState({ + tokens: { access_token: `tok-${i}`, token_type: 'Bearer' }, }))); const reads = Array.from({ length: 20 }, () => store.load()); const [, results] = await Promise.all([Promise.all(writes), Promise.all(reads)]); for (const r of results) { if (r === null) continue; - expect(r.clientId).toMatch(/^cid-\d+$/); - expect(r.accessToken).toMatch(/^tok-\d+$/); + expect(r.tokens?.access_token).toMatch(/^tok-\d+$/); } }); }); From 159c4247b3a3d40631dcfb9340efc7e0a07bf13e Mon Sep 17 00:00:00 2001 From: Vladislav Kartashov Date: Wed, 27 May 2026 17:23:16 +0300 Subject: [PATCH 05/10] PASTE-1994 Drop tests that overlap with SDK or assert trivia MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim 11 tests that either re-tested `@modelcontextprotocol/sdk`'s own behavior (DCR / token-exchange happy paths, error-from-AS), tested constants and shape (PASTE_STATUS_TOOL name / description / schema), or exhaustively enumerated the same URL-validation branch (file://, vscode://, http://0.0.0.0 in addition to javascript: and https://evil). Kept everything that defends against bugs we actually found in review (callback DoS / state filtering, store atomicity and 0600, SSE WHATWG edge cases, 401 retry exactly-once, stdout-destroyed exit, cache short-circuit on cached access token) plus one happy-path smoke per file. 63 → 52 tests, no real coverage loss. --- test/fallback.test.ts | 37 +++------------------ test/oauth/client.test.ts | 70 ++------------------------------------- 2 files changed, 8 insertions(+), 99 deletions(-) diff --git a/test/fallback.test.ts b/test/fallback.test.ts index c2ea2d5..07f30a5 100644 --- a/test/fallback.test.ts +++ b/test/fallback.test.ts @@ -1,45 +1,18 @@ import { describe, it, expect } from 'vitest'; import { PASTE_STATUS_TOOL, - SETUP_MESSAGE, buildFallbackServer, callPasteStatus, } from '../src/fallback.js'; -describe('callPasteStatus', () => { - it('returns the setup message for paste_status', () => { +describe('fallback', () => { + it('paste_status returns a message that tells the user to start Paste', () => { const result = callPasteStatus(PASTE_STATUS_TOOL.name); expect(result.isError).toBe(false); - expect(result.content).toHaveLength(1); - expect(result.content[0]).toEqual({ type: 'text', text: SETUP_MESSAGE }); + expect(result.content[0]?.text.toLowerCase()).toContain('start paste'); }); - it('tells the user to start Paste and enable MCP', () => { - expect(SETUP_MESSAGE.toLowerCase()).toContain('start paste'); - expect(SETUP_MESSAGE.toLowerCase()).toContain('enable mcp'); - }); - - it('throws on unknown tool names', () => { - expect(() => callPasteStatus('paste_unknown')).toThrow(/Unknown tool/); - }); -}); - -describe('PASTE_STATUS_TOOL', () => { - it('is named paste_status with an empty object schema', () => { - expect(PASTE_STATUS_TOOL.name).toBe('paste_status'); - expect(PASTE_STATUS_TOOL.inputSchema.type).toBe('object'); - expect(PASTE_STATUS_TOOL.inputSchema.properties).toEqual({}); - expect(PASTE_STATUS_TOOL.inputSchema.additionalProperties).toBe(false); - }); - - it('has a description that mentions Paste', () => { - expect(PASTE_STATUS_TOOL.description.toLowerCase()).toContain('paste'); - }); -}); - -describe('buildFallbackServer', () => { - it('constructs a server without throwing', () => { - const server = buildFallbackServer(); - expect(server).toBeDefined(); + it('builds a server without throwing', () => { + expect(buildFallbackServer()).toBeDefined(); }); }); diff --git a/test/oauth/client.test.ts b/test/oauth/client.test.ts index 3cff7af..3881002 100644 --- a/test/oauth/client.test.ts +++ b/test/oauth/client.test.ts @@ -189,82 +189,18 @@ describe('OAuthClient', () => { expect((await store.load())?.serverURL).toBe(`${mock.url}/mcp`); }); - it('invalidate() clears the cache so the next call re-runs OAuth', async () => { - const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { - openBrowser: simulatedBrowser(), - }); - await client.accessToken(); - await client.invalidate(); - expect(await store.load()).toBeNull(); - - mock.setNextCode('mock-code-2'); - const token = await client.accessToken(); - expect(token).toBe('tok-for-mock-code-2'); - expect(mock.registerCalls).toBe(2); - }); - - it('throws when the OAuth provider returns an error in the callback', async () => { - const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { - openBrowser: async (authUrlStr) => { - const authURL = new URL(authUrlStr); - const redirectUri = authURL.searchParams.get('redirect_uri')!; - const stateParam = authURL.searchParams.get('state') ?? ''; - const cb = new URL(redirectUri); - cb.searchParams.set('error', 'access_denied'); - cb.searchParams.set('error_description', 'user denied'); - cb.searchParams.set('state', stateParam); - await fetch(cb.toString()); - }, - }); - await expect(client.accessToken()).rejects.toThrow(/access_denied/); - // Cache may have partial state (DCR'd client + verifier); the access token - // itself never landed, so the next attempt will re-auth. - expect((await store.load())?.tokens).toBeUndefined(); - }); - - it('persists refresh_token from the token response', async () => { - mock.setTokenResponse({ - access_token: 'tok', - token_type: 'Bearer', - refresh_token: 'rt-1', - expires_in: 3600, - }); - const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { - openBrowser: simulatedBrowser(), - }); - await client.accessToken(); - const cached = await store.load(); - expect(cached?.tokens?.refresh_token).toBe('rt-1'); - expect(cached?.tokens?.expires_in).toBe(3600); - }); }); describe('assertLoopbackHTTPURL', () => { - it('accepts http://127.0.0.1', () => { + it('accepts an HTTP loopback URL', () => { expect(() => assertLoopbackHTTPURL('http://127.0.0.1:5454/authorize')).not.toThrow(); }); - it('accepts http://localhost', () => { - expect(() => assertLoopbackHTTPURL('http://localhost:5454/authorize')).not.toThrow(); - }); - - it('rejects javascript: URLs (attacker-supplied auth metadata)', () => { + it('rejects non-HTTP(S) URL schemes — javascript:, file:, vscode:, etc.', () => { expect(() => assertLoopbackHTTPURL('javascript:alert(1)')).toThrow(/non-HTTP/); }); - it('rejects file: URLs', () => { - expect(() => assertLoopbackHTTPURL('file:///etc/passwd')).toThrow(/non-HTTP/); - }); - - it('rejects vscode: and other custom URL handlers', () => { - expect(() => assertLoopbackHTTPURL('vscode://settings')).toThrow(/non-HTTP/); - }); - - it('rejects HTTPS to a non-loopback host', () => { + it('rejects URLs whose host is not loopback', () => { expect(() => assertLoopbackHTTPURL('https://evil.example.com/authorize')).toThrow(/non-loopback/); }); - - it('rejects http://0.0.0.0 (not loopback)', () => { - expect(() => assertLoopbackHTTPURL('http://0.0.0.0/authorize')).toThrow(/non-loopback/); - }); }); From 7c396d39d0fd791b886c3b47604859302a1bbdbe Mon Sep 17 00:00:00 2001 From: Vladislav Kartashov Date: Wed, 27 May 2026 17:35:34 +0300 Subject: [PATCH 06/10] Mark package-lock.json as generated so GitHub collapses it in diffs --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cf96872 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +package-lock.json linguist-generated=true From 477d3493efaa7e776c74a1a8d9597cc637093733 Mon Sep 17 00:00:00 2001 From: Vladislav Kartashov Date: Wed, 27 May 2026 17:47:33 +0300 Subject: [PATCH 07/10] PASTE-1994 OAuth hardening pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six small fixes from a second review round on the SDK-backed bridge: - Move `assertLoopbackHTTPURL` from `defaultOpenBrowser` up to `BridgeProvider.redirectToAuthorization`. The AS-supplied `authorization_endpoint` is attacker-influenceable if the discovered port has been hijacked, and the guard belongs to the adapter that owns the invariant — not the default opener (a test-injected `openBrowser` would otherwise bypass it). - `loadCache()` returns a fresh empty state when the on-disk cache was issued for a different `serverURL`, instead of silently grafting another server's `tokens`/`clientInformation` onto a save that only touches one field. Closes a latent race for any future caller that multiplexes flows. - `saveTokens()` drops the PKCE `codeVerifier` from disk in the same write. Verifier is single-use; once tokens land it's just at-rest surface for no purpose. - Drop the misleading "RFC 9207 iss validation" claim from the header comment — SDK 1.21 doesn't enforce it. - Tighten `mergeCache` parameter type to `Partial` and `saveClientInformation` signature to `OAuthClientInformationMixed` to match the SDK `OAuthClientProvider` interface exactly. - Add two tests: `invalidate()` → next call truly re-runs OAuth (the short-circuit/invalidation interaction we trimmed earlier), and `codeVerifier` is absent from the cache after a successful token exchange. 54 tests pass. --- src/oauth/client.ts | 39 ++++++++++++++++++++++++++++----------- test/oauth/client.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/oauth/client.ts b/src/oauth/client.ts index b161991..f2de72c 100644 --- a/src/oauth/client.ts +++ b/src/oauth/client.ts @@ -2,9 +2,9 @@ // `@modelcontextprotocol/sdk` `auth()` function. The SDK handles metadata // discovery (RFC 9728 protected-resource + RFC 8414 + OIDC fallback), // dynamic client registration (RFC 7591), PKCE generation/verification, -// the `resource` indicator (RFC 8707), `iss` validation (RFC 9207), token -// exchange, and refresh-token rotation. We provide the storage backing -// and the side effects (open browser, wait on the loopback callback). +// the `resource` indicator (RFC 8707), token exchange, and refresh-token +// rotation. We provide the storage backing and the side effects (open +// browser, wait on the loopback callback). import { spawn } from 'node:child_process'; import { @@ -12,13 +12,12 @@ import { type OAuthClientProvider, } from '@modelcontextprotocol/sdk/client/auth.js'; import type { - OAuthClientInformationFull, OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens, } from '@modelcontextprotocol/sdk/shared/auth.js'; import { startCallbackServer, type CallbackServer } from './callback.js'; -import { TokenStore } from './store.js'; +import { TokenStore, type CachedOAuthState } from './store.js'; const CLIENT_NAME = 'Paste MCP Bridge'; @@ -141,7 +140,7 @@ class BridgeProvider implements OAuthClientProvider { return (await this.loadCache()).clientInformation; } - async saveClientInformation(info: OAuthClientInformationFull): Promise { + async saveClientInformation(info: OAuthClientInformationMixed): Promise { await this.mergeCache({ clientInformation: info }); } @@ -150,7 +149,14 @@ class BridgeProvider implements OAuthClientProvider { } async saveTokens(tokens: OAuthTokens): Promise { - await this.mergeCache({ tokens }); + // The verifier is single-use; once tokens land we don't need it on disk. + const cached = await this.loadCache(); + const next: CachedOAuthState = { + serverURL: this.opts.serverURL, + tokens, + ...(cached.clientInformation ? { clientInformation: cached.clientInformation } : {}), + }; + await this.opts.store.save(next); } async saveCodeVerifier(verifier: string): Promise { @@ -166,6 +172,10 @@ class BridgeProvider implements OAuthClientProvider { } async redirectToAuthorization(authorizationUrl: URL): Promise { + // Enforced here (not just in defaultOpenBrowser) so an injected opener + // can't bypass the guard. The AS-supplied `authorization_endpoint` is + // attacker-influenceable if the discovered port has been hijacked. + assertLoopbackHTTPURL(authorizationUrl.toString()); await this.opts.openBrowser(authorizationUrl.toString()); } @@ -184,11 +194,17 @@ class BridgeProvider implements OAuthClientProvider { await this.opts.store.save(next); } - private async loadCache() { - return (await this.opts.store.load()) ?? { serverURL: this.opts.serverURL }; + private async loadCache(): Promise { + const stored = await this.opts.store.load(); + // Cache must belong to the server we were constructed for. A stale entry + // for a different server gets dropped here rather than overwritten — the + // alternative (always stamping `serverURL: this.opts.serverURL` on save) + // would silently graft another server's tokens onto our cache. + if (stored && stored.serverURL === this.opts.serverURL) return stored; + return { serverURL: this.opts.serverURL }; } - private async mergeCache(patch: Partial> & object>): Promise { + private async mergeCache(patch: Partial): Promise { const cached = await this.loadCache(); await this.opts.store.save({ ...cached, ...patch, serverURL: this.opts.serverURL }); } @@ -210,7 +226,8 @@ export function assertLoopbackHTTPURL(url: string): URL { } function defaultOpenBrowser(url: string): void { - assertLoopbackHTTPURL(url); + // URL guard lives in BridgeProvider.redirectToAuthorization so an injected + // opener can't bypass it; here we just spawn `/usr/bin/open`. const child = spawn('/usr/bin/open', [url], { stdio: 'ignore', detached: true }); // Without this listener, a missing `/usr/bin/open` (non-macOS) would crash // the process with ERR_UNHANDLED_ERROR; here it fails silently and the diff --git a/test/oauth/client.test.ts b/test/oauth/client.test.ts index 3881002..36febc4 100644 --- a/test/oauth/client.test.ts +++ b/test/oauth/client.test.ts @@ -173,6 +173,32 @@ describe('OAuthClient', () => { expect(mock.tokenCalls).toBe(1); }); + it('invalidate() clears the cache so the next call re-runs OAuth', async () => { + const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { + openBrowser: simulatedBrowser(), + }); + await client.accessToken(); + expect(mock.registerCalls).toBe(1); + + await client.invalidate(); + expect(await store.load()).toBeNull(); + + mock.setNextCode('mock-code-2'); + const next = await client.accessToken(); + expect(next).toBe('tok-for-mock-code-2'); + expect(mock.registerCalls).toBe(2); // re-registered, not short-circuited + }); + + it('wipes the PKCE code verifier from disk after the token exchange', async () => { + const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { + openBrowser: simulatedBrowser(), + }); + await client.accessToken(); + const cached = await store.load(); + expect(cached?.tokens?.access_token).toBeDefined(); + expect(cached?.codeVerifier).toBeUndefined(); + }); + it('drops a cached state that was issued for a different server URL', async () => { const stale: CachedOAuthState = { serverURL: 'http://127.0.0.1:99999/mcp', From a5ee4ee9eb98d0158ece6642aeebeb4ec8334c36 Mon Sep 17 00:00:00 2001 From: Vladislav Kartashov Date: Thu, 28 May 2026 11:01:48 +0300 Subject: [PATCH 08/10] PASTE-1994 Detect AI tool name for DCR client_name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hard-coded "Paste MCP Bridge" client name with a layered detector so each connected app appears in Paste's MCP & AI Tools list under its own name (Claude Desktop, Claude Code, Cursor, Codex, Windsurf, VS Code) instead of all collapsing to "@pasteapp/mcp". Resolution order, top wins: - `PASTE_MCP_CLIENT` env var — set by Paste's Connect AI Tool button or by `add-mcp` invocations that know the target up front. - Process-tree walk via `ps -p -o ppid=,args=`, stepping past shell / `node` / `npx` wrappers, matching the first ancestor whose argv contains a known signature. - Fall back to `@pasteapp/mcp`. The detected name must contain one of the substrings Paste's Swift `Client.Kind.inferred(fromClientName:)` recognizes (`claude code`, `claude`, `cursor`, `codex`, `windsurf`, `vscode`); otherwise the client lands as `.custom` with the slug. Includes 11 vitest cases covering each known client, env override, multi-hop walks past wrappers, stop-at-unknown-non-wrapper, and the ps-fails fallback. 65 tests total. --- src/client-name.ts | 98 +++++++++++++++++++++++++++++++++ src/oauth/client.ts | 19 +++++-- test/client-name.test.ts | 115 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 src/client-name.ts create mode 100644 test/client-name.test.ts diff --git a/src/client-name.ts b/src/client-name.ts new file mode 100644 index 0000000..9013b58 --- /dev/null +++ b/src/client-name.ts @@ -0,0 +1,98 @@ +// Identify which AI tool spawned the bridge so Paste's `Client.Kind.inferred` +// can pick the right icon and label in the MCP & AI Tools list. Three layers: +// +// 1. PASTE_MCP_CLIENT env var — set by Paste's "Connect AI Tool" button or +// add-mcp invocations that know the target client up front. +// 2. Process-tree walk — find the first ancestor whose argv mentions a +// known AI tool, skipping shells/node/npx wrappers along the way. +// 3. Fallback to the npm package slug. +// +// The returned name MUST contain a substring that Paste's Swift +// `Client.Kind.inferred(fromClientName:)` recognizes — "claude code", +// "claude", "cursor", "codex", "windsurf", "vscode"/"vs code" — otherwise +// the client lands as `.custom` with the bare package slug. + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +const DEFAULT_NAME = '@pasteapp/mcp'; +const ENV_VAR = 'PASTE_MCP_CLIENT'; + +// More specific first — `Claude Code` must be tested before `Claude Desktop` +// because the Code CLI's argv also contains the substring "claude". +const CLIENT_PATTERNS: ReadonlyArray<{ pattern: RegExp; name: string }> = [ + { pattern: /@anthropic-ai\/claude-code|\.claude\/local\/.*\bclaude\b|\bclaude\b[^ ]*cli/i, name: 'Claude Code' }, + { pattern: /Claude\.app\b/, name: 'Claude Desktop' }, + { pattern: /Cursor\.app\b/, name: 'Cursor' }, + { pattern: /Windsurf\.app\b/i, name: 'Windsurf' }, + { pattern: /Visual Studio Code\.app\b|\/Code\.app\b/i, name: 'VS Code' }, + { pattern: /@openai\/codex|\bcodex\b/i, name: 'Codex' }, +]; + +// Processes we step past when walking up — they're shell/runtime wrappers, +// not the actual host. Matched only AFTER the client patterns fail. +const WRAPPER_PATTERNS: ReadonlyArray = [ + /\bnpx\b/, + /\bnode\b/, + /\bsh\b|\bbash\b|\bzsh\b|\bfish\b/, +]; + +export interface ProcessInfo { + ppid: number; + args: string; +} + +export interface DetectOptions { + env?: NodeJS.ProcessEnv; + startPid?: number; + readProcessArgs?: (pid: number) => Promise; +} + +async function defaultReadProcessArgs(pid: number): Promise { + try { + const { stdout } = await execFileAsync( + '/bin/ps', + ['-p', String(pid), '-o', 'ppid=,args='], + { timeout: 500 }, + ); + const match = stdout.trim().match(/^\s*(\d+)\s+(.+)$/); + if (!match) return null; + return { ppid: Number.parseInt(match[1]!, 10), args: match[2]! }; + } catch { + return null; + } +} + +function matchClient(args: string): string | null { + for (const { pattern, name } of CLIENT_PATTERNS) { + if (pattern.test(args)) return name; + } + return null; +} + +function isWrapper(args: string): boolean { + return WRAPPER_PATTERNS.some((p) => p.test(args)); +} + +export async function detectClientName(opts: DetectOptions = {}): Promise { + const env = opts.env ?? process.env; + const override = env[ENV_VAR]; + if (override && override.length > 0) return override; + + const read = opts.readProcessArgs ?? defaultReadProcessArgs; + let pid = opts.startPid ?? process.ppid; + for (let i = 0; i < 8; i++) { + const result = await read(pid); + if (!result) break; + const matched = matchClient(result.args); + if (matched) return matched; + // Stop the moment we hit a non-wrapper we don't recognize — going past it + // would just walk into the user's shell / WindowServer / launchd. + if (!isWrapper(result.args)) break; + if (result.ppid <= 1 || result.ppid === pid) break; + pid = result.ppid; + } + return DEFAULT_NAME; +} diff --git a/src/oauth/client.ts b/src/oauth/client.ts index f2de72c..faac520 100644 --- a/src/oauth/client.ts +++ b/src/oauth/client.ts @@ -16,11 +16,10 @@ import type { OAuthClientMetadata, OAuthTokens, } from '@modelcontextprotocol/sdk/shared/auth.js'; +import { detectClientName } from '../client-name.js'; import { startCallbackServer, type CallbackServer } from './callback.js'; import { TokenStore, type CachedOAuthState } from './store.js'; -const CLIENT_NAME = 'Paste MCP Bridge'; - export type StartCallbackServer = () => Promise; export type OpenBrowser = (url: string) => void | Promise; @@ -28,6 +27,9 @@ export interface OAuthClientOptions { fetch?: typeof fetch; openBrowser?: OpenBrowser; startCallbackServer?: StartCallbackServer; + /// Override the auto-detected AI tool name sent at DCR. Default: env + /// `PASTE_MCP_CLIENT` → process-tree heuristic → `@pasteapp/mcp`. + clientName?: string | (() => string | Promise); } export class OAuthClient { @@ -38,7 +40,7 @@ export class OAuthClient { constructor( public readonly serverURL: URL, private readonly store: TokenStore = new TokenStore(), - opts: OAuthClientOptions = {}, + private readonly opts: OAuthClientOptions = {}, ) { this.fetchImpl = opts.fetch ?? fetch; this.openBrowserImpl = opts.openBrowser ?? defaultOpenBrowser; @@ -65,6 +67,7 @@ export class OAuthClient { state: callback.state, store: this.store, openBrowser: this.openBrowserImpl, + clientName: await this.resolveClientName(), }); try { const first = await auth(provider, { @@ -97,6 +100,13 @@ export class OAuthClient { async invalidate(): Promise { await this.store.clear(); } + + private async resolveClientName(): Promise { + const override = this.opts.clientName; + if (typeof override === 'string') return override; + if (typeof override === 'function') return await override(); + return await detectClientName(); + } } async function readToken(provider: OAuthClientProvider): Promise { @@ -111,6 +121,7 @@ interface BridgeProviderOptions { state: string; store: TokenStore; openBrowser: OpenBrowser; + clientName: string; } class BridgeProvider implements OAuthClientProvider { @@ -122,7 +133,7 @@ class BridgeProvider implements OAuthClientProvider { get clientMetadata(): OAuthClientMetadata { return { - client_name: CLIENT_NAME, + client_name: this.opts.clientName, redirect_uris: [this.opts.redirectUrl], token_endpoint_auth_method: 'none', grant_types: ['authorization_code', 'refresh_token'], diff --git a/test/client-name.test.ts b/test/client-name.test.ts new file mode 100644 index 0000000..bca3d92 --- /dev/null +++ b/test/client-name.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { detectClientName, type ProcessInfo } from '../src/client-name.js'; + +function chain(...steps: ProcessInfo[]): (pid: number) => Promise { + let i = 0; + return async () => (i < steps.length ? steps[i++]! : null); +} + +describe('detectClientName', () => { + it('returns PASTE_MCP_CLIENT when set in the env', async () => { + const name = await detectClientName({ + env: { PASTE_MCP_CLIENT: 'Codex' }, + readProcessArgs: async () => null, + }); + expect(name).toBe('Codex'); + }); + + it('ignores empty env override and walks the process tree', async () => { + const name = await detectClientName({ + env: { PASTE_MCP_CLIENT: '' }, + startPid: 100, + readProcessArgs: chain({ ppid: 0, args: '/Applications/Claude.app/Contents/MacOS/Claude' }), + }); + expect(name).toBe('Claude Desktop'); + }); + + it('recognizes Claude Desktop', async () => { + expect(await detectClientName({ + env: {}, + startPid: 1, + readProcessArgs: chain({ ppid: 0, args: '/Applications/Claude.app/Contents/MacOS/Claude' }), + })).toBe('Claude Desktop'); + }); + + it('recognizes Claude Code by walking past npx + node wrappers', async () => { + expect(await detectClientName({ + env: {}, + startPid: 1, + readProcessArgs: chain( + { ppid: 2, args: 'npx -y @pasteapp/mcp' }, + { ppid: 3, args: '/usr/local/bin/node /Users/u/.claude/local/node_modules/.bin/claude' }, + ), + })).toBe('Claude Code'); + }); + + it('recognizes Cursor', async () => { + expect(await detectClientName({ + env: {}, + startPid: 1, + readProcessArgs: chain({ ppid: 0, args: '/Applications/Cursor.app/Contents/MacOS/Cursor --type=renderer' }), + })).toBe('Cursor'); + }); + + it('recognizes Codex by argv', async () => { + expect(await detectClientName({ + env: {}, + startPid: 1, + readProcessArgs: chain( + { ppid: 2, args: 'npx -y @pasteapp/mcp' }, + { ppid: 3, args: '/usr/local/bin/node /usr/local/lib/node_modules/@openai/codex/dist/cli.js' }, + ), + })).toBe('Codex'); + }); + + it('recognizes Windsurf', async () => { + expect(await detectClientName({ + env: {}, + startPid: 1, + readProcessArgs: chain({ ppid: 0, args: '/Applications/Windsurf.app/Contents/MacOS/Windsurf' }), + })).toBe('Windsurf'); + }); + + it('recognizes VS Code', async () => { + expect(await detectClientName({ + env: {}, + startPid: 1, + readProcessArgs: chain({ ppid: 0, args: '/Applications/Visual Studio Code.app/Contents/MacOS/Electron' }), + })).toBe('VS Code'); + }); + + it('falls back to the package slug when nothing in the tree matches', async () => { + expect(await detectClientName({ + env: {}, + startPid: 1, + readProcessArgs: chain( + { ppid: 2, args: 'npx -y @pasteapp/mcp' }, + { ppid: 3, args: 'node /random/script.js' }, + { ppid: 4, args: '/bin/zsh' }, + { ppid: 0, args: '/sbin/launchd' }, + ), + })).toBe('@pasteapp/mcp'); + }); + + it('stops walking past an unknown non-wrapper process', async () => { + expect(await detectClientName({ + env: {}, + startPid: 1, + readProcessArgs: chain( + { ppid: 2, args: 'npx -y @pasteapp/mcp' }, + { ppid: 3, args: '/Applications/SomeWrapper.app/Contents/MacOS/SomeWrapper' }, + // Claude.app sits further up but we never reach it — SomeWrapper is + // not a recognized wrapper. + { ppid: 0, args: '/Applications/Claude.app/Contents/MacOS/Claude' }, + ), + })).toBe('@pasteapp/mcp'); + }); + + it('falls back when ps returns null at every step', async () => { + expect(await detectClientName({ + env: {}, + startPid: 1, + readProcessArgs: async () => null, + })).toBe('@pasteapp/mcp'); + }); +}); From 263f5d0142bb0479bce13c75e7192f313ddb4067 Mon Sep 17 00:00:00 2001 From: Vladislav Kartashov Date: Thu, 28 May 2026 11:07:11 +0300 Subject: [PATCH 09/10] PASTE-1994 Drop PASTE_MCP_CLIENT env override, keep heuristic only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The process-tree heuristic alone catches Claude Desktop, Claude Code, Cursor, Codex, Windsurf, and VS Code reliably on macOS, so the env escape hatch is unused surface — the env was only meant for Paste's Connect AI Tool button to inject an authoritative name, but every install path the README documents (`add-mcp`, manual snippets, the Cursor/VS Code deeplink buttons) sets no env, and would always have fallen through to the heuristic anyway. Removing it keeps the detection contract one-shaped. --- src/client-name.ts | 17 ++++------------- src/oauth/client.ts | 4 ++-- test/client-name.test.ts | 26 -------------------------- 3 files changed, 6 insertions(+), 41 deletions(-) diff --git a/src/client-name.ts b/src/client-name.ts index 9013b58..d218ad3 100644 --- a/src/client-name.ts +++ b/src/client-name.ts @@ -1,11 +1,8 @@ // Identify which AI tool spawned the bridge so Paste's `Client.Kind.inferred` -// can pick the right icon and label in the MCP & AI Tools list. Three layers: -// -// 1. PASTE_MCP_CLIENT env var — set by Paste's "Connect AI Tool" button or -// add-mcp invocations that know the target client up front. -// 2. Process-tree walk — find the first ancestor whose argv mentions a -// known AI tool, skipping shells/node/npx wrappers along the way. -// 3. Fallback to the npm package slug. +// can pick the right icon and label in the MCP & AI Tools list. Walks the +// parent-process tree via `ps`, stepping past shell/node/npx wrappers, and +// matches the first ancestor whose argv mentions a known AI tool. Falls back +// to the npm package slug when nothing matches. // // The returned name MUST contain a substring that Paste's Swift // `Client.Kind.inferred(fromClientName:)` recognizes — "claude code", @@ -18,7 +15,6 @@ import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); const DEFAULT_NAME = '@pasteapp/mcp'; -const ENV_VAR = 'PASTE_MCP_CLIENT'; // More specific first — `Claude Code` must be tested before `Claude Desktop` // because the Code CLI's argv also contains the substring "claude". @@ -45,7 +41,6 @@ export interface ProcessInfo { } export interface DetectOptions { - env?: NodeJS.ProcessEnv; startPid?: number; readProcessArgs?: (pid: number) => Promise; } @@ -77,10 +72,6 @@ function isWrapper(args: string): boolean { } export async function detectClientName(opts: DetectOptions = {}): Promise { - const env = opts.env ?? process.env; - const override = env[ENV_VAR]; - if (override && override.length > 0) return override; - const read = opts.readProcessArgs ?? defaultReadProcessArgs; let pid = opts.startPid ?? process.ppid; for (let i = 0; i < 8; i++) { diff --git a/src/oauth/client.ts b/src/oauth/client.ts index faac520..de8ffff 100644 --- a/src/oauth/client.ts +++ b/src/oauth/client.ts @@ -27,8 +27,8 @@ export interface OAuthClientOptions { fetch?: typeof fetch; openBrowser?: OpenBrowser; startCallbackServer?: StartCallbackServer; - /// Override the auto-detected AI tool name sent at DCR. Default: env - /// `PASTE_MCP_CLIENT` → process-tree heuristic → `@pasteapp/mcp`. + /// Override the auto-detected AI tool name sent at DCR. Default: a + /// process-tree heuristic that falls back to the `@pasteapp/mcp` slug. clientName?: string | (() => string | Promise); } diff --git a/test/client-name.test.ts b/test/client-name.test.ts index bca3d92..5c084b5 100644 --- a/test/client-name.test.ts +++ b/test/client-name.test.ts @@ -7,26 +7,8 @@ function chain(...steps: ProcessInfo[]): (pid: number) => Promise { - it('returns PASTE_MCP_CLIENT when set in the env', async () => { - const name = await detectClientName({ - env: { PASTE_MCP_CLIENT: 'Codex' }, - readProcessArgs: async () => null, - }); - expect(name).toBe('Codex'); - }); - - it('ignores empty env override and walks the process tree', async () => { - const name = await detectClientName({ - env: { PASTE_MCP_CLIENT: '' }, - startPid: 100, - readProcessArgs: chain({ ppid: 0, args: '/Applications/Claude.app/Contents/MacOS/Claude' }), - }); - expect(name).toBe('Claude Desktop'); - }); - it('recognizes Claude Desktop', async () => { expect(await detectClientName({ - env: {}, startPid: 1, readProcessArgs: chain({ ppid: 0, args: '/Applications/Claude.app/Contents/MacOS/Claude' }), })).toBe('Claude Desktop'); @@ -34,7 +16,6 @@ describe('detectClientName', () => { it('recognizes Claude Code by walking past npx + node wrappers', async () => { expect(await detectClientName({ - env: {}, startPid: 1, readProcessArgs: chain( { ppid: 2, args: 'npx -y @pasteapp/mcp' }, @@ -45,7 +26,6 @@ describe('detectClientName', () => { it('recognizes Cursor', async () => { expect(await detectClientName({ - env: {}, startPid: 1, readProcessArgs: chain({ ppid: 0, args: '/Applications/Cursor.app/Contents/MacOS/Cursor --type=renderer' }), })).toBe('Cursor'); @@ -53,7 +33,6 @@ describe('detectClientName', () => { it('recognizes Codex by argv', async () => { expect(await detectClientName({ - env: {}, startPid: 1, readProcessArgs: chain( { ppid: 2, args: 'npx -y @pasteapp/mcp' }, @@ -64,7 +43,6 @@ describe('detectClientName', () => { it('recognizes Windsurf', async () => { expect(await detectClientName({ - env: {}, startPid: 1, readProcessArgs: chain({ ppid: 0, args: '/Applications/Windsurf.app/Contents/MacOS/Windsurf' }), })).toBe('Windsurf'); @@ -72,7 +50,6 @@ describe('detectClientName', () => { it('recognizes VS Code', async () => { expect(await detectClientName({ - env: {}, startPid: 1, readProcessArgs: chain({ ppid: 0, args: '/Applications/Visual Studio Code.app/Contents/MacOS/Electron' }), })).toBe('VS Code'); @@ -80,7 +57,6 @@ describe('detectClientName', () => { it('falls back to the package slug when nothing in the tree matches', async () => { expect(await detectClientName({ - env: {}, startPid: 1, readProcessArgs: chain( { ppid: 2, args: 'npx -y @pasteapp/mcp' }, @@ -93,7 +69,6 @@ describe('detectClientName', () => { it('stops walking past an unknown non-wrapper process', async () => { expect(await detectClientName({ - env: {}, startPid: 1, readProcessArgs: chain( { ppid: 2, args: 'npx -y @pasteapp/mcp' }, @@ -107,7 +82,6 @@ describe('detectClientName', () => { it('falls back when ps returns null at every step', async () => { expect(await detectClientName({ - env: {}, startPid: 1, readProcessArgs: async () => null, })).toBe('@pasteapp/mcp'); From 1285cb55f1cc53c56c6e342521753e4d19735068 Mon Sep 17 00:00:00 2001 From: Vladislav Kartashov Date: Thu, 28 May 2026 11:12:25 +0300 Subject: [PATCH 10/10] PASTE-1994 Detect OpenCode in the client-name heuristic --- src/client-name.ts | 5 ++++- test/client-name.test.ts | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/client-name.ts b/src/client-name.ts index d218ad3..ec8f43e 100644 --- a/src/client-name.ts +++ b/src/client-name.ts @@ -7,7 +7,9 @@ // The returned name MUST contain a substring that Paste's Swift // `Client.Kind.inferred(fromClientName:)` recognizes — "claude code", // "claude", "cursor", "codex", "windsurf", "vscode"/"vs code" — otherwise -// the client lands as `.custom` with the bare package slug. +// the client lands as `.custom` with the slug below as its display name. +// (OpenCode is detected here so it shows up labeled, but Paste still maps +// it to `.custom` until the Swift side gains a case for it.) import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; @@ -25,6 +27,7 @@ const CLIENT_PATTERNS: ReadonlyArray<{ pattern: RegExp; name: string }> = [ { pattern: /Windsurf\.app\b/i, name: 'Windsurf' }, { pattern: /Visual Studio Code\.app\b|\/Code\.app\b/i, name: 'VS Code' }, { pattern: /@openai\/codex|\bcodex\b/i, name: 'Codex' }, + { pattern: /\bopencode\b/i, name: 'OpenCode' }, ]; // Processes we step past when walking up — they're shell/runtime wrappers, diff --git a/test/client-name.test.ts b/test/client-name.test.ts index 5c084b5..90e2ec8 100644 --- a/test/client-name.test.ts +++ b/test/client-name.test.ts @@ -31,6 +31,13 @@ describe('detectClientName', () => { })).toBe('Cursor'); }); + it('recognizes OpenCode by argv', async () => { + expect(await detectClientName({ + startPid: 1, + readProcessArgs: chain({ ppid: 0, args: '/opt/homebrew/bin/opencode --tui' }), + })).toBe('OpenCode'); + }); + it('recognizes Codex by argv', async () => { expect(await detectClientName({ startPid: 1,