diff --git a/package-lock.json b/package-lock.json index 8d1b346..64a8506 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,17 @@ { "name": "@cueapi/mcp", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cueapi/mcp", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", + "better-sqlite3": "^11.3.0", + "express": "^4.21.0", "zod": "^3.23.0", "zod-to-json-schema": "^3.24.0" }, @@ -17,7 +19,11 @@ "cueapi-mcp": "dist/index.js" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.11", + "@types/express": "^5.0.0", "@types/node": "^22.0.0", + "@types/supertest": "^6.0.2", + "supertest": "^7.0.0", "tsx": "^4.19.0", "typescript": "^5.5.0", "vitest": "^2.0.0" @@ -527,6 +533,275 @@ } } }, + "node_modules/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -877,24 +1152,159 @@ "win32" ] }, - "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==", + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/node": "*" + } }, - "node_modules/@types/node": { - "version": "22.19.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", - "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "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/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -1009,13 +1419,13 @@ } }, "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==", + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "license": "MIT", "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" + "mime-types": "~2.1.34", + "negotiator": "0.6.3" }, "engines": { "node": ">= 0.6" @@ -1054,6 +1464,19 @@ } } }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1064,28 +1487,152 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "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==", + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "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" + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" }, "engines": { - "node": ">=18" + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/body-parser/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" } }, "node_modules/bytes": { @@ -1163,17 +1710,45 @@ "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==", + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "delayed-stream": "~1.0.0" }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" } }, "node_modules/content-type": { @@ -1195,13 +1770,17 @@ } }, "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" - } + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" }, "node_modules/cors": { "version": "2.8.6", @@ -1251,6 +1830,21 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1261,6 +1855,25 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1270,6 +1883,36 @@ "node": ">= 0.8" } }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1299,6 +1942,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1336,6 +1988,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -1424,6 +2092,15 @@ "node": ">=18.0.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1435,43 +2112,46 @@ } }, "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "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" + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" }, "engines": { - "node": ">= 18" + "node": ">= 0.10.0" }, "funding": { "type": "opencollective", @@ -1496,12 +2176,34 @@ "express": ">= 4.11" } }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "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-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -1518,25 +2220,78 @@ ], "license": "BSD-3-Clause" }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "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" + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 18.0.0" + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://ko-fi.com/tunnckoCore/commissions" } }, "node_modules/forwarded": { @@ -1549,14 +2304,20 @@ } }, "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1631,6 +2392,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1655,10 +2422,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "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" @@ -1713,12 +2496,38 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "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/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -1792,56 +2601,97 @@ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.6" } }, - "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==", + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "license": "MIT", - "engines": { - "node": ">=18" + "bin": { + "mime": "cli.js" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=4" } }, "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==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "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==", + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { - "mime-db": "^1.54.0" + "mime-db": "1.52.0" }, "engines": { - "node": ">=18" + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1867,15 +2717,33 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "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==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "license": "MIT", "engines": { "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1937,14 +2805,10 @@ } }, "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" - } + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" }, "node_modules/pathe": { "version": "1.1.2", @@ -2008,6 +2872,33 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2021,10 +2912,20 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -2060,6 +2961,35 @@ "node": ">= 0.10" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2140,55 +3070,106 @@ "node": ">= 18" } }, + "node_modules/router/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/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "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/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "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" + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "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": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" } }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" }, "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.8.0" } }, "node_modules/setprototypeof": { @@ -2297,6 +3278,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2330,6 +3356,111 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2403,15 +3534,26 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" + "media-typer": "0.3.0", + "mime-types": "~2.1.24" }, "engines": { "node": ">= 0.6" @@ -2447,6 +3589,21 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2462,6 +3619,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/package.json b/package.json index 204c56e..2d45208 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cueapi/mcp", - "version": "0.1.4", + "version": "0.2.0", "mcpName": "io.github.govindkavaturi-art/cueapi-mcp", "description": "Official Model Context Protocol (MCP) server for CueAPI — give your AI agent a scheduler and verification gate. Open-source execution accountability primitive for AI agents.", "type": "module", @@ -53,11 +53,17 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", + "better-sqlite3": "^11.3.0", + "express": "^4.21.0", "zod": "^3.23.0", "zod-to-json-schema": "^3.24.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.11", + "@types/express": "^5.0.0", "@types/node": "^22.0.0", + "supertest": "^7.0.0", + "@types/supertest": "^6.0.2", "tsx": "^4.19.0", "typescript": "^5.5.0", "vitest": "^2.0.0" diff --git a/src/client.ts b/src/client.ts index 068eb6f..3e98211 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,10 +4,23 @@ * We deliberately avoid adding the cueapi-sdk as a dependency — MCP servers * should be tiny and self-contained so they cold-start fast under Claude * Desktop and other hosts. + * + * 0.2.0 (HTTP transport) changes: + * + * - The constructor's ``apiKey`` is now OPTIONAL. In stdio mode it + * comes from ``CUEAPI_API_KEY`` once at boot and is reused for + * every request. In HTTP mode each incoming ``/mcp`` request + * carries its own bearer token (looked up in the token store from + * the OAuth access_token), so the key is passed on every + * ``request()`` call as the optional ``apiKey`` argument. + * + * - ``request()`` accepts a trailing ``apiKey`` that, when provided, + * overrides the constructor's default. Callers in HTTP mode + * always pass it; callers in stdio mode can omit. */ export interface CueAPIClientOptions { - apiKey: string; + apiKey?: string; baseUrl?: string; } @@ -23,14 +36,11 @@ export class CueAPIError extends Error { } export class CueAPIClient { - private readonly apiKey: string; + private readonly defaultApiKey: string | undefined; private readonly baseUrl: string; - constructor(opts: CueAPIClientOptions) { - if (!opts.apiKey) { - throw new Error("CUEAPI_API_KEY is required"); - } - this.apiKey = opts.apiKey; + constructor(opts: CueAPIClientOptions = {}) { + this.defaultApiKey = opts.apiKey; this.baseUrl = (opts.baseUrl ?? "https://api.cueapi.ai").replace(/\/$/, ""); } @@ -38,8 +48,17 @@ export class CueAPIClient { method: string, path: string, body?: Record | null, - query?: Record + query?: Record, + apiKey?: string ): Promise { + const effectiveKey = apiKey ?? this.defaultApiKey; + if (!effectiveKey) { + throw new Error( + "No API key available. Provide one to the constructor (stdio mode) " + + "or pass it on the request call (HTTP mode)." + ); + } + const qs = query ? "?" + Object.entries(query) @@ -55,10 +74,10 @@ export class CueAPIClient { const res = await fetch(url, { method, headers: { - Authorization: `Bearer ${this.apiKey}`, + Authorization: `Bearer ${effectiveKey}`, "Content-Type": "application/json", Accept: "application/json", - "User-Agent": "cueapi-mcp/0.1.0", + "User-Agent": "cueapi-mcp/0.2.0", }, body: body ? JSON.stringify(body) : undefined, }); diff --git a/src/http-entry.ts b/src/http-entry.ts new file mode 100644 index 0000000..35e9051 --- /dev/null +++ b/src/http-entry.ts @@ -0,0 +1,563 @@ +/** + * HTTP transport entry point — new in 0.2.0. + * + * Purpose: let remote MCP hosts (Claude.ai Custom Connector, any other + * OAuth-speaking MCP client) talk to cueapi-mcp over HTTP/SSE instead + * of spawning a local subprocess. Stdio mode (0.1.x) is preserved + * unchanged via ``stdio-entry.ts``. + * + * Endpoints: + * + * GET /health + * GET /.well-known/oauth-authorization-server (RFC 8414 metadata) + * GET /authorize (OAuth 2.1 + PKCE entry) + * GET /callback/cueapi (post magic-link) + * POST /token (exchange code → access_token) + * POST /mcp (MCP protocol) + * + * OAuth flow (numbered to match the inline spec comments below): + * + * 1. Claude.ai → GET /authorize with code_challenge + redirect_uri + state + * 2. We sign (Anthropic's state + challenge + redirect_uri) and redirect the + * user to cueapi.ai's magic-link flow, passing return_to=our-callback + * and our own signed state. + * 3. User completes magic-link. cueapi.ai redirects to /callback/cueapi + * with their session_token + our signed state. + * 4. We verify our state signature, extract the wrapped PKCE challenge + + * Anthropic's state, and POST to cueapi.ai /v1/auth/mcp-exchange + * with the session_token + our registered client_id. Response is the + * user's new CueAPI api_key. + * 5. We mint a random auth_code, store (code, api_key, code_challenge, + * redirect_uri, client_id, expires_at=60s) in the token store, and + * redirect the user to Anthropic's redirect_uri with ?code=... &state=... + * 6. Claude.ai → POST /token with auth_code + code_verifier + * 7. We verify the PKCE challenge, delete the auth_code (single-use), + * mint access_token (24h) + refresh_token (30d), store both with the + * api_key encrypted at rest, and return them to Anthropic. + * 8. Claude.ai → POST /mcp with Authorization: Bearer + * We look up the api_key, create a per-request CueAPIClient, and + * hand the MCP StreamableHTTPServerTransport the request. + */ + +import express, { type NextFunction, type Request, type Response } from "express"; +import { createHmac, randomBytes } from "node:crypto"; + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { z } from "zod"; + +import { CueAPIClient, CueAPIError } from "./client.js"; +import { tools } from "./tools.js"; +import { verifyCodeChallenge } from "./pkce.js"; +import { + SQLiteTokenStore, + signState, + verifyState, + type TokenStore, +} from "./token-store.js"; + +const AUTH_CODE_TTL_SECONDS = 60; +const ACCESS_TOKEN_TTL_SECONDS = 86400; // 24h +const REFRESH_TOKEN_TTL_SECONDS = 30 * 86400; // 30d +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + +// ---------- env parsing ---------- + +interface HttpConfig { + port: number; + publicUrl: string; + cueapiBaseUrl: string; + oauthSigningSecret: string; + sqlitePath: string; + allowedClaudeAiClientIds: Set; + cueapiMcpClientId: string; + cueapiMcpExchangeEndpoint: string; +} + +function loadConfig(): HttpConfig { + const missing: string[] = []; + const req = (key: string): string => { + const v = process.env[key]; + if (!v) missing.push(key); + return v || ""; + }; + + const publicUrl = (process.env.MCP_PUBLIC_URL || "").replace(/\/$/, ""); + if (!publicUrl) missing.push("MCP_PUBLIC_URL"); + const oauthSigningSecret = req("OAUTH_SIGNING_SECRET"); + const allowedRaw = req("ALLOWED_CLAUDE_AI_CLIENT_IDS"); + const cueapiMcpClientId = req("CUEAPI_MCP_CLIENT_ID"); + + if (missing.length > 0) { + throw new Error( + `HTTP transport requires these env vars: ${missing.join(", ")}. See README for the full list.` + ); + } + if (oauthSigningSecret.length < 32) { + throw new Error("OAUTH_SIGNING_SECRET must be at least 32 characters."); + } + + return { + port: parseInt(process.env.MCP_PORT || "3000", 10), + publicUrl, + cueapiBaseUrl: (process.env.CUEAPI_BASE_URL || "https://api.cueapi.ai").replace(/\/$/, ""), + oauthSigningSecret, + sqlitePath: process.env.SQLITE_PATH || "./mcp-tokens.db", + allowedClaudeAiClientIds: new Set( + allowedRaw.split(",").map((s) => s.trim()).filter(Boolean) + ), + cueapiMcpClientId: cueapiMcpClientId, + cueapiMcpExchangeEndpoint: + process.env.CUEAPI_MCP_EXCHANGE_ENDPOINT || + `${(process.env.CUEAPI_BASE_URL || "https://api.cueapi.ai").replace(/\/$/, "")}/v1/auth/mcp-exchange`, + }; +} + +// ---------- app factory (exported for tests) ---------- + +export interface HttpAppDeps { + store: TokenStore; + config: HttpConfig; + /** Optional override for tests: skip the real cueapi.ai redirect. */ + fetchApiKeyFromSession?: (sessionToken: string) => Promise<{ apiKey: string; userId: string }>; +} + +export function buildApp({ store, config, fetchApiKeyFromSession }: HttpAppDeps) { + const app = express(); + app.use(express.json({ limit: "1mb" })); + app.use(express.urlencoded({ extended: true })); + + // Inject config so handlers don't close over the outer scope. + app.locals.config = config; + app.locals.store = store; + + // ---- GET /health ---- + app.get("/health", (_req: Request, res: Response) => { + res.json({ + status: "healthy", + version: "0.2.0", + transport: "http", + uptime_seconds: Math.floor(process.uptime()), + }); + }); + + // ---- GET /.well-known/oauth-authorization-server ---- + // RFC 8414 — describes our OAuth surface to clients that introspect it. + app.get("/.well-known/oauth-authorization-server", (_req: Request, res: Response) => { + res.json({ + issuer: config.publicUrl, + authorization_endpoint: `${config.publicUrl}/authorize`, + token_endpoint: `${config.publicUrl}/token`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp"], + }); + }); + + // ---- GET /authorize ---- + // Step 1 of the OAuth flow. Claude.ai redirects the user's browser here. + app.get("/authorize", async (req: Request, res: Response) => { + const { response_type, client_id, redirect_uri, code_challenge, code_challenge_method, state, scope } = + req.query as Record; + + if (response_type !== "code") { + return sendOAuthError(res, "unsupported_response_type", "Only response_type=code is supported."); + } + if (!client_id || !config.allowedClaudeAiClientIds.has(client_id)) { + return sendOAuthError(res, "unauthorized_client", "client_id is not an approved OAuth client."); + } + if (!redirect_uri) { + return sendOAuthError(res, "invalid_request", "redirect_uri is required."); + } + if (!code_challenge) { + return sendOAuthError(res, "invalid_request", "code_challenge is required (PKCE mandatory)."); + } + if (code_challenge_method && code_challenge_method !== "S256") { + return sendOAuthError(res, "invalid_request", "code_challenge_method must be S256."); + } + + // Sign an envelope that wraps the client's state + PKCE material. + // cueapi.ai's magic-link flow will round-trip this untouched; we + // verify the signature on /callback/cueapi to prevent tampering. + const wrappedState = signState( + { + clientState: state || "", + codeChallenge: code_challenge, + redirectUri: redirect_uri, + clientId: client_id, + scope: scope || "mcp", + issuedAt: Math.floor(Date.now() / 1000), + }, + config.oauthSigningSecret + ); + + // Redirect to cueapi.ai magic-link entry with our callback URL and + // signed state. The actual magic-link UI on cueapi.ai handles the + // email-and-code dance; we just care about the session_token on the + // other side. + const callbackUrl = `${config.publicUrl}/callback/cueapi`; + const redirect = new URL("/auth/magic-link", config.cueapiBaseUrl); + redirect.searchParams.set("return_to", callbackUrl); + redirect.searchParams.set("state", wrappedState); + res.redirect(302, redirect.toString()); + }); + + // ---- GET /callback/cueapi ---- + // Step 3. cueapi.ai redirected the user back after magic-link success. + app.get("/callback/cueapi", async (req: Request, res: Response) => { + const { session_token, state } = req.query as Record; + if (!session_token || !state) { + return sendOAuthError(res, "invalid_request", "session_token and state are required."); + } + + // 3a. Verify our signed state envelope. + const unwrapped = verifyState<{ + clientState: string; + codeChallenge: string; + redirectUri: string; + clientId: string; + scope: string; + issuedAt: number; + }>(state, config.oauthSigningSecret); + if (!unwrapped) { + return sendOAuthError(res, "invalid_request", "State signature is invalid or tampered."); + } + // State shouldn't be older than 15 minutes — user who leaves their + // magic-link email sitting open for an hour needs to restart. + if (Math.floor(Date.now() / 1000) - unwrapped.issuedAt > 900) { + return sendOAuthError(res, "invalid_request", "Authorization state expired. Please retry."); + } + + // 3b. Exchange session_token for a scoped CueAPI api_key via the + // Stage B endpoint on cueapi.ai. + let apiKey: string; + let userId: string; + try { + const result = fetchApiKeyFromSession + ? await fetchApiKeyFromSession(session_token) + : await exchangeSessionTokenForApiKey({ + sessionToken: session_token, + clientId: unwrapped.clientId, + exchangeEndpoint: config.cueapiMcpExchangeEndpoint, + cueapiMcpClientId: config.cueapiMcpClientId, + }); + apiKey = result.apiKey; + userId = result.userId; + } catch (err: any) { + return sendOAuthError( + res, + "server_error", + `CueAPI exchange failed: ${err instanceof Error ? err.message : String(err)}` + ); + } + + // 5. Mint an auth_code, store (code, apiKey, challenge, ...), redirect to Anthropic. + const authCode = randomToken(32); + const now = Math.floor(Date.now() / 1000); + await store.setAuthCode(authCode, { + apiKey, + codeChallenge: unwrapped.codeChallenge, + redirectUri: unwrapped.redirectUri, + clientId: unwrapped.clientId, + createdAt: now, + expiresAt: now + AUTH_CODE_TTL_SECONDS, + }); + // Ferry Anthropic's original client state back untouched. + const anthropicRedirect = new URL(unwrapped.redirectUri); + anthropicRedirect.searchParams.set("code", authCode); + if (unwrapped.clientState) { + anthropicRedirect.searchParams.set("state", unwrapped.clientState); + } + // Best-effort: stash userId in a server-side memo for later ops. + // (We don't put it in the URL — that leaks PII into browser history.) + void userId; + res.redirect(302, anthropicRedirect.toString()); + }); + + // ---- POST /token ---- + // Step 6. Claude.ai exchanges the auth_code for an access_token using the PKCE verifier. + app.post("/token", async (req: Request, res: Response) => { + const { grant_type } = req.body as { grant_type?: string }; + if (grant_type === "authorization_code") { + return handleAuthCodeGrant(req, res, store, config); + } + if (grant_type === "refresh_token") { + return handleRefreshGrant(req, res, store, config); + } + return res.status(400).json({ + error: "unsupported_grant_type", + error_description: "grant_type must be authorization_code or refresh_token.", + }); + }); + + // ---- POST /mcp ---- + // Step 8. Claude.ai speaks MCP protocol with Bearer . + app.post("/mcp", bearerAuth(store), async (req: Request, res: Response) => { + const apiKey = (req as any).apiKey as string; + const client = new CueAPIClient({ + apiKey, + baseUrl: config.cueapiBaseUrl, + }); + + const server = buildMcpServerForRequest(client); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // stateless — each request is its own session + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + }); + + return app; +} + +// ---------- OAuth grant handlers ---------- + +async function handleAuthCodeGrant( + req: Request, + res: Response, + store: TokenStore, + config: HttpConfig +) { + const { code, code_verifier, client_id, redirect_uri } = req.body as Record; + if (!code || !code_verifier) { + return res.status(400).json({ error: "invalid_request" }); + } + + const entry = await store.getAuthCode(code); + if (!entry) { + return res.status(400).json({ error: "invalid_grant", error_description: "auth code not found or expired" }); + } + + // PKCE verification. + if (!verifyCodeChallenge(code_verifier, entry.codeChallenge, "S256")) { + // Don't delete the code yet — an attacker shouldn't be able to DoS a + // legit caller by submitting a wrong verifier. But the code does + // expire naturally within 60s, so this bounded. + return res.status(400).json({ error: "invalid_grant", error_description: "PKCE verification failed" }); + } + + // client_id + redirect_uri must match the /authorize request that + // created this code. + if (client_id && client_id !== entry.clientId) { + return res.status(400).json({ error: "invalid_grant", error_description: "client_id mismatch" }); + } + if (redirect_uri && redirect_uri !== entry.redirectUri) { + return res.status(400).json({ error: "invalid_grant", error_description: "redirect_uri mismatch" }); + } + + // Mint access + refresh tokens. + const accessToken = randomToken(40); + const refreshToken = randomToken(40); + const now = Math.floor(Date.now() / 1000); + + // Single-use — delete the auth code now. + await store.deleteAuthCode(code); + + await store.setAccessToken(accessToken, { + apiKey: entry.apiKey, + userId: "", // not known at this point; refreshed on /mcp lookup if needed + clientId: entry.clientId, + refreshToken, + createdAt: now, + expiresAt: now + ACCESS_TOKEN_TTL_SECONDS, + }); + await store.setRefreshToken(refreshToken, { + apiKey: entry.apiKey, + userId: "", + clientId: entry.clientId, + createdAt: now, + expiresAt: now + REFRESH_TOKEN_TTL_SECONDS, + }); + + return res.json({ + access_token: accessToken, + token_type: "Bearer", + expires_in: ACCESS_TOKEN_TTL_SECONDS, + refresh_token: refreshToken, + scope: "mcp", + }); +} + +async function handleRefreshGrant( + req: Request, + res: Response, + store: TokenStore, + config: HttpConfig +) { + const { refresh_token } = req.body as Record; + if (!refresh_token) { + return res.status(400).json({ error: "invalid_request" }); + } + const entry = await store.getRefreshToken(refresh_token); + if (!entry) { + return res.status(400).json({ error: "invalid_grant", error_description: "refresh_token not found or expired" }); + } + + const accessToken = randomToken(40); + const now = Math.floor(Date.now() / 1000); + await store.setAccessToken(accessToken, { + apiKey: entry.apiKey, + userId: entry.userId, + clientId: entry.clientId, + refreshToken: refresh_token, + createdAt: now, + expiresAt: now + ACCESS_TOKEN_TTL_SECONDS, + }); + return res.json({ + access_token: accessToken, + token_type: "Bearer", + expires_in: ACCESS_TOKEN_TTL_SECONDS, + scope: "mcp", + }); +} + +// ---------- middleware: bearer auth from access_token ---------- + +function bearerAuth(store: TokenStore) { + return async (req: Request, res: Response, next: NextFunction) => { + const header = req.headers.authorization || ""; + const m = header.match(/^Bearer\s+(.+)$/i); + if (!m) { + return res + .status(401) + .set("WWW-Authenticate", 'Bearer realm="mcp", error="invalid_token"') + .end(); + } + const token = m[1].trim(); + const entry = await store.getAccessToken(token); + if (!entry) { + return res + .status(401) + .set("WWW-Authenticate", 'Bearer realm="mcp", error="invalid_token"') + .end(); + } + (req as any).apiKey = entry.apiKey; + (req as any).accessTokenEntry = entry; + next(); + }; +} + +// ---------- MCP server per-request ---------- + +function buildMcpServerForRequest(client: CueAPIClient) { + const server = new Server( + { name: "cueapi-mcp", version: "0.2.0" }, + { capabilities: { tools: {} } } + ); + + const toolListResponse = { + tools: tools.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: zodToJsonSchema(t.schema, { target: "jsonSchema7" }), + })), + }; + server.setRequestHandler(ListToolsRequestSchema, async () => toolListResponse); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const tool = tools.find((t) => t.name === request.params.name); + if (!tool) { + return { + content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }], + isError: true, + }; + } + try { + const parsed = tool.schema.parse(request.params.arguments ?? {}); + const result = await tool.handler(client, parsed); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + if (err instanceof z.ZodError) { + return { + content: [{ type: "text", text: `Invalid arguments:\n${JSON.stringify(err.issues, null, 2)}` }], + isError: true, + }; + } + if (err instanceof CueAPIError) { + return { + content: [{ type: "text", text: `CueAPI error (${err.status}): ${err.message}\n${JSON.stringify(err.body, null, 2)}` }], + isError: true, + }; + } + return { + content: [{ type: "text", text: `Unexpected error: ${err instanceof Error ? err.message : String(err)}` }], + isError: true, + }; + } + }); + + return server; +} + +// ---------- helpers ---------- + +async function exchangeSessionTokenForApiKey(args: { + sessionToken: string; + clientId: string; + exchangeEndpoint: string; + cueapiMcpClientId: string; +}): Promise<{ apiKey: string; userId: string }> { + const res = await fetch(args.exchangeEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + session_token: args.sessionToken, + client_id: args.cueapiMcpClientId, + label: args.clientId, // pass through the OAuth client for labeling + }), + }); + const body = (await res.json()) as any; + if (!res.ok) { + throw new Error( + `mcp-exchange responded ${res.status}: ${body?.error?.message || JSON.stringify(body)}` + ); + } + return { apiKey: body.api_key, userId: body.user_id }; +} + +function randomToken(bytes: number): string { + return randomBytes(bytes).toString("base64url"); +} + +function sendOAuthError(res: Response, code: string, description: string) { + res.status(400).json({ error: code, error_description: description }); +} + +// ---------- main entry (called by index.ts when --transport=http) ---------- + +export async function runHttp(): Promise { + const config = loadConfig(); + const store = new SQLiteTokenStore(config.sqlitePath, config.oauthSigningSecret); + + // Periodic cleanup of expired rows. + const cleanupTimer = setInterval(() => { + store.cleanup().catch((err) => console.error("[cueapi-mcp] cleanup failed:", err)); + }, CLEANUP_INTERVAL_MS); + cleanupTimer.unref?.(); + + const app = buildApp({ store, config }); + const server = app.listen(config.port, () => { + console.error( + `[cueapi-mcp] HTTP transport listening on :${config.port} (public URL: ${config.publicUrl})` + ); + }); + + // Graceful shutdown. + const shutdown = async () => { + clearInterval(cleanupTimer); + await new Promise((resolve) => server.close(() => resolve())); + await store.close(); + process.exit(0); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} diff --git a/src/index.ts b/src/index.ts index 1036107..fdc7181 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,11 @@ #!/usr/bin/env node /** - * CueAPI MCP server. + * CueAPI MCP server — dual-transport entry point. * - * Speaks the Model Context Protocol over stdio and exposes CueAPI's - * core surface as MCP tools. Configure in your MCP host (Claude Desktop, - * Cursor, etc.) with: + * Routes to either the stdio or HTTP transport based on a CLI flag or + * env var. Stdio is the default — matches 0.1.x behavior exactly so + * existing Claude Desktop / Claude Code / Cursor / Zed configurations + * keep working on upgrade with zero changes: * * { * "mcpServers": { @@ -15,127 +16,35 @@ * } * } * } + * + * HTTP transport is new in 0.2.0. Used by remote MCP hosts like + * Claude.ai Custom Connectors that can't spawn a local subprocess. + * Configured by setting ``MCP_TRANSPORT=http`` or passing + * ``--transport http`` and supplying the OAuth-related env vars + * (see ``http-entry.ts`` for the full list). */ -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { - CallToolRequestSchema, - ListToolsRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; -import { zodToJsonSchema } from "zod-to-json-schema"; -import { z } from "zod"; - -import { CueAPIClient, CueAPIError } from "./client.js"; -import { tools } from "./tools.js"; - -const apiKey = process.env.CUEAPI_API_KEY; -if (!apiKey) { - console.error( - "[cueapi-mcp] CUEAPI_API_KEY env var is required. Generate one at https://cueapi.ai" - ); - process.exit(1); -} - -const client = new CueAPIClient({ - apiKey, - baseUrl: process.env.CUEAPI_BASE_URL, -}); - -const server = new Server( - { - name: "cueapi-mcp", - version: "0.1.0", - }, - { - capabilities: { - tools: {}, - }, +function parseTransport(): "stdio" | "http" { + // CLI flag wins over env var. + const argFlag = process.argv.indexOf("--transport"); + if (argFlag >= 0 && process.argv[argFlag + 1]) { + const v = process.argv[argFlag + 1].toLowerCase(); + if (v === "http" || v === "stdio") return v; } -); - -// Build the JSON-schema tool list once up front. -const toolListResponse = { - tools: tools.map((t) => ({ - name: t.name, - description: t.description, - inputSchema: zodToJsonSchema(t.schema, { target: "jsonSchema7" }), - })), -}; - -server.setRequestHandler(ListToolsRequestSchema, async () => toolListResponse); - -server.setRequestHandler(CallToolRequestSchema, async (request) => { - const tool = tools.find((t) => t.name === request.params.name); - if (!tool) { - return { - content: [ - { type: "text", text: `Unknown tool: ${request.params.name}` }, - ], - isError: true, - }; - } - - try { - const parsed = tool.schema.parse(request.params.arguments ?? {}); - const result = await tool.handler(client, parsed); - return { - content: [ - { - type: "text", - text: JSON.stringify(result, null, 2), - }, - ], - }; - } catch (err) { - if (err instanceof z.ZodError) { - return { - content: [ - { - type: "text", - text: `Invalid arguments for ${tool.name}:\n${JSON.stringify( - err.issues, - null, - 2 - )}`, - }, - ], - isError: true, - }; - } - if (err instanceof CueAPIError) { - return { - content: [ - { - type: "text", - text: `CueAPI error (${err.status}): ${err.message}\n${JSON.stringify( - err.body, - null, - 2 - )}`, - }, - ], - isError: true, - }; - } - return { - content: [ - { - type: "text", - text: `Unexpected error in ${tool.name}: ${ - err instanceof Error ? err.message : String(err) - }`, - }, - ], - isError: true, - }; - } -}); + const env = (process.env.MCP_TRANSPORT || "").toLowerCase(); + if (env === "http") return "http"; + return "stdio"; +} async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); - // Don't log on success — MCP stdio hosts treat stderr output as diagnostic. + const transport = parseTransport(); + if (transport === "http") { + const { runHttp } = await import("./http-entry.js"); + await runHttp(); + } else { + const { runStdio } = await import("./stdio-entry.js"); + await runStdio(); + } } main().catch((err) => { diff --git a/src/pkce.ts b/src/pkce.ts new file mode 100644 index 0000000..5335328 --- /dev/null +++ b/src/pkce.ts @@ -0,0 +1,79 @@ +/** + * PKCE S256 verifier (RFC 7636) for the Stage C OAuth 2.1 flow. + * + * Claude.ai (and any well-behaved OAuth client) sends a + * ``code_challenge`` on ``/authorize`` and proves possession of the + * corresponding ``code_verifier`` on ``/token``. We support S256 only — + * ``plain`` is explicitly rejected because it adds zero security over + * not using PKCE at all. + */ + +import { createHash } from "node:crypto"; + +/** + * Compute the S256 code_challenge for a given verifier. + * Spec: BASE64URL-ENCODE(SHA256(ASCII(verifier))). + */ +export function computeCodeChallengeS256(verifier: string): string { + const digest = createHash("sha256").update(verifier, "ascii").digest(); + return base64UrlEncode(digest); +} + +/** + * Verify that the caller's verifier matches the stored challenge. + * Returns true on match, false otherwise. Constant-time comparison + * via Buffer.compare isn't strictly needed here (the challenge and + * verifier are ephemeral per authorization code), but we use it + * anyway — cheap and nicer for defense in depth. + */ +export function verifyCodeChallenge( + verifier: string, + expectedChallenge: string, + method: "S256" | "plain" = "S256" +): boolean { + if (method !== "S256") { + // Plain is rejected even if the caller advertises it — callers + // who want PKCE must do it correctly. + return false; + } + // Spec says verifier length must be 43-128 chars from the unreserved + // URL set. We don't police character set here (it's not a security + // boundary), but the length check catches obvious garbage. + if (verifier.length < 43 || verifier.length > 128) { + return false; + } + const computed = computeCodeChallengeS256(verifier); + if (computed.length !== expectedChallenge.length) { + return false; + } + return constantTimeEqual(computed, expectedChallenge); +} + +/** + * Base64url encode (RFC 4648 §5) — no padding, ``+`` → ``-``, ``/`` → ``_``. + */ +export function base64UrlEncode(buf: Buffer | Uint8Array): string { + return Buffer.from(buf) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +/** + * Base64url decode — reverse of ``base64UrlEncode``. + */ +export function base64UrlDecode(s: string): Buffer { + const padded = s.replace(/-/g, "+").replace(/_/g, "/"); + const padding = (4 - (padded.length % 4)) % 4; + return Buffer.from(padded + "=".repeat(padding), "base64"); +} + +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return result === 0; +} diff --git a/src/stdio-entry.ts b/src/stdio-entry.ts new file mode 100644 index 0000000..e64dfea --- /dev/null +++ b/src/stdio-entry.ts @@ -0,0 +1,142 @@ +/** + * Stdio entry point — the original cueapi-mcp behavior, preserved + * exactly as of 0.1.4. + * + * Used when ``cueapi-mcp`` is spawned as a subprocess by an MCP host + * (Claude Desktop, Claude Code, Cursor, Zed). The host and the server + * exchange MCP protocol messages over stdin/stdout. ``stderr`` is the + * diagnostic channel — never log to stdout. + * + * The HTTP entry (``http-entry.ts``) was added in 0.2.0 for remote + * MCP hosts (Claude.ai Custom Connector et al). ``index.ts`` now + * chooses between the two based on the ``--transport`` flag or the + * ``MCP_TRANSPORT`` env var. Stdio remains the default — any caller + * that invoked ``npx @cueapi/mcp`` before 0.2.0 gets the exact same + * behavior on upgrade. + */ + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { z } from "zod"; + +import { CueAPIClient, CueAPIError } from "./client.js"; +import { tools } from "./tools.js"; + +export async function runStdio(): Promise { + const apiKey = process.env.CUEAPI_API_KEY; + if (!apiKey) { + console.error( + "[cueapi-mcp] CUEAPI_API_KEY env var is required. Generate one at https://cueapi.ai" + ); + process.exit(1); + } + + const client = new CueAPIClient({ + apiKey, + baseUrl: process.env.CUEAPI_BASE_URL, + }); + + const server = new Server( + { + name: "cueapi-mcp", + version: "0.2.0", + }, + { + capabilities: { + tools: {}, + }, + } + ); + + const toolListResponse = { + tools: tools.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: zodToJsonSchema(t.schema, { target: "jsonSchema7" }), + })), + }; + + server.setRequestHandler(ListToolsRequestSchema, async () => toolListResponse); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const tool = tools.find((t) => t.name === request.params.name); + if (!tool) { + return { + content: [ + { type: "text", text: `Unknown tool: ${request.params.name}` }, + ], + isError: true, + }; + } + + try { + const parsed = tool.schema.parse(request.params.arguments ?? {}); + // Stdio: the single client is authenticated from the env var, + // pass-through to the handler. + const result = await tool.handler(client, parsed); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (err) { + return formatToolError(tool.name, err); + } + }); + + const transport = new StdioServerTransport(); + await server.connect(transport); + // Don't log on success — MCP stdio hosts treat stderr output as diagnostic. +} + +function formatToolError(toolName: string, err: unknown) { + if (err instanceof z.ZodError) { + return { + content: [ + { + type: "text", + text: `Invalid arguments for ${toolName}:\n${JSON.stringify( + err.issues, + null, + 2 + )}`, + }, + ], + isError: true, + }; + } + if (err instanceof CueAPIError) { + return { + content: [ + { + type: "text", + text: `CueAPI error (${err.status}): ${err.message}\n${JSON.stringify( + err.body, + null, + 2 + )}`, + }, + ], + isError: true, + }; + } + return { + content: [ + { + type: "text", + text: `Unexpected error in ${toolName}: ${ + err instanceof Error ? err.message : String(err) + }`, + }, + ], + isError: true, + }; +} diff --git a/src/token-store.ts b/src/token-store.ts new file mode 100644 index 0000000..edcfc8e --- /dev/null +++ b/src/token-store.ts @@ -0,0 +1,338 @@ +/** + * Token store for the Stage C OAuth flow. + * + * Three kinds of entries: + * + * auth_code — short-lived (60s) code returned to the OAuth client's + * redirect_uri after /callback/cueapi. Exchanged for an + * access_token via /token (with PKCE verifier). Single-use. + * + * access_token — 24h bearer token the OAuth client presents on /mcp + * calls. We look up the CueAPI api_key from this row and + * hand it to the per-request CueAPIClient. + * + * refresh_token — 30d token to mint new access_tokens without the user + * re-doing the magic-link dance. + * + * The CueAPI ``api_key`` stored on each row is encrypted at rest with + * AES-256-GCM using a key derived from ``OAUTH_SIGNING_SECRET`` via + * HKDF — this way an attacker who exfiltrates the SQLite file without + * the signing secret can't use the tokens to talk to CueAPI. + * + * Default backend is SQLite (zero-config, file-backed at + * ``SQLITE_PATH`` or ``./mcp-tokens.db``). A Redis backend can be added + * later without changing the ``TokenStore`` interface. + */ + +import Database from "better-sqlite3"; +import { + createCipheriv, + createDecipheriv, + createHmac, + randomBytes, + scryptSync, +} from "node:crypto"; + +export interface AuthCodeEntry { + /** The CueAPI api_key minted for this OAuth session. */ + apiKey: string; + /** S256 code_challenge sent on /authorize — verified on /token. */ + codeChallenge: string; + /** The OAuth client's redirect_uri (Anthropic's). */ + redirectUri: string; + /** The OAuth client's client_id (Anthropic's). */ + clientId: string; + createdAt: number; // unix seconds + expiresAt: number; +} + +export interface AccessTokenEntry { + apiKey: string; + userId: string; + clientId: string; + refreshToken: string; + createdAt: number; + expiresAt: number; +} + +export interface RefreshTokenEntry { + apiKey: string; + userId: string; + clientId: string; + createdAt: number; + expiresAt: number; +} + +export interface TokenStore { + setAuthCode(code: string, data: AuthCodeEntry): Promise; + getAuthCode(code: string): Promise; + deleteAuthCode(code: string): Promise; + + setAccessToken(token: string, data: AccessTokenEntry): Promise; + getAccessToken(token: string): Promise; + deleteAccessToken(token: string): Promise; + + setRefreshToken(token: string, data: RefreshTokenEntry): Promise; + getRefreshToken(token: string): Promise; + deleteRefreshToken(token: string): Promise; + + /** Drop all expired rows. Called on an interval from http-entry. */ + cleanup(): Promise; + + /** Release resources. Used in tests. */ + close(): Promise; +} + +// ---------- encryption ---------- + +const ENCRYPTION_INFO = "cueapi-mcp/token-store/api-key-encryption"; +const KEY_SIZE = 32; // AES-256 +const IV_SIZE = 12; // GCM standard +const TAG_SIZE = 16; + +function deriveEncryptionKey(signingSecret: string): Buffer { + // HKDF-style derivation via scrypt (Node lacks a first-class HKDF in + // every supported version; scrypt is strictly stronger as a KDF and + // good enough here). + return scryptSync(signingSecret, ENCRYPTION_INFO, KEY_SIZE); +} + +export function encryptApiKey(plaintext: string, signingSecret: string): string { + const key = deriveEncryptionKey(signingSecret); + const iv = randomBytes(IV_SIZE); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + // Layout: iv || tag || ciphertext, base64 — self-contained. + return Buffer.concat([iv, tag, ct]).toString("base64"); +} + +export function decryptApiKey(payload: string, signingSecret: string): string { + const key = deriveEncryptionKey(signingSecret); + const buf = Buffer.from(payload, "base64"); + if (buf.length < IV_SIZE + TAG_SIZE) { + throw new Error("Encrypted api_key payload is truncated"); + } + const iv = buf.subarray(0, IV_SIZE); + const tag = buf.subarray(IV_SIZE, IV_SIZE + TAG_SIZE); + const ct = buf.subarray(IV_SIZE + TAG_SIZE); + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + const pt = Buffer.concat([decipher.update(ct), decipher.final()]); + return pt.toString("utf8"); +} + +/** + * Sign a state blob for the outer ``/authorize`` → cueapi.ai magic-link + * redirect. We wrap Anthropic's state + the PKCE challenge in our own + * HMAC-SHA256-signed envelope so a malicious party can't tamper with + * the return trip to our ``/callback/cueapi``. + */ +export function signState(payload: object, signingSecret: string): string { + const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); + const sig = createHmac("sha256", signingSecret).update(body).digest("base64url"); + return `${body}.${sig}`; +} + +export function verifyState( + signed: string, + signingSecret: string +): T | null { + const parts = signed.split("."); + if (parts.length !== 2) return null; + const [body, sig] = parts; + const expected = createHmac("sha256", signingSecret) + .update(body) + .digest("base64url"); + if (sig.length !== expected.length) return null; + let ok = 0; + for (let i = 0; i < sig.length; i++) { + ok |= sig.charCodeAt(i) ^ expected.charCodeAt(i); + } + if (ok !== 0) return null; + try { + return JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as T; + } catch { + return null; + } +} + +// ---------- SQLite implementation ---------- + +export class SQLiteTokenStore implements TokenStore { + private readonly db: Database.Database; + private readonly signingSecret: string; + + constructor(path: string, signingSecret: string) { + if (!signingSecret || signingSecret.length < 32) { + throw new Error( + "OAUTH_SIGNING_SECRET must be at least 32 characters for AES-256 derivation" + ); + } + this.signingSecret = signingSecret; + this.db = new Database(path); + this.db.pragma("journal_mode = WAL"); + this.db.pragma("synchronous = NORMAL"); + this.db.exec(` + CREATE TABLE IF NOT EXISTS auth_codes ( + code TEXT PRIMARY KEY, + api_key_enc TEXT NOT NULL, + code_challenge TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + client_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_auth_codes_expires ON auth_codes(expires_at); + + CREATE TABLE IF NOT EXISTS access_tokens ( + token TEXT PRIMARY KEY, + api_key_enc TEXT NOT NULL, + user_id TEXT NOT NULL, + client_id TEXT NOT NULL, + refresh_token TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_access_tokens_expires ON access_tokens(expires_at); + + CREATE TABLE IF NOT EXISTS refresh_tokens ( + token TEXT PRIMARY KEY, + api_key_enc TEXT NOT NULL, + user_id TEXT NOT NULL, + client_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens(expires_at); + `); + } + + async setAuthCode(code: string, data: AuthCodeEntry): Promise { + const enc = encryptApiKey(data.apiKey, this.signingSecret); + this.db + .prepare( + `INSERT INTO auth_codes + (code, api_key_enc, code_challenge, redirect_uri, client_id, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run(code, enc, data.codeChallenge, data.redirectUri, data.clientId, data.createdAt, data.expiresAt); + } + + async getAuthCode(code: string): Promise { + const row = this.db + .prepare( + `SELECT api_key_enc, code_challenge, redirect_uri, client_id, created_at, expires_at + FROM auth_codes WHERE code = ?` + ) + .get(code) as any; + if (!row) return null; + if (row.expires_at < nowSec()) { + this.db.prepare(`DELETE FROM auth_codes WHERE code = ?`).run(code); + return null; + } + return { + apiKey: decryptApiKey(row.api_key_enc, this.signingSecret), + codeChallenge: row.code_challenge, + redirectUri: row.redirect_uri, + clientId: row.client_id, + createdAt: row.created_at, + expiresAt: row.expires_at, + }; + } + + async deleteAuthCode(code: string): Promise { + this.db.prepare(`DELETE FROM auth_codes WHERE code = ?`).run(code); + } + + async setAccessToken(token: string, data: AccessTokenEntry): Promise { + const enc = encryptApiKey(data.apiKey, this.signingSecret); + this.db + .prepare( + `INSERT INTO access_tokens + (token, api_key_enc, user_id, client_id, refresh_token, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run(token, enc, data.userId, data.clientId, data.refreshToken, data.createdAt, data.expiresAt); + } + + async getAccessToken(token: string): Promise { + const row = this.db + .prepare( + `SELECT api_key_enc, user_id, client_id, refresh_token, created_at, expires_at + FROM access_tokens WHERE token = ?` + ) + .get(token) as any; + if (!row) return null; + if (row.expires_at < nowSec()) { + this.db.prepare(`DELETE FROM access_tokens WHERE token = ?`).run(token); + return null; + } + return { + apiKey: decryptApiKey(row.api_key_enc, this.signingSecret), + userId: row.user_id, + clientId: row.client_id, + refreshToken: row.refresh_token, + createdAt: row.created_at, + expiresAt: row.expires_at, + }; + } + + async deleteAccessToken(token: string): Promise { + this.db.prepare(`DELETE FROM access_tokens WHERE token = ?`).run(token); + } + + async setRefreshToken(token: string, data: RefreshTokenEntry): Promise { + const enc = encryptApiKey(data.apiKey, this.signingSecret); + this.db + .prepare( + `INSERT INTO refresh_tokens + (token, api_key_enc, user_id, client_id, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .run(token, enc, data.userId, data.clientId, data.createdAt, data.expiresAt); + } + + async getRefreshToken(token: string): Promise { + const row = this.db + .prepare( + `SELECT api_key_enc, user_id, client_id, created_at, expires_at + FROM refresh_tokens WHERE token = ?` + ) + .get(token) as any; + if (!row) return null; + if (row.expires_at < nowSec()) { + this.db.prepare(`DELETE FROM refresh_tokens WHERE token = ?`).run(token); + return null; + } + return { + apiKey: decryptApiKey(row.api_key_enc, this.signingSecret), + userId: row.user_id, + clientId: row.client_id, + createdAt: row.created_at, + expiresAt: row.expires_at, + }; + } + + async deleteRefreshToken(token: string): Promise { + this.db.prepare(`DELETE FROM refresh_tokens WHERE token = ?`).run(token); + } + + async cleanup(): Promise { + const now = nowSec(); + const results = [ + this.db.prepare(`DELETE FROM auth_codes WHERE expires_at < ?`).run(now), + this.db.prepare(`DELETE FROM access_tokens WHERE expires_at < ?`).run(now), + this.db.prepare(`DELETE FROM refresh_tokens WHERE expires_at < ?`).run(now), + ]; + return results.reduce((acc, r) => acc + r.changes, 0); + } + + async close(): Promise { + this.db.close(); + } +} + +function nowSec(): number { + return Math.floor(Date.now() / 1000); +} diff --git a/tests/oauth-flow.test.ts b/tests/oauth-flow.test.ts new file mode 100644 index 0000000..be41f21 --- /dev/null +++ b/tests/oauth-flow.test.ts @@ -0,0 +1,254 @@ +/** + * End-to-end OAuth flow test — walks the Claude.ai → cueapi-mcp + * dance without contacting the real cueapi.ai endpoint. Uses the + * ``fetchApiKeyFromSession`` override on ``buildApp`` so the + * /callback/cueapi handler doesn't need a live network. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomBytes } from "node:crypto"; +import supertest from "supertest"; + +import { buildApp } from "../src/http-entry.js"; +import { SQLiteTokenStore } from "../src/token-store.js"; +import { computeCodeChallengeS256 } from "../src/pkce.js"; + +const SECRET = "a-forty-character-signing-secret-for-oauth!!!"; + +const baseConfig = { + port: 0, + publicUrl: "https://mcp.test.example", + cueapiBaseUrl: "https://api.cueapi.ai", + oauthSigningSecret: SECRET, + sqlitePath: "", + allowedClaudeAiClientIds: new Set(["claude-ai-test"]), + cueapiMcpClientId: "cueapi-mcp-instance-1", + cueapiMcpExchangeEndpoint: "https://api.cueapi.ai/v1/auth/mcp-exchange", +}; + +function freshVerifier() { + // 43-char minimum; use 64 random chars. + return randomBytes(48).toString("base64url").slice(0, 64); +} + +describe("OAuth flow", () => { + let dbPath: string; + let store: SQLiteTokenStore; + let app: ReturnType; + + beforeEach(() => { + dbPath = join(tmpdir(), `oauth-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + store = new SQLiteTokenStore(dbPath, SECRET); + app = buildApp({ + store, + config: { ...baseConfig, sqlitePath: dbPath }, + fetchApiKeyFromSession: async () => ({ + apiKey: "cue_sk_testauth", + userId: "user-abc-def", + }), + }); + }); + + afterEach(async () => { + await store.close(); + try { + unlinkSync(dbPath); + unlinkSync(`${dbPath}-wal`); + unlinkSync(`${dbPath}-shm`); + } catch {} + }); + + describe("GET /health", () => { + it("returns healthy", async () => { + const res = await supertest(app).get("/health"); + expect(res.status).toBe(200); + expect(res.body.status).toBe("healthy"); + expect(res.body.transport).toBe("http"); + }); + }); + + describe("GET /.well-known/oauth-authorization-server", () => { + it("advertises S256-only PKCE + authorization_code grant", async () => { + const res = await supertest(app).get("/.well-known/oauth-authorization-server"); + expect(res.status).toBe(200); + expect(res.body.code_challenge_methods_supported).toEqual(["S256"]); + expect(res.body.grant_types_supported).toContain("authorization_code"); + expect(res.body.authorization_endpoint).toBe("https://mcp.test.example/authorize"); + }); + }); + + describe("GET /authorize", () => { + it("redirects to cueapi.ai magic-link with signed state for valid request", async () => { + const verifier = freshVerifier(); + const challenge = computeCodeChallengeS256(verifier); + const res = await supertest(app).get("/authorize").query({ + response_type: "code", + client_id: "claude-ai-test", + redirect_uri: "https://claude.ai/oauth/callback", + code_challenge: challenge, + code_challenge_method: "S256", + state: "anthropic-state-opaque", + scope: "mcp", + }); + expect(res.status).toBe(302); + const loc = res.headers.location; + expect(loc).toContain("api.cueapi.ai/auth/magic-link"); + const url = new URL(loc); + expect(url.searchParams.get("return_to")).toBe("https://mcp.test.example/callback/cueapi"); + expect(url.searchParams.get("state")).toBeTruthy(); + }); + + it("rejects unsupported response_type", async () => { + const res = await supertest(app).get("/authorize").query({ + response_type: "token", + client_id: "claude-ai-test", + redirect_uri: "https://claude.ai/cb", + code_challenge: "x".repeat(43), + }); + expect(res.status).toBe(400); + expect(res.body.error).toBe("unsupported_response_type"); + }); + + it("rejects unknown client_id", async () => { + const res = await supertest(app).get("/authorize").query({ + response_type: "code", + client_id: "random-untrusted-client", + redirect_uri: "https://claude.ai/cb", + code_challenge: "x".repeat(43), + }); + expect(res.status).toBe(400); + expect(res.body.error).toBe("unauthorized_client"); + }); + + it("rejects missing code_challenge (PKCE mandatory)", async () => { + const res = await supertest(app).get("/authorize").query({ + response_type: "code", + client_id: "claude-ai-test", + redirect_uri: "https://claude.ai/cb", + }); + expect(res.status).toBe(400); + expect(res.body.error).toBe("invalid_request"); + }); + + it("rejects plain code_challenge_method", async () => { + const res = await supertest(app).get("/authorize").query({ + response_type: "code", + client_id: "claude-ai-test", + redirect_uri: "https://claude.ai/cb", + code_challenge: "x".repeat(43), + code_challenge_method: "plain", + }); + expect(res.status).toBe(400); + expect(res.body.error).toBe("invalid_request"); + }); + }); + + describe("Full authorize → callback → token → /mcp flow", () => { + async function runAuthorize(verifier: string, anthropicState: string) { + const challenge = computeCodeChallengeS256(verifier); + const res = await supertest(app).get("/authorize").query({ + response_type: "code", + client_id: "claude-ai-test", + redirect_uri: "https://claude.ai/oauth/callback", + code_challenge: challenge, + code_challenge_method: "S256", + state: anthropicState, + scope: "mcp", + }); + expect(res.status).toBe(302); + const url = new URL(res.headers.location); + return url.searchParams.get("state")!; + } + + async function runCallback(wrappedState: string, sessionToken: string) { + const res = await supertest(app).get("/callback/cueapi").query({ + session_token: sessionToken, + state: wrappedState, + }); + expect(res.status).toBe(302); + const url = new URL(res.headers.location); + expect(url.hostname).toBe("claude.ai"); + const code = url.searchParams.get("code"); + const returnedState = url.searchParams.get("state"); + return { code, returnedState }; + } + + async function runTokenExchange(code: string, verifier: string) { + const res = await supertest(app).post("/token").type("form").send({ + grant_type: "authorization_code", + code, + code_verifier: verifier, + client_id: "claude-ai-test", + redirect_uri: "https://claude.ai/oauth/callback", + }); + return res; + } + + it("happy path end-to-end", async () => { + const verifier = freshVerifier(); + const wrappedState = await runAuthorize(verifier, "anthropic-state-opaque"); + const { code, returnedState } = await runCallback(wrappedState, "session-from-magic-link"); + + expect(code).toBeTruthy(); + expect(returnedState).toBe("anthropic-state-opaque"); + + const tok = await runTokenExchange(code!, verifier); + expect(tok.status).toBe(200); + expect(tok.body.access_token).toBeTruthy(); + expect(tok.body.token_type).toBe("Bearer"); + expect(tok.body.expires_in).toBe(86400); + expect(tok.body.refresh_token).toBeTruthy(); + }); + + it("/token rejects wrong PKCE verifier", async () => { + const verifier = freshVerifier(); + const wrappedState = await runAuthorize(verifier, "state-x"); + const { code } = await runCallback(wrappedState, "session-x"); + const badVerifier = freshVerifier(); + const tok = await runTokenExchange(code!, badVerifier); + expect(tok.status).toBe(400); + expect(tok.body.error).toBe("invalid_grant"); + }); + + it("/token rejects used auth code (single-use)", async () => { + const verifier = freshVerifier(); + const wrappedState = await runAuthorize(verifier, "state-y"); + const { code } = await runCallback(wrappedState, "session-y"); + const first = await runTokenExchange(code!, verifier); + expect(first.status).toBe(200); + const second = await runTokenExchange(code!, verifier); + expect(second.status).toBe(400); + expect(second.body.error).toBe("invalid_grant"); + }); + + it("/callback rejects tampered state", async () => { + const verifier = freshVerifier(); + const wrappedState = await runAuthorize(verifier, "state-t"); + const tampered = wrappedState.slice(0, -3) + "XXX"; + const res = await supertest(app).get("/callback/cueapi").query({ + session_token: "x", + state: tampered, + }); + expect(res.status).toBe(400); + expect(res.body.error).toBe("invalid_request"); + }); + }); + + describe("POST /mcp bearer auth", () => { + it("401s without bearer token", async () => { + const res = await supertest(app).post("/mcp").send({}); + expect(res.status).toBe(401); + expect(res.headers["www-authenticate"]).toContain("Bearer"); + }); + + it("401s with unknown token", async () => { + const res = await supertest(app) + .post("/mcp") + .set("Authorization", "Bearer definitely-not-a-real-token") + .send({}); + expect(res.status).toBe(401); + }); + }); +}); diff --git a/tests/pkce.test.ts b/tests/pkce.test.ts new file mode 100644 index 0000000..496af14 --- /dev/null +++ b/tests/pkce.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + base64UrlDecode, + base64UrlEncode, + computeCodeChallengeS256, + verifyCodeChallenge, +} from "../src/pkce.js"; + +describe("pkce", () => { + describe("computeCodeChallengeS256", () => { + it("matches RFC 7636 appendix B example", () => { + // Spec test vector. + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + const expectedChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + expect(computeCodeChallengeS256(verifier)).toBe(expectedChallenge); + }); + + it("is deterministic", () => { + const v = "some-random-verifier-at-least-43-characters-long"; + expect(computeCodeChallengeS256(v)).toBe(computeCodeChallengeS256(v)); + }); + }); + + describe("verifyCodeChallenge", () => { + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + const challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + + it("accepts matching verifier with S256", () => { + expect(verifyCodeChallenge(verifier, challenge, "S256")).toBe(true); + }); + + it("rejects tampered verifier", () => { + const tampered = verifier.slice(0, -1) + "X"; + expect(verifyCodeChallenge(tampered, challenge, "S256")).toBe(false); + }); + + it("rejects plain method even if verifier == challenge", () => { + expect(verifyCodeChallenge("anything", "anything", "plain" as any)).toBe(false); + }); + + it("rejects verifier shorter than 43 chars", () => { + expect(verifyCodeChallenge("too-short", challenge, "S256")).toBe(false); + }); + + it("rejects verifier longer than 128 chars", () => { + const tooLong = "x".repeat(129); + expect(verifyCodeChallenge(tooLong, challenge, "S256")).toBe(false); + }); + }); + + describe("base64url round-trip", () => { + it("encodes and decodes cleanly with padding", () => { + const original = Buffer.from("hello world with some padding needed!!"); + const encoded = base64UrlEncode(original); + expect(encoded).not.toContain("="); + expect(encoded).not.toContain("+"); + expect(encoded).not.toContain("/"); + const decoded = base64UrlDecode(encoded); + expect(decoded.equals(original)).toBe(true); + }); + }); +}); diff --git a/tests/token-store.test.ts b/tests/token-store.test.ts new file mode 100644 index 0000000..b565970 --- /dev/null +++ b/tests/token-store.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + SQLiteTokenStore, + decryptApiKey, + encryptApiKey, + signState, + verifyState, +} from "../src/token-store.js"; + +const SECRET = "a-forty-character-signing-secret-for-tests!!!"; + +describe("encryptApiKey / decryptApiKey", () => { + it("round-trips a cue_sk_ plaintext", () => { + const plaintext = "cue_sk_1234567890abcdef1234567890abcdef"; + const encrypted = encryptApiKey(plaintext, SECRET); + expect(encrypted).not.toContain(plaintext); + expect(encrypted.length).toBeGreaterThan(plaintext.length); + expect(decryptApiKey(encrypted, SECRET)).toBe(plaintext); + }); + + it("produces different ciphertexts for the same plaintext (IV randomization)", () => { + const plaintext = "cue_sk_example"; + const a = encryptApiKey(plaintext, SECRET); + const b = encryptApiKey(plaintext, SECRET); + expect(a).not.toBe(b); + }); + + it("rejects tampered ciphertext (GCM auth tag)", () => { + const plaintext = "cue_sk_foo"; + const encrypted = encryptApiKey(plaintext, SECRET); + // Tamper one byte in the middle of the payload. + const buf = Buffer.from(encrypted, "base64"); + buf[buf.length - 5] ^= 0xff; + const tampered = buf.toString("base64"); + expect(() => decryptApiKey(tampered, SECRET)).toThrow(); + }); + + it("rejects decryption with the wrong secret", () => { + const encrypted = encryptApiKey("cue_sk_x", SECRET); + expect(() => decryptApiKey(encrypted, "wrong-secret-of-sufficient-length!!!!")).toThrow(); + }); +}); + +describe("signState / verifyState", () => { + it("round-trips a payload", () => { + const payload = { clientState: "abc", codeChallenge: "xyz", issuedAt: 123 }; + const signed = signState(payload, SECRET); + const verified = verifyState(signed, SECRET); + expect(verified).toEqual(payload); + }); + + it("rejects tampered body", () => { + const signed = signState({ a: 1 }, SECRET); + const [body, sig] = signed.split("."); + const tamperedBody = body.slice(0, -1) + (body.at(-1) === "A" ? "B" : "A"); + expect(verifyState(`${tamperedBody}.${sig}`, SECRET)).toBeNull(); + }); + + it("rejects tampered signature", () => { + const signed = signState({ a: 1 }, SECRET); + const [body] = signed.split("."); + const fakeSig = "x".repeat(43); + expect(verifyState(`${body}.${fakeSig}`, SECRET)).toBeNull(); + }); + + it("rejects signature from a different secret", () => { + const signed = signState({ a: 1 }, "secret-one-of-sufficient-length!!!!!!!"); + expect(verifyState(signed, "secret-two-of-sufficient-length!!!!!!!")).toBeNull(); + }); +}); + +describe("SQLiteTokenStore", () => { + let path: string; + let store: SQLiteTokenStore; + + beforeEach(() => { + path = join(tmpdir(), `cueapi-mcp-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + store = new SQLiteTokenStore(path, SECRET); + }); + + afterEach(async () => { + await store.close(); + try { + unlinkSync(path); + unlinkSync(`${path}-wal`); + unlinkSync(`${path}-shm`); + } catch { + // ignore + } + }); + + it("requires signing secret of at least 32 chars", () => { + expect(() => new SQLiteTokenStore(path, "short")).toThrow(); + }); + + describe("auth codes", () => { + it("stores, reads, deletes", async () => { + const now = Math.floor(Date.now() / 1000); + await store.setAuthCode("code-abc", { + apiKey: "cue_sk_test", + codeChallenge: "chal-xyz", + redirectUri: "https://claude.ai/callback", + clientId: "claude-client", + createdAt: now, + expiresAt: now + 60, + }); + const entry = await store.getAuthCode("code-abc"); + expect(entry).not.toBeNull(); + expect(entry!.apiKey).toBe("cue_sk_test"); + expect(entry!.codeChallenge).toBe("chal-xyz"); + + await store.deleteAuthCode("code-abc"); + expect(await store.getAuthCode("code-abc")).toBeNull(); + }); + + it("returns null for expired codes", async () => { + const now = Math.floor(Date.now() / 1000); + await store.setAuthCode("code-stale", { + apiKey: "cue_sk_t", + codeChallenge: "c", + redirectUri: "r", + clientId: "x", + createdAt: now - 120, + expiresAt: now - 1, // already expired + }); + expect(await store.getAuthCode("code-stale")).toBeNull(); + }); + }); + + describe("access tokens", () => { + it("round-trips full entry", async () => { + const now = Math.floor(Date.now() / 1000); + await store.setAccessToken("tok-1", { + apiKey: "cue_sk_access", + userId: "user-123", + clientId: "claude-client", + refreshToken: "refresh-1", + createdAt: now, + expiresAt: now + 86400, + }); + const entry = await store.getAccessToken("tok-1"); + expect(entry).not.toBeNull(); + expect(entry!.apiKey).toBe("cue_sk_access"); + expect(entry!.userId).toBe("user-123"); + expect(entry!.refreshToken).toBe("refresh-1"); + }); + }); + + describe("refresh tokens", () => { + it("round-trips full entry", async () => { + const now = Math.floor(Date.now() / 1000); + await store.setRefreshToken("refresh-1", { + apiKey: "cue_sk_refresh", + userId: "user-456", + clientId: "claude-client", + createdAt: now, + expiresAt: now + 30 * 86400, + }); + const entry = await store.getRefreshToken("refresh-1"); + expect(entry).not.toBeNull(); + expect(entry!.apiKey).toBe("cue_sk_refresh"); + }); + }); + + describe("cleanup", () => { + it("drops only expired rows", async () => { + const now = Math.floor(Date.now() / 1000); + await store.setAuthCode("fresh", { + apiKey: "a", codeChallenge: "c", redirectUri: "r", clientId: "x", + createdAt: now, expiresAt: now + 60, + }); + await store.setAuthCode("stale", { + apiKey: "a", codeChallenge: "c", redirectUri: "r", clientId: "x", + createdAt: now - 200, expiresAt: now - 100, + }); + const deleted = await store.cleanup(); + expect(deleted).toBe(1); + expect(await store.getAuthCode("fresh")).not.toBeNull(); + expect(await store.getAuthCode("stale")).toBeNull(); + }); + }); + + describe("encryption at rest", () => { + it("stores ciphertext, not plaintext, in the DB file", async () => { + const now = Math.floor(Date.now() / 1000); + await store.setAuthCode("enc-check", { + apiKey: "cue_sk_ABCXYZ_very_distinctive_plaintext", + codeChallenge: "c", redirectUri: "r", clientId: "x", + createdAt: now, expiresAt: now + 60, + }); + await store.close(); + + // Read the raw file bytes and confirm the plaintext does NOT appear. + const { readFileSync } = await import("node:fs"); + const raw = readFileSync(path); + expect(raw.includes(Buffer.from("cue_sk_ABCXYZ_very_distinctive_plaintext"))).toBe(false); + + // Reopen and verify it decrypts correctly. + store = new SQLiteTokenStore(path, SECRET); + const entry = await store.getAuthCode("enc-check"); + expect(entry!.apiKey).toBe("cue_sk_ABCXYZ_very_distinctive_plaintext"); + }); + }); +});