diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..88d3c6ea --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +**/node_modules +**/dist +**/*.tsbuildinfo +**/.react-router +.git +.diffity +.claude +.bin +plans +tmp +snapshot-* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..9b4d4e3a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# The hosted diffity server (packages/server). Build from the repository root: +# docker build -t diffity-server . + +FROM node:24-slim AS build +WORKDIR /src +COPY package.json package-lock.json ./ +COPY packages/api/package.json packages/api/ +COPY packages/cli/package.json packages/cli/ +COPY packages/git/package.json packages/git/ +COPY packages/github/package.json packages/github/ +COPY packages/parser/package.json packages/parser/ +COPY packages/server/package.json packages/server/ +COPY packages/ui/package.json packages/ui/ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:24-slim AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +COPY packages/api/package.json packages/api/ +COPY packages/cli/package.json packages/cli/ +COPY packages/git/package.json packages/git/ +COPY packages/github/package.json packages/github/ +COPY packages/parser/package.json packages/parser/ +COPY packages/server/package.json packages/server/ +COPY packages/ui/package.json packages/ui/ +# The workspace packages are bundled into dist; only the server's npm dependencies are installed. +RUN npm ci --omit=dev --workspace @diffity/server --include-workspace-root=false --ignore-scripts + +FROM node:24-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 10001 --home-dir /data --shell /usr/sbin/nologin diffity \ + && mkdir -p /data \ + && chown diffity:diffity /data +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /src/packages/server/package.json ./package.json +COPY --from=build /src/packages/server/dist ./dist +ENV NODE_ENV=production \ + DIFFITY_DATA_DIR=/data \ + DIFFITY_BIND=0.0.0.0 \ + PORT=5390 +USER diffity +VOLUME /data +EXPOSE 5390 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ + CMD ["node", "-e", "fetch('http://127.0.0.1:' + (process.env.PORT || 5390) + '/healthz').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"] +CMD ["node", "dist/index.js"] diff --git a/package-lock.json b/package-lock.json index d3cfa0bf..48f25487 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "packages/git", "packages/github", "packages/parser", - "packages/ui" + "packages/ui", + "packages/server" ], "devDependencies": { "concurrently": "^9.2.1", @@ -776,6 +777,10 @@ "resolved": "packages/parser", "link": true }, + "node_modules/@diffity/server": { + "resolved": "packages/server", + "link": true + }, "node_modules/@diffity/ui": { "resolved": "packages/ui", "link": true @@ -1245,6 +1250,18 @@ } } }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", @@ -1322,6 +1339,46 @@ "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==", "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.1.tgz", + "integrity": "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -2807,6 +2864,17 @@ "license": "MIT", "peer": true }, + "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/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2818,6 +2886,16 @@ "assertion-error": "^2.0.1" } }, + "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/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -3102,6 +3180,31 @@ "@types/estree": "*" } }, + "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.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -3117,6 +3220,13 @@ "@types/unist": "*" } }, + "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/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -3149,6 +3259,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "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/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -3168,6 +3292,27 @@ "@types/react": "^19.2.0" } }, + "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/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -3310,6 +3455,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3322,6 +3480,39 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3439,6 +3630,59 @@ "require-from-string": "^2.0.2" } }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -3498,6 +3742,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -3508,6 +3761,35 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001781", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", @@ -3756,6 +4038,28 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3776,6 +4080,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -3785,6 +4115,20 @@ "layout-base": "^1.0.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -4449,6 +4793,15 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -4497,6 +4850,26 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.325", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", @@ -4511,6 +4884,15 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", @@ -4536,6 +4918,24 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -4543,6 +4943,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -4595,6 +5007,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -4641,6 +5059,36 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/exit-hook": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", @@ -4664,16 +5112,87 @@ "node": ">=12.0.0" } }, - "node_modules/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", - "dev": true, - "license": "MIT" - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "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/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, @@ -4690,6 +5209,28 @@ "node": ">=0.10.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4707,6 +5248,45 @@ } } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4721,6 +5301,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4741,6 +5330,43 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", @@ -4754,6 +5380,18 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4792,6 +5430,30 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -4964,6 +5626,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hono": { + "version": "4.13.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.9.tgz", + "integrity": "sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -4997,6 +5668,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -5009,6 +5700,12 @@ "node": ">=0.10.0" } }, + "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/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -5024,6 +5721,24 @@ "node": ">=12" } }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -5152,6 +5867,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -5176,6 +5897,12 @@ "node": ">=18" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -5185,6 +5912,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5296,6 +6032,18 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -5696,6 +6444,15 @@ "node": ">= 20" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -5973,6 +6730,31 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/mermaid": { "version": "11.13.0", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz", @@ -6565,6 +7347,31 @@ ], "license": "MIT" }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -6634,6 +7441,35 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -6647,6 +7483,27 @@ "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==", "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -6658,6 +7515,27 @@ ], "license": "MIT" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/oniguruma-parser": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", @@ -6751,12 +7629,40 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-data-parser": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", "license": "MIT" }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -6781,6 +7687,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", @@ -6905,6 +7820,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6915,6 +7847,66 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -7160,7 +8152,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7278,6 +8269,22 @@ "points-on-path": "^0.2.1" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -7358,12 +8365,84 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shell-quote": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", @@ -7396,6 +8475,78 @@ "node": ">=20" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -7446,6 +8597,15 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", @@ -7643,6 +8803,15 @@ "dev": true, "license": "MIT" }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tough-cookie": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", @@ -7735,6 +8904,37 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -7859,6 +9059,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -7918,6 +9127,15 @@ } } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -8331,6 +9549,21 @@ "node": "^22.14.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -8366,6 +9599,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/wsl-utils": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", @@ -8445,6 +9684,24 @@ "node": ">=12" } }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -8457,7 +9714,7 @@ }, "packages/api": { "name": "@diffity/api", - "version": "0.10.37", + "version": "0.10.38", "dependencies": { "@diffity/parser": "*" }, @@ -8468,7 +9725,7 @@ }, "packages/cli": { "name": "@naturalcycles/diffity", - "version": "0.10.37", + "version": "0.10.38", "license": "MIT", "dependencies": { "commander": "^14.0.3", @@ -8492,7 +9749,7 @@ }, "packages/git": { "name": "@diffity/git", - "version": "0.10.37", + "version": "0.10.38", "devDependencies": { "@types/node": "^25.5.0", "typescript": "^5.9.3", @@ -8501,7 +9758,7 @@ }, "packages/github": { "name": "@diffity/github", - "version": "0.10.37", + "version": "0.10.38", "dependencies": { "@diffity/api": "*", "@diffity/parser": "*" @@ -8514,15 +9771,41 @@ }, "packages/parser": { "name": "@diffity/parser", - "version": "0.10.37", + "version": "0.10.38", + "devDependencies": { + "typescript": "^5.9.3", + "vitest": "^4.1.0" + } + }, + "packages/server": { + "name": "@diffity/server", + "version": "0.10.38", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.1", + "express": "^5.2.1", + "zod": "^4.6.5" + }, + "bin": { + "diffity-server": "dist/index.js" + }, "devDependencies": { + "@diffity/api": "*", + "@diffity/git": "*", + "@diffity/parser": "*", + "@types/express": "^5.0.6", + "@types/node": "^25.5.0", + "esbuild": "^0.27.0", + "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.1.0" + }, + "engines": { + "node": ">=22.13" } }, "packages/ui": { "name": "@diffity/ui", - "version": "0.10.37", + "version": "0.10.38", "dependencies": { "@diffity/api": "*", "@diffity/parser": "*", diff --git a/package.json b/package.json index cb0c347b..6c3ae287 100644 --- a/package.json +++ b/package.json @@ -10,16 +10,17 @@ "packages/git", "packages/github", "packages/parser", - "packages/ui" + "packages/ui", + "packages/server" ], "scripts": { "build": "tsx scripts/build.ts", "build:skills": "tsx scripts/build-skills.ts", - "test": "npm run typecheck && npm run test -w @diffity/api && npm run test -w @diffity/git && npm run test -w @diffity/github && npm run test -w @diffity/parser && npm run test -w @diffity/ui && npm run test -w @naturalcycles/diffity && npm run test:scripts", + "test": "npm run typecheck && npm run test -w @diffity/api && npm run test -w @diffity/git && npm run test -w @diffity/github && npm run test -w @diffity/parser && npm run test -w @diffity/ui && npm run test -w @naturalcycles/diffity && npm run test -w @diffity/server && npm run test:scripts", "link-dev": "tsx scripts/link-dev.ts", "dev": "tsx scripts/dev.ts", - "test:scripts": "vitest run scripts", - "typecheck": "npm run typecheck -w @diffity/parser && npm run typecheck -w @diffity/api && npm run typecheck -w @diffity/git && npm run typecheck -w @diffity/github && npm run typecheck -w @diffity/ui && npm run typecheck -w @naturalcycles/diffity && tsc -p tsconfig.scripts.json" + "test:scripts": "vitest run --dir scripts", + "typecheck": "npm run typecheck -w @diffity/parser && npm run typecheck -w @diffity/api && npm run typecheck -w @diffity/git && npm run typecheck -w @diffity/github && npm run typecheck -w @diffity/ui && npm run typecheck -w @naturalcycles/diffity && npm run typecheck -w @diffity/server && tsc -p tsconfig.scripts.json" }, "keywords": [ "git", diff --git a/packages/api/package.json b/packages/api/package.json index 3699cd30..4dde05e0 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/api", - "version": "0.10.37", + "version": "0.10.38", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/api/src/info.ts b/packages/api/src/info.ts index 9b01b153..7b0d5227 100644 --- a/packages/api/src/info.ts +++ b/packages/api/src/info.ts @@ -24,4 +24,9 @@ export interface RepoInfoResponse { review?: ReviewRun | null; github: GitHubRemote | null; editor: 'vscode' | null; + /** + * Served by the hosted server, which reviews pushed commits: there is no working tree to browse + * and nothing is posted to the forge from the page. Absent means the local CLI. + */ + hosted?: boolean; } diff --git a/packages/cli/package.json b/packages/cli/package.json index e351a659..f2b8e50a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@naturalcycles/diffity", - "version": "0.10.37", + "version": "0.10.38", "description": "Agent-agnostic, GitHub-style diff viewer and code review tool with a live agent loop", "type": "module", "bin": { diff --git a/packages/git/package.json b/packages/git/package.json index 9fa81a2f..31abca77 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/git", - "version": "0.10.37", + "version": "0.10.38", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/git/src/config.ts b/packages/git/src/config.ts index 66852f37..7a715761 100644 --- a/packages/git/src/config.ts +++ b/packages/git/src/config.ts @@ -32,8 +32,19 @@ export function readRepoConfig(repoRoot: string): RepoConfig { return {}; } + let text: string; try { - const parsed = JSON.parse(readFileSync(path, 'utf-8')) as RepoConfig; + text = readFileSync(path, 'utf-8'); + } catch { + return {}; + } + return parseRepoConfig(text); +} + +/** The same reading as `readRepoConfig`, for a config that did not come from a checkout. */ +export function parseRepoConfig(text: string): RepoConfig { + try { + const parsed = JSON.parse(text) as RepoConfig; const config: RepoConfig = {}; if (typeof parsed?.dataDir === 'string') { diff --git a/packages/git/src/index.ts b/packages/git/src/index.ts index 9918137a..164aef51 100644 --- a/packages/git/src/index.ts +++ b/packages/git/src/index.ts @@ -5,7 +5,7 @@ export { getDiff, getDiffFiles, getDiffStat, getDiffStatForRef, getRenameStatus, export type { RefDiffArgs } from './diff.js'; export { getDirtyPaths } from './status.js'; export { getRecentCommits } from './commits.js'; -export { readRepoConfig, resolveDataDir, REPO_CONFIG_FILE, DEFAULT_SEVERITIES } from './config.js'; +export { readRepoConfig, parseRepoConfig, resolveDataDir, REPO_CONFIG_FILE, DEFAULT_SEVERITIES } from './config.js'; export type { RepoConfig, ReviewConfig } from './config.js'; export { getTree, getTreeEntries, getTreeFingerprint, getWorkingTreeFileContent, getWorkingTreeRawFile, resolveInRepo } from './tree.js'; export type { TreeEntry } from './tree.js'; diff --git a/packages/github/package.json b/packages/github/package.json index 3bc64045..99c05b6b 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/github", - "version": "0.10.37", + "version": "0.10.38", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/parser/package.json b/packages/parser/package.json index af78d40c..01303d28 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/parser", - "version": "0.10.37", + "version": "0.10.38", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/server/README.md b/packages/server/README.md new file mode 100644 index 00000000..f39f3de9 --- /dev/null +++ b/packages/server/README.md @@ -0,0 +1,74 @@ +# @diffity/server + +The hosted, multi-user diffity: the same review UI as the CLI, served for review sessions on pushed +code, and an OAuth-protected MCP endpoint through which agents create those sessions and comment on +them. The server runs no model and has no working tree; it keeps a blob-less mirror of each GitHub +repository it has been asked about. + +An agent calls `create_session` with a pull request, two commits, or a base commit and a patch; the +result carries a URL, which the user opens and signs in to. + +## Running on localhost + +```bash +npm install +npm run build # at the repository root: builds the UI into the server's dist + +DIFFITY_DATA_DIR=/tmp/diffity-data \ +DIFFITY_DEV_LOGIN=1 \ +DIFFITY_DEV_GITHUB_TOKEN=$(gh auth token) \ + node packages/server/dist/index.js +``` + +or, from source, `npm run dev -w @diffity/server` with the same environment (the UI still has to be +built once). + +Open , sign in with an address in the allowed domain, and add a GitHub token +under **Settings** if the dev token is not set. + +## Adding it to Claude Code + +```bash +claude mcp add --transport http diffity http://localhost:5390/mcp +``` + +The first tool call opens the browser to sign in and allow the connection. The +`diffity-review-remote` skill (installed by `diffity skills install`) drives a review through these +tools. + +## Environment + +| Variable | Default | | +|---|---|---| +| `DIFFITY_DATA_DIR` | — (required) | Database (`diffity.db`) and repository mirrors | +| `DIFFITY_PUBLIC_URL` | `http://localhost:5390` | The origin users and agents reach the server on; OAuth issuer and session links use it | +| `PORT` | `5390` | Port to listen on | +| `DIFFITY_BIND` | `127.0.0.1` for a localhost public URL, else `0.0.0.0` | Interface to listen on | +| `DIFFITY_SECRET_KEY` | generated per run on localhost, required elsewhere | 32 bytes, base64 (`openssl rand -base64 32`); encrypts stored GitHub tokens | +| `DIFFITY_DEV_LOGIN` | off | `1` enables the email-only development login; refused unless the public URL is localhost | +| `DIFFITY_ALLOWED_DOMAIN` | `naturalcycles.com` | Who may sign in | +| `DIFFITY_ALLOWED_EMAILS` | — | Comma-separated addresses; replaces the domain rule when set | +| `DIFFITY_DEV_GITHUB_TOKEN` | — | A token used for users who have not set their own; localhost only | +| `GITHUB_API_URL` | `https://api.github.com` | GitHub REST API | + +An `http://` public URL other than localhost is refused by the MCP SDK's OAuth metadata unless +`MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL=1` is set; use https anywhere else. + +## Container + +```bash +docker build -t diffity-server . +docker run --rm -p 127.0.0.1:5390:5390 -v diffity-data:/data \ + -e DIFFITY_DEV_LOGIN=1 -e DIFFITY_DEV_GITHUB_TOKEN=$(gh auth token) diffity-server +``` + +Publish the port on loopback only while the dev login is on: it trusts whatever email is typed. + +## Access and isolation + +- Every session, thread, comment and walkthrough belongs to one user; another user's ids answer as + if they did not exist. +- Before a session is created, GitHub is asked whether the user's token can read the repository. + Mirror fetches use that token through the environment, never the mirror's config. +- OAuth client secrets, codes, access and refresh tokens and the web session cookie are stored as + sha256 hashes; GitHub tokens are encrypted with `DIFFITY_SECRET_KEY`. diff --git a/packages/server/build.ts b/packages/server/build.ts new file mode 100644 index 00000000..d4be2a82 --- /dev/null +++ b/packages/server/build.ts @@ -0,0 +1,35 @@ +import { build } from 'esbuild'; +import { cpSync, existsSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const distDir = join(here, 'dist'); +const uiSource = join(here, '../cli/dist/ui/client'); + +rmSync(distDir, { recursive: true, force: true }); + +await build({ + entryPoints: [join(here, 'src/index.ts')], + bundle: true, + platform: 'node', + target: 'node24', + format: 'esm', + outfile: join(distDir, 'index.js'), + banner: { js: '#!/usr/bin/env node' }, + // Installed as the package's own dependencies; the workspace packages are bundled in. + packages: 'external', + alias: { + '@diffity/api': join(here, '../api/src/index.ts'), + '@diffity/git': join(here, '../git/src/index.ts'), + '@diffity/parser': join(here, '../parser/src/index.ts'), + }, + minifySyntax: true, + treeShaking: true, +}); + +// Copied rather than referenced, so dist is the whole server and a container needs nothing else. +if (!existsSync(join(uiSource, 'index.html'))) { + throw new Error(`${uiSource} is missing: build @diffity/ui first (npm run build at the repository root)`); +} +cpSync(uiSource, join(distDir, 'ui'), { recursive: true }); diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 00000000..4e4f65dd --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,36 @@ +{ + "name": "@diffity/server", + "version": "0.10.38", + "private": true, + "type": "module", + "description": "Hosted, multi-user diffity: the review UI and an OAuth-protected MCP endpoint", + "bin": { + "diffity-server": "./dist/index.js" + }, + "scripts": { + "build": "tsx build.ts", + "dev": "tsx src/index.ts", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc -p tsconfig.typecheck.json" + }, + "engines": { + "node": ">=22.13" + }, + "devDependencies": { + "@diffity/api": "*", + "@diffity/git": "*", + "@diffity/parser": "*", + "@types/express": "^5.0.6", + "@types/node": "^25.5.0", + "esbuild": "^0.27.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^4.1.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.1", + "express": "^5.2.1", + "zod": "^4.6.5" + } +} diff --git a/packages/server/src/anchor.ts b/packages/server/src/anchor.ts new file mode 100644 index 00000000..836744a6 --- /dev/null +++ b/packages/server/src/anchor.ts @@ -0,0 +1,81 @@ +// The same rules as the CLI's anchor.ts, over file content read from a commit rather than the +// working tree, which a server does not have. + +export interface AnchorRange { + startLine: number; + endLine: number; +} + +/** Enough of a fingerprint to trust a nearest-match: more than one line, or a substantial one. */ +const MIN_DISTINCTIVE_CHARS = 12; + +function isDistinctive(anchorLines: string[]): boolean { + if (anchorLines.length > 1) { + return true; + } + return anchorLines[0].replace(/\s+/g, '').length >= MIN_DISTINCTIVE_CHARS; +} + +/** + * Where a comment's lines went after the file changed under it. Exact matches only; when the same + * lines appear more than once, the occurrence nearest to where the comment used to be wins, but + * only for an anchor distinctive enough to identify the code. + */ +export function reanchor(anchorContent: string, fileLines: string[], originalStartLine: number): AnchorRange | null { + if (anchorContent === '') { + return null; + } + const anchorLines = anchorContent.split('\n'); + const matches: number[] = []; + for (let i = 0; i + anchorLines.length <= fileLines.length; i++) { + if (anchorLines.every((line, offset) => fileLines[i + offset] === line)) { + matches.push(i + 1); + } + } + if (matches.length === 0 || (matches.length > 1 && !isDistinctive(anchorLines))) { + return null; + } + const startLine = matches.reduce((best, candidate) => + Math.abs(candidate - originalStartLine) < Math.abs(best - originalStartLine) ? candidate : best, + ); + return { startLine, endLine: startLine + anchorLines.length - 1 }; +} + +/** An agent working from hunk headers can overshoot the end, and a range past it renders nowhere. */ +export function clampToFile(fileLineCount: number | null, startLine: number, endLine: number): AnchorRange { + if (!fileLineCount || fileLineCount < 1) { + return { startLine, endLine }; + } + const start = Math.min(startLine, fileLineCount); + return { startLine: start, endLine: Math.max(start, Math.min(endLine, fileLineCount)) }; +} + +/** A trailing newline splits into an empty string that is not a line. */ +export function splitLines(content: string): string[] { + if (content === '') { + return []; + } + const lines = content.split('\n'); + if (lines[lines.length - 1] === '') { + lines.pop(); + } + return lines; +} + +export function readAnchor(content: string, startLine: number, endLine: number): string | null { + return splitLines(content).slice(startLine - 1, endLine).join('\n') || null; +} + +/** A rename can happen twice across the commits a review spans; bounded, because a swap would loop. */ +export function followRename(path: string, moves: Map): string { + const seen = new Set([path]); + let current = path; + while (true) { + const next = moves.get(current); + if (!next || seen.has(next)) { + return current; + } + seen.add(next); + current = next; + } +} diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts new file mode 100644 index 00000000..dfb2e764 --- /dev/null +++ b/packages/server/src/app.ts @@ -0,0 +1,393 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import express, { type NextFunction, type Request, type Response } from 'express'; +import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js'; +import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import type { AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js'; +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { Config } from './config.js'; +import type { Users, User, WebSessions } from './users.js'; +import { WebSessions as WebSessionsClass } from './users.js'; +import { hashPresentedClientSecret, type OAuthProvider } from './oauth.js'; +import type { ReviewService } from './service.js'; +import { describeSession } from './service.js'; +import type { LoginProvider } from './login.js'; +import { createMcpServer } from './mcp.js'; +import { uiApiRouter } from './ui-api.js'; +import { escapeHtml, page } from './html.js'; +import { randomToken } from './crypto.js'; +import { + SESSION_COOKIE, + UI_CONTENT_SECURITY_POLICY, + pageContentSecurityPolicy, + parseCookies, + requireSameOrigin, + safeNext, + sessionCookie, +} from './web.js'; + +export interface AppDeps { + config: Pick; + users: Users; + webSessions: WebSessions; + oauth: OAuthProvider; + service: ReviewService; + login: LoginProvider | null; + /** The built review UI; null serves a notice instead of the page. */ + uiDir: string | null; + version: string; + /** The SDK's per-IP limits on the OAuth endpoints; tests turn them off. */ + rateLimit?: boolean; + /** How GitHub access is described on the settings page. */ + gitHubTokenFallback: boolean; +} + +interface PendingConsent { + client: OAuthClientInformationFull; + params: AuthorizationParams; + userId: string; + expiresAt: number; +} + +const CONSENT_TTL_MS = 10 * 60 * 1000; + +function authorizeUrl(client: OAuthClientInformationFull, params: AuthorizationParams): string { + const query = new URLSearchParams({ + client_id: client.client_id, + redirect_uri: params.redirectUri, + response_type: 'code', + code_challenge: params.codeChallenge, + code_challenge_method: 'S256', + }); + if (params.state !== undefined) { + query.set('state', params.state); + } + if (params.scopes && params.scopes.length > 0) { + query.set('scope', params.scopes.join(' ')); + } + if (params.resource) { + query.set('resource', params.resource.href); + } + return `/authorize?${query.toString()}`; +} + +/** + * Where the consent form's redirect may go. A browser applies `form-action` to the redirect that + * follows a form post as well, so the client's redirect target has to be allowed by name. + */ +function formTargetFor(redirectUri: string): string { + const url = new URL(redirectUri); + return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : url.protocol; +} + +export function createApp(deps: AppDeps): express.Express { + const { config, users, webSessions, oauth, service } = deps; + const publicUrl = config.publicUrl; + const mcpUrl = new URL('/mcp', publicUrl); + const secureCookies = publicUrl.protocol === 'https:'; + const reviews = service.reviews; + const pending = new Map(); + const app = express(); + app.disable('x-powered-by'); + + const indexHtml = deps.uiDir && existsSync(join(deps.uiDir, 'index.html')) + ? readFileSync(join(deps.uiDir, 'index.html'), 'utf-8') + : null; + + const cookieToken = (req: Request): string | undefined => parseCookies(req.headers.cookie)[SESSION_COOKIE]; + + const userFor = (req: Request): User | null => { + const userId = webSessions.userFor(cookieToken(req)); + return userId ? users.get(userId) : null; + }; + + const sendPage = (res: Response, status: number, title: string, body: string, user: User | null, formTargets: string[] = []) => { + res.status(status) + .set('Content-Security-Policy', pageContentSecurityPolicy(formTargets)) + .set('Cache-Control', 'no-store') + .type('html') + .send(page({ title, body, user })); + }; + + const loginRedirect = (req: Request, res: Response) => { + res.redirect(302, `/login?next=${encodeURIComponent(req.originalUrl)}`); + }; + + app.use((_req, res, next) => { + res.set('X-Content-Type-Options', 'nosniff'); + res.set('Referrer-Policy', 'same-origin'); + next(); + }); + + app.get('/healthz', (_req, res) => { + res.json({ ok: true, version: deps.version }); + }); + + if (deps.uiDir) { + // Build artifacts, the same for everyone; nothing about a review is in them. + app.use('/assets', express.static(join(deps.uiDir, 'assets'), { index: false, immutable: true, maxAge: '365d' })); + for (const file of ['favicon.svg', 'brand.svg']) { + app.get(`/${file}`, (_req, res) => res.sendFile(join(deps.uiDir!, file))); + } + } + + oauth.onAuthorize = async (client, params, res) => { + const req = res.req; + const user = userFor(req); + if (!user) { + res.redirect(302, `/login?next=${encodeURIComponent(authorizeUrl(client, params))}`); + return; + } + const now = Date.now(); + for (const [id, entry] of pending) { + if (entry.expiresAt < now) { + pending.delete(id); + } + } + const id = randomToken(); + pending.set(id, { client, params, userId: user.id, expiresAt: now + CONSENT_TTL_MS }); + const clientName = client.client_name?.trim() || 'An MCP client'; + const target = new URL(params.redirectUri); + sendPage(res, 200, 'Connect an agent', ` +

Connect ${escapeHtml(clientName)}?

+

${escapeHtml(clientName)} will be able to create review sessions and comment on them as + ${escapeHtml(user.email)}, with the GitHub access you have given this server.

+

After you allow it, you are sent back to ${escapeHtml(target.origin === 'null' ? target.protocol : target.origin)}.

+
+ + + +
`, user, [formTargetFor(params.redirectUri)]); + }; + + // The SDK compares client secrets as plain strings; see DbClientsStore for why this hashes them. + app.use(['/token', '/revoke'], express.urlencoded({ extended: false }), (req, _res, next) => { + hashPresentedClientSecret(req.body); + next(); + }); + + const limits = deps.rateLimit === false ? { rateLimit: false as const } : {}; + app.use(mcpAuthRouter({ + provider: oauth, + issuerUrl: publicUrl, + resourceServerUrl: mcpUrl, + resourceName: 'diffity', + authorizationOptions: limits, + tokenOptions: limits, + revocationOptions: limits, + clientRegistrationOptions: limits, + })); + + // Some clients look for the resource's metadata at the root rather than under its path. + app.get('/.well-known/oauth-protected-resource', (_req, res) => { + res.json({ resource: mcpUrl.href, authorization_servers: [publicUrl.href], resource_name: 'diffity' }); + }); + + const bearer = requireBearerAuth({ + verifier: oauth, + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl), + }); + + app.post('/mcp', bearer, express.json({ limit: '12mb' }), async (req, res) => { + const userId = req.auth?.extra?.userId; + if (typeof userId !== 'string' || !users.get(userId)) { + res.status(401).json({ error: 'The token belongs to no user' }); + return; + } + const server = createMcpServer({ + service, + userId, + agentName: oauth.clientsStore.clientName(req.auth!.clientId) ?? 'Claude', + version: deps.version, + }); + // Stateless: every request is complete in itself, so nothing is held between them. + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + res.on('close', () => { + void transport.close(); + void server.close(); + }); + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (err) { + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32603, message: err instanceof Error ? err.message : String(err) }, + id: null, + }); + } + } + }); + + app.all('/mcp', bearer, (_req, res) => { + res.status(405).set('Allow', 'POST').json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'This server is stateless: POST only' }, + id: null, + }); + }); + + const sameOrigin = requireSameOrigin(publicUrl); + const forms = express.urlencoded({ extended: false, limit: '64kb' }); + + app.get('/login', (req, res) => { + const next = safeNext(req.query.next); + if (userFor(req)) { + res.redirect(302, next); + return; + } + const form = deps.login + ? deps.login.renderForm({ action: '/login', next }) + : '

No sign-in method is configured on this server.

'; + sendPage(res, 200, 'Sign in', `

Sign in

${form}`, null); + }); + + app.post('/login', sameOrigin, forms, async (req, res) => { + const next = safeNext(req.body?.next); + if (!deps.login) { + sendPage(res, 404, 'Sign in', '

No sign-in method is configured on this server.

', null); + return; + } + const result = await deps.login.handleLogin(req); + if ('error' in result) { + sendPage(res, 403, 'Sign in', `

Sign in

${escapeHtml(result.error)}

${deps.login.renderForm({ action: '/login', next })}`, null); + return; + } + const user = users.findOrCreate(result.email, result.name); + const token = webSessions.create(user.id); + res.set('Set-Cookie', sessionCookie(token, { secure: secureCookies, maxAgeSeconds: WebSessionsClass.maxAgeSeconds() })); + res.redirect(303, next); + }); + + app.post('/logout', sameOrigin, (req, res) => { + webSessions.destroy(cookieToken(req)); + res.set('Set-Cookie', sessionCookie('', { secure: secureCookies, maxAgeSeconds: 0 })); + res.redirect(303, '/login'); + }); + + app.post('/oauth/consent', sameOrigin, forms, (req, res) => { + const user = userFor(req); + const id = typeof req.body?.request === 'string' ? req.body.request : ''; + const entry = pending.get(id); + if (!user || !entry || entry.userId !== user.id || entry.expiresAt < Date.now()) { + sendPage(res, 400, 'Connect an agent', '

This request has expired. Start connecting again from your agent.

', user); + return; + } + pending.delete(id); + const target = new URL(entry.params.redirectUri); + if (req.body.decision === 'allow') { + target.searchParams.set('code', oauth.issueCode(entry.client.client_id, user.id, entry.params)); + } else { + target.searchParams.set('error', 'access_denied'); + target.searchParams.set('error_description', 'The user did not allow access'); + } + if (entry.params.state !== undefined) { + target.searchParams.set('state', entry.params.state); + } + res.set('Content-Security-Policy', pageContentSecurityPolicy([formTargetFor(entry.params.redirectUri)])); + res.redirect(302, target.href); + }); + + app.get('/settings', (req, res) => { + const user = userFor(req); + if (!user) { + loginRedirect(req, res); + return; + } + const own = users.gitHubToken(user.id) !== null; + const status = own + ? 'Your own token is set.' + : deps.gitHubTokenFallback + ? 'No token of your own; this server’s development token is used.' + : 'No token set: only public repositories can be reviewed.'; + sendPage(res, 200, 'Settings', ` +

Settings

+

GitHub access

+

${escapeHtml(status)} Every session is checked against GitHub with this token before anything is fetched.

+
+ + +
+ ${own ? '
' : ''} +

Connect an agent

+

Add this server to Claude Code as an MCP connector; it signs you in through this page the first time.

+
claude mcp add --transport http diffity ${escapeHtml(mcpUrl.href)}
`, user); + }); + + app.post('/settings/github-token', sameOrigin, forms, (req, res) => { + const user = userFor(req); + if (!user) { + res.redirect(303, '/login?next=/settings'); + return; + } + const token = typeof req.body?.token === 'string' ? req.body.token.trim() : ''; + users.setGitHubToken(user.id, req.body?.clear === '1' ? null : token || null); + res.redirect(303, '/settings'); + }); + + app.get('/', (req, res) => { + const user = userFor(req); + if (!user) { + loginRedirect(req, res); + return; + } + const sessions = reviews.listSessions(user.id); + const rows = sessions.map(session => ` + + ${escapeHtml(`${session.owner}/${session.repo}`)} + ${escapeHtml(describeSession(session))} + ${escapeHtml(session.createdAt.slice(0, 16).replace('T', ' '))} + `).join(''); + sendPage(res, 200, 'Sessions', sessions.length > 0 + ? `

Your review sessions

${rows}
RepositoryChangeCreated
` + : `

No review sessions yet

Ask your agent to create_session through the diffity MCP connector. + See settings for how to add it.

`, user); + }); + + app.use('/s/:sid/api', requireSameOrigin(publicUrl), (_req, res, next) => { + res.set('Content-Security-Policy', UI_CONTENT_SECURITY_POLICY); + res.set('Cache-Control', 'no-store'); + next(); + }, uiApiRouter({ service, userFor })); + + app.get('/s/:sid{/*rest}', (req, res) => { + const user = userFor(req); + if (!user) { + loginRedirect(req, res); + return; + } + const session = reviews.getSession(user.id, req.params.sid); + if (!session || session.id !== req.params.sid) { + sendPage(res, 404, 'Not found', '

No such session

Your sessions

', user); + return; + } + if (!indexHtml) { + sendPage(res, 503, 'Not built', '

The review UI is not built

Run npm run build at the repository root.

', user); + return; + } + // The UI reads its API and router base from this, so one build serves every session. + const base = JSON.stringify(`/s/${session.id}`).replaceAll('<', '\\u003c'); + res.status(200) + .set('Content-Security-Policy', UI_CONTENT_SECURITY_POLICY) + .set('Cache-Control', 'no-store') + .type('html') + .send(indexHtml.replace('', ``)); + }); + + app.use((_req, res) => { + res.status(404).json({ error: 'Not found' }); + }); + + app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { + const status = typeof (err as { status?: unknown })?.status === 'number' ? (err as { status: number }).status : 500; + if (!res.headersSent) { + res.status(status).json({ + error: status === 400 ? 'Request body must be valid JSON' : err instanceof Error ? err.message : String(err), + }); + } + }); + + return app; +} diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts new file mode 100644 index 00000000..6cd8de83 --- /dev/null +++ b/packages/server/src/config.ts @@ -0,0 +1,128 @@ +import { randomBytes } from 'node:crypto'; + +export interface Config { + publicUrl: URL; + port: number; + /** + * Loopback by default for a localhost public URL, since the dev login trusts any typed email; a + * container sets 0.0.0.0 and publishes the port. + */ + bindHost: string; + dataDir: string; + secretKey: Buffer; + /** True when no key was configured and one was made up for this run. */ + secretKeyGenerated: boolean; + devLogin: boolean; + allowedDomain: string; + allowedEmails: string[]; + devGitHubToken: string | null; + githubApiUrl: string; +} + +export class ConfigError extends Error {} + +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); + +export function isLoopbackUrl(url: URL): boolean { + return LOOPBACK_HOSTNAMES.has(url.hostname); +} + +function flag(value: string | undefined): boolean { + return value === '1' || value?.toLowerCase() === 'true'; +} + +function parsePublicUrl(raw: string | undefined): URL { + const text = raw?.trim() || 'http://localhost:5390'; + let url: URL; + try { + url = new URL(text); + } catch { + throw new ConfigError(`DIFFITY_PUBLIC_URL is not a URL: ${text}`); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new ConfigError('DIFFITY_PUBLIC_URL must be http or https'); + } + if (url.pathname !== '/' || url.search || url.hash) { + throw new ConfigError('DIFFITY_PUBLIC_URL must be an origin, without a path, query or fragment'); + } + return url; +} + +function parsePort(raw: string | undefined): number { + if (!raw?.trim()) { + return 5390; + } + const port = Number(raw); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new ConfigError(`PORT must be an integer between 0 and 65535, got ${raw}`); + } + return port; +} + +function parseSecretKey(raw: string | undefined, publicUrl: URL): { key: Buffer; generated: boolean } { + if (!raw?.trim()) { + // Stored GitHub tokens cannot be read back after a restart with a different key, which is + // acceptable on a laptop and never anywhere else. + if (!isLoopbackUrl(publicUrl)) { + throw new ConfigError('DIFFITY_SECRET_KEY is required when DIFFITY_PUBLIC_URL is not localhost'); + } + return { key: randomBytes(32), generated: true }; + } + const key = Buffer.from(raw.trim(), 'base64'); + if (key.length !== 32) { + throw new ConfigError('DIFFITY_SECRET_KEY must be 32 bytes, base64-encoded (openssl rand -base64 32)'); + } + return { key, generated: false }; +} + +function parseEmails(raw: string | undefined): string[] { + return (raw ?? '') + .split(',') + .map(email => email.trim().toLowerCase()) + .filter(Boolean); +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { + const publicUrl = parsePublicUrl(env.DIFFITY_PUBLIC_URL); + const dataDir = env.DIFFITY_DATA_DIR?.trim(); + if (!dataDir) { + throw new ConfigError('DIFFITY_DATA_DIR is required'); + } + const devLogin = flag(env.DIFFITY_DEV_LOGIN); + // The dev login trusts whatever email is typed, so it must never face the internet. + if (devLogin && !isLoopbackUrl(publicUrl)) { + throw new ConfigError('DIFFITY_DEV_LOGIN is only allowed when DIFFITY_PUBLIC_URL is localhost'); + } + const devGitHubToken = env.DIFFITY_DEV_GITHUB_TOKEN?.trim() || null; + // Shared by every user, so it would hand one person's repository access to all of them. + if (devGitHubToken && !isLoopbackUrl(publicUrl)) { + throw new ConfigError('DIFFITY_DEV_GITHUB_TOKEN is only allowed when DIFFITY_PUBLIC_URL is localhost'); + } + const { key, generated } = parseSecretKey(env.DIFFITY_SECRET_KEY, publicUrl); + + return { + publicUrl, + port: parsePort(env.PORT), + bindHost: env.DIFFITY_BIND?.trim() || (isLoopbackUrl(publicUrl) ? '127.0.0.1' : '0.0.0.0'), + dataDir, + secretKey: key, + secretKeyGenerated: generated, + devLogin, + allowedDomain: (env.DIFFITY_ALLOWED_DOMAIN?.trim() || 'naturalcycles.com').toLowerCase(), + allowedEmails: parseEmails(env.DIFFITY_ALLOWED_EMAILS), + devGitHubToken, + githubApiUrl: (env.GITHUB_API_URL?.trim() || 'https://api.github.com').replace(/\/+$/, ''), + }; +} + +/** An explicit allow-list replaces the domain rule rather than adding to it. */ +export function isAllowedEmail(config: Pick, email: string): boolean { + const normalised = email.trim().toLowerCase(); + if (!/^[^@\s]+@[^@\s]+$/.test(normalised)) { + return false; + } + if (config.allowedEmails.length > 0) { + return config.allowedEmails.includes(normalised); + } + return normalised.endsWith(`@${config.allowedDomain}`); +} diff --git a/packages/server/src/crypto.ts b/packages/server/src/crypto.ts new file mode 100644 index 00000000..02af2c22 --- /dev/null +++ b/packages/server/src/crypto.ts @@ -0,0 +1,42 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from 'node:crypto'; + +/** Opaque bearer secrets: tokens, codes, cookie ids. Only their hash is ever stored. */ +export function randomToken(bytes = 32): string { + return randomBytes(bytes).toString('base64url'); +} + +export function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +export function safeEqual(a: string, b: string): boolean { + const left = Buffer.from(a); + const right = Buffer.from(b); + return left.length === right.length && timingSafeEqual(left, right); +} + +const IV_BYTES = 12; +const TAG_BYTES = 16; + +/** AES-256-GCM, as `base64(iv | tag | ciphertext)`. */ +export function encrypt(key: Buffer, plaintext: string): string { + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const body = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), body]).toString('base64'); +} + +/** Null when the value was written with another key or has been tampered with. */ +export function decrypt(key: Buffer, sealed: string): string | null { + try { + const raw = Buffer.from(sealed, 'base64'); + const iv = raw.subarray(0, IV_BYTES); + const tag = raw.subarray(IV_BYTES, IV_BYTES + TAG_BYTES); + const body = raw.subarray(IV_BYTES + TAG_BYTES); + const decipher = createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8'); + } catch { + return null; + } +} diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts new file mode 100644 index 00000000..9a1fe07e --- /dev/null +++ b/packages/server/src/db.ts @@ -0,0 +1,226 @@ +import { chmodSync, mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { DatabaseSync, type SQLInputValue } from 'node:sqlite'; + +export interface Migration { + version: number; + sql: string; +} + +/** Applied in order, each once. A shipped migration is never edited; a change is a new one. */ +export const MIGRATIONS: Migration[] = [ + { + version: 1, + sql: ` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + settings TEXT NOT NULL DEFAULT '{"shareReviews":"private"}', + github_token TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE web_sessions ( + id_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + + CREATE TABLE oauth_clients ( + client_id TEXT PRIMARY KEY, + client_secret_hash TEXT, + metadata TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE oauth_codes ( + code_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_challenge TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + scopes TEXT NOT NULL, + resource TEXT, + expires_at INTEGER NOT NULL + ); + + CREATE TABLE oauth_tokens ( + token_hash TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('access', 'refresh')), + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scopes TEXT NOT NULL, + resource TEXT, + expires_at INTEGER NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX idx_oauth_tokens_user ON oauth_tokens(user_id); + + CREATE TABLE repos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner TEXT NOT NULL, + name TEXT NOT NULL, + UNIQUE (owner, name) + ); + + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + repo_id INTEGER NOT NULL REFERENCES repos(id), + kind TEXT NOT NULL CHECK (kind IN ('pr', 'shas', 'patch')), + pr_number INTEGER, + pr_meta TEXT, + base_sha TEXT NOT NULL, + head_sha TEXT NOT NULL, + review_started_at TEXT, + review_finished_at TEXT, + review_note TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + UNIQUE (user_id, repo_id, base_sha, head_sha) + ); + CREATE INDEX idx_sessions_user ON sessions(user_id, created_at); + + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + file_path TEXT NOT NULL, + side TEXT NOT NULL, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + anchor_content TEXT, + submitted_at TEXT, + submitted_review_url TEXT, + submitted_head_sha TEXT, + submitted_body TEXT, + github_comment_id INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX idx_threads_session ON threads(session_id); + + CREATE TABLE comments ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + author_name TEXT NOT NULL, + author_type TEXT NOT NULL, + body TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'review', + created_at TEXT NOT NULL + ); + CREATE INDEX idx_comments_thread ON comments(thread_id); + + CREATE TABLE tours ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + topic TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'building', + created_at TEXT NOT NULL + ); + CREATE INDEX idx_tours_session ON tours(session_id); + + CREATE TABLE tour_steps ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tour_id TEXT NOT NULL REFERENCES tours(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL, + file_path TEXT NOT NULL, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + body TEXT NOT NULL DEFAULT '', + annotation TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + ); + CREATE INDEX idx_tour_steps_tour ON tour_steps(tour_id); + `, + }, +]; + +/** + * node:sqlite types every row as `Record`, so the shape a query returns + * has to be asserted; these keep the assertion in one place. + */ +export class Store { + readonly db: DatabaseSync; + + constructor(path: string, migrations: Migration[] = MIGRATIONS) { + mkdirSync(dirname(path), { recursive: true }); + this.db = new DatabaseSync(path); + // Anchor content is source code and the tables hold token hashes; neither is for other users. + for (const file of [path, `${path}-wal`, `${path}-shm`]) { + try { + chmodSync(file, 0o600); + } catch { + // The WAL siblings appear only once something has been written. + } + } + this.db.exec('PRAGMA journal_mode = WAL'); + this.db.exec('PRAGMA foreign_keys = ON'); + this.db.exec('PRAGMA busy_timeout = 5000'); + migrate(this.db, migrations); + } + + all(sql: string, ...params: SQLInputValue[]): T[] { + return this.db.prepare(sql).all(...params) as T[]; + } + + get(sql: string, ...params: SQLInputValue[]): T | undefined { + return this.db.prepare(sql).get(...params) as T | undefined; + } + + run(sql: string, ...params: SQLInputValue[]): { changes: number } { + const result = this.db.prepare(sql).run(...params); + return { changes: Number(result.changes) }; + } + + transaction(work: () => T): T { + this.db.exec('BEGIN IMMEDIATE'); + try { + const result = work(); + this.db.exec('COMMIT'); + return result; + } catch (err) { + this.db.exec('ROLLBACK'); + throw err; + } + } + + schemaVersion(): number { + return this.get<{ version: number }>('SELECT MAX(version) AS version FROM schema_version')?.version ?? 0; + } + + close(): void { + this.db.close(); + } +} + +function migrate(db: DatabaseSync, migrations: Migration[]): void { + db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)'); + const applied = new Set( + (db.prepare('SELECT version FROM schema_version').all() as { version: number }[]).map(row => row.version), + ); + const ordered = [...migrations].sort((a, b) => a.version - b.version); + for (const migration of ordered) { + if (applied.has(migration.version)) { + continue; + } + db.exec('BEGIN IMMEDIATE'); + try { + db.exec(migration.sql); + db.prepare('INSERT INTO schema_version (version, applied_at) VALUES (?, ?)').run( + migration.version, + new Date().toISOString(), + ); + db.exec('COMMIT'); + } catch (err) { + db.exec('ROLLBACK'); + throw new Error(`Migration ${migration.version} failed: ${err instanceof Error ? err.message : err}`); + } + } +} diff --git a/packages/server/src/git.ts b/packages/server/src/git.ts new file mode 100644 index 00000000..aebb158a --- /dev/null +++ b/packages/server/src/git.ts @@ -0,0 +1,386 @@ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdir, rename, rm } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { dirname, join } from 'node:path'; + +export class GitError extends Error { + constructor(message: string, readonly exitCode: number | null, readonly stderr: string) { + super(message); + } +} + +export interface GitOptions { + gitDir?: string; + input?: string; + env?: Record; + timeoutMs?: number; +} + +const MAX_OUTPUT_BYTES = 64 * 1024 * 1024; +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; + +/** + * The operator's own git config — `insteadOf` rewrites, credential helpers, colour — would change + * what a mirror fetches and with whose credentials, so it is shut out of every call. + */ +const ISOLATED_ENV: Record = { + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: '', + SSH_ASKPASS: '', +}; + +export async function runGit(args: string[], options: GitOptions = {}): Promise { + return (await runGitBuffer(args, options)).toString('utf8'); +} + +export function runGitBuffer(args: string[], options: GitOptions = {}): Promise { + const fullArgs = options.gitDir ? ['--git-dir', options.gitDir, ...args] : args; + return new Promise((resolve, reject) => { + const child = spawn('git', fullArgs, { + env: { ...process.env, ...ISOLATED_ENV, ...options.env }, + stdio: ['pipe', 'pipe', 'pipe'], + timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }); + const out: Buffer[] = []; + const err: Buffer[] = []; + let size = 0; + child.stdout.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_OUTPUT_BYTES) { + child.kill(); + return; + } + out.push(chunk); + }); + child.stderr.on('data', (chunk: Buffer) => err.push(chunk)); + child.on('error', reject); + child.on('close', code => { + const stderr = Buffer.concat(err).toString('utf8'); + if (size > MAX_OUTPUT_BYTES) { + reject(new GitError(`git ${args[0]} produced more than ${MAX_OUTPUT_BYTES} bytes`, code, stderr)); + return; + } + if (code !== 0) { + reject(new GitError(`git ${args[0]} failed: ${stderr.trim() || `exit ${code}`}`, code, stderr)); + return; + } + resolve(Buffer.concat(out)); + }); + child.stdin.on('error', () => {}); + child.stdin.end(options.input ?? ''); + }); +} + +/** Runs works one at a time in arrival order; one failing does not break the chain. */ +function serializer(): (work: () => Promise) => Promise { + let chain: Promise = Promise.resolve(); + return work => { + const run = chain.then(work, work); + chain = run.catch(() => {}); + return run; + }; +} + +/** Same flags as the CLI's diffs, so the parser sees the format it was written for. */ +const DIFF_FORMAT_ARGS = ['--no-color', '--no-ext-diff', '--src-prefix=a/', '--dst-prefix=b/']; + +const SHA_PATTERN = /^[0-9a-f]{40}$/; +const NAME_PATTERN = /^[A-Za-z0-9_.-]+$/; + +export function isSha(value: string): boolean { + return SHA_PATTERN.test(value); +} + +export function isRepoName(value: string): boolean { + return NAME_PATTERN.test(value) && value !== '.' && value !== '..'; +} + +/** + * A repository-relative path as the diff names it. Rejected rather than normalised: nothing in a + * review has a reason to send an absolute path or climb out with `..`. + */ +export function isSafeRepoPath(path: string): boolean { + if (!path || path.startsWith('/') || path.includes('\0') || path.includes('\\')) { + return false; + } + return path.split('/').every(segment => segment !== '' && segment !== '.' && segment !== '..'); +} + +export type RemoteUrlBuilder = (owner: string, repo: string) => string; + +export const githubRemoteUrl: RemoteUrlBuilder = (owner, repo) => `https://github.com/${owner}/${repo}.git`; + +/** + * Passed through the environment rather than `-c`, so the token is not in the argument list that + * `ps` shows every user on the host, and never written into the mirror's config. Git hands + * `GIT_CONFIG_*` on to the fetches a partial clone makes behind the scenes. + */ +function authEnv(token: string | null): Record { + if (!token) { + return {}; + } + const basic = Buffer.from(`x-access-token:${token}`).toString('base64'); + return { + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'http.extraHeader', + GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}`, + }; +} + +export interface NameStatus { + status: string; + oldPath: string; + newPath: string; +} + +export function parseNameStatus(raw: string): NameStatus[] { + const entries: NameStatus[] = []; + for (const line of raw.split('\n')) { + if (!line) { + continue; + } + const [status, first, second] = line.split('\t'); + if (!status || !first) { + continue; + } + entries.push({ status, oldPath: first, newPath: second ?? first }); + } + return entries; +} + +/** + * Bare, blob-less mirrors of GitHub repositories, one per repository, shared by every user. Access + * is checked against GitHub before a mirror is touched; the mirror itself only holds what has been + * fetched with some permitted user's token. + */ +export class Mirrors { + private readonly locks = new Map(work: () => Promise) => Promise>(); + + constructor(private readonly dataDir: string, private readonly remoteUrl: RemoteUrlBuilder = githubRemoteUrl) {} + + pathFor(owner: string, repo: string): string { + if (!isRepoName(owner) || !isRepoName(repo)) { + throw new Error(`Not a repository name: ${owner}/${repo}`); + } + return join(this.dataDir, 'mirrors', owner.toLowerCase(), `${repo.toLowerCase()}.git`); + } + + private lock(key: string): (work: () => Promise) => Promise { + let run = this.locks.get(key); + if (!run) { + run = serializer(); + this.locks.set(key, run); + } + return run; + } + + private withRepo(owner: string, repo: string, work: (gitDir: string) => Promise): Promise { + const gitDir = this.pathFor(owner, repo); + return this.lock(gitDir)(() => work(gitDir)); + } + + private async ensureCloned(owner: string, repo: string, gitDir: string, token: string | null): Promise { + if (existsSync(join(gitDir, 'HEAD'))) { + return; + } + await mkdir(dirname(gitDir), { recursive: true }); + // Cloned beside the target and moved into place, so a failed clone leaves nothing that + // looks like a mirror. + const staging = `${gitDir}.tmp-${randomUUID()}`; + try { + await runGit(['clone', '--bare', '--filter=blob:none', '--no-tags', this.remoteUrl(owner, repo), staging], { + env: authEnv(token), + timeoutMs: 30 * 60 * 1000, + }); + // The shas a session needs are pinned by refs, but a gc could still drop what a review is + // looking at before it is pinned. + await runGit(['config', 'gc.auto', '0'], { gitDir: staging }); + await runGit(['config', 'maintenance.auto', 'false'], { gitDir: staging }); + await rename(staging, gitDir); + } catch (err) { + await rm(staging, { recursive: true, force: true }); + throw err; + } + } + + private async hasObject(gitDir: string, spec: string): Promise { + try { + await runGit(['cat-file', '-e', spec], { gitDir }); + return true; + } catch { + return false; + } + } + + private async pin(gitDir: string, sha: string): Promise { + await runGit(['update-ref', `refs/diffity/keep/${sha}`, sha], { gitDir }); + } + + /** Makes each commit present, fetching the ones that are not, and pins them against gc. */ + fetchCommits(owner: string, repo: string, token: string | null, shas: string[]): Promise { + return this.withRepo(owner, repo, async gitDir => { + await this.ensureCloned(owner, repo, gitDir, token); + for (const sha of shas) { + if (!isSha(sha)) { + throw new Error(`Not a full commit sha: ${sha}`); + } + if (!(await this.hasObject(gitDir, `${sha}^{commit}`))) { + await runGit(['fetch', '--filter=blob:none', '--no-tags', 'origin', sha], { + gitDir, + env: authEnv(token), + }); + } + if (!(await this.hasObject(gitDir, `${sha}^{commit}`))) { + throw new Error(`Commit ${sha} is not in ${owner}/${repo}`); + } + await this.pin(gitDir, sha); + } + }); + } + + /** The pull request's head as GitHub has it now, fetched and pinned. */ + fetchPullHead(owner: string, repo: string, token: string | null, prNumber: number): Promise { + return this.withRepo(owner, repo, async gitDir => { + await this.ensureCloned(owner, repo, gitDir, token); + const ref = `refs/diffity/pull/${prNumber}`; + await runGit(['fetch', '--filter=blob:none', '--no-tags', 'origin', `+refs/pull/${prNumber}/head:${ref}`], { + gitDir, + env: authEnv(token), + }); + const sha = (await runGit(['rev-parse', '--verify', `${ref}^{commit}`], { gitDir })).trim(); + await this.pin(gitDir, sha); + return sha; + }); + } + + mergeBase(owner: string, repo: string, token: string | null, a: string, b: string): Promise { + return this.withRepo(owner, repo, async gitDir => + (await runGit(['merge-base', a, b], { gitDir, env: authEnv(token) })).trim(), + ); + } + + /** + * The tree a patch makes of the base commit, built in a throwaway index so the mirror's own + * state is never touched. Its sha stands in for a head commit that does not exist. + */ + applyPatch(owner: string, repo: string, token: string | null, base: string, patch: string): Promise { + return this.withRepo(owner, repo, async gitDir => { + const indexFile = join(this.dataDir, 'tmp', `${randomUUID()}.index`); + await mkdir(dirname(indexFile), { recursive: true }); + const env = { ...authEnv(token), GIT_INDEX_FILE: indexFile }; + try { + await runGit(['read-tree', base], { gitDir, env }); + try { + await runGit(['apply', '--cached', '--whitespace=nowarn', '-'], { gitDir, env, input: patch }); + } catch (err) { + const detail = err instanceof GitError ? err.stderr.trim() : String(err); + throw new Error(`The patch does not apply to ${base.slice(0, 12)}: ${detail}`); + } + const tree = (await runGit(['write-tree'], { gitDir, env })).trim(); + await runGit(['update-ref', `refs/diffity/keep/tree-${tree}`, tree], { gitDir }); + return tree; + } finally { + await rm(indexFile, { force: true }); + } + }); + } + + diff( + owner: string, + repo: string, + token: string | null, + base: string, + head: string, + options: { ignoreWhitespace?: boolean; path?: string } = {}, + ): Promise { + return this.withRepo(owner, repo, gitDir => + runGit( + [ + '--literal-pathspecs', + 'diff', + ...DIFF_FORMAT_ARGS, + ...(options.ignoreWhitespace ? ['-w'] : []), + base, + head, + ...(options.path ? ['--', options.path] : []), + ], + { gitDir, env: authEnv(token) }, + ), + ); + } + + shortStat(owner: string, repo: string, token: string | null, base: string, head: string): Promise { + return this.withRepo(owner, repo, gitDir => + runGit(['diff', '--shortstat', base, head], { gitDir, env: authEnv(token) }), + ); + } + + nameStatus(owner: string, repo: string, token: string | null, base: string, head: string): Promise { + return this.withRepo(owner, repo, async gitDir => + parseNameStatus(await runGit(['diff', '--name-status', base, head], { gitDir, env: authEnv(token) })), + ); + } + + renames(owner: string, repo: string, token: string | null, from: string, to: string): Promise { + return this.withRepo(owner, repo, async gitDir => + parseNameStatus( + await runGit(['diff', '-M', '--name-status', '--diff-filter=R', from, to], { gitDir, env: authEnv(token) }), + ), + ); + } + + /** Many files in one git call; a path missing at that revision maps to null. */ + readFiles(owner: string, repo: string, token: string | null, rev: string, paths: string[]): Promise> { + const wanted = paths.filter(isSafeRepoPath); + const result = new Map(paths.map(path => [path, null])); + if (wanted.length === 0) { + return Promise.resolve(result); + } + return this.withRepo(owner, repo, async gitDir => { + const raw = await runGitBuffer(['cat-file', '--batch'], { + gitDir, + env: authEnv(token), + input: wanted.map(path => `${rev}:${path}`).join('\n') + '\n', + }); + let offset = 0; + for (const path of wanted) { + const newline = raw.indexOf(0x0a, offset); + if (newline === -1) { + break; + } + const header = raw.subarray(offset, newline).toString('utf8'); + offset = newline + 1; + const match = /^[0-9a-f]+ (\w+) (\d+)$/.exec(header); + if (!match) { + continue; + } + const size = Number(match[2]); + if (match[1] === 'blob') { + result.set(path, raw.subarray(offset, offset + size).toString('utf8')); + } + offset += size + 1; + } + return result; + }); + } + + /** Null when the path does not exist at that revision. */ + readFile(owner: string, repo: string, token: string | null, rev: string, path: string): Promise { + if (!isSafeRepoPath(path)) { + return Promise.resolve(null); + } + return this.withRepo(owner, repo, async gitDir => { + try { + return await runGit(['cat-file', 'blob', `${rev}:${path}`], { gitDir, env: authEnv(token) }); + } catch (err) { + if (err instanceof GitError) { + return null; + } + throw err; + } + }); + } +} diff --git a/packages/server/src/github.ts b/packages/server/src/github.ts new file mode 100644 index 00000000..94450212 --- /dev/null +++ b/packages/server/src/github.ts @@ -0,0 +1,114 @@ +import type { Users } from './users.js'; + +/** + * Whose GitHub credentials act for a user. Phase 1 reads a token the user pasted; the GitHub App + * replaces this with an installation token without anything above it changing. + */ +export interface GitHubAccess { + tokenFor(userId: string): Promise; +} + +export class StoredTokenAccess implements GitHubAccess { + constructor(private readonly users: Users, private readonly fallbackToken: string | null) {} + + async tokenFor(userId: string): Promise { + return this.users.gitHubToken(userId) ?? this.fallbackToken; + } +} + +export interface RepoInfo { + owner: string; + name: string; + private: boolean; +} + +export interface PullInfo { + number: number; + title: string; + url: string; + createdAt: string; + author: string; + body: string; + baseSha: string; + headSha: string; + headRef: string; + baseRef: string; + state: string; +} + +export class GitHubApiError extends Error { + constructor(message: string, readonly status: number) { + super(message); + } +} + +type Fetch = typeof fetch; + +export class GitHubApi { + constructor(private readonly apiUrl: string, private readonly fetchImpl: Fetch = fetch) {} + + private async request(token: string | null, path: string): Promise { + const headers: Record = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'diffity-server', + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + return this.fetchImpl(`${this.apiUrl}${path}`, { headers, signal: AbortSignal.timeout(20_000) }); + } + + /** + * Whether this token may read the repository, answered by GitHub itself. Null for both "does not + * exist" and "not yours": GitHub answers 404 to both so as not to say which. + */ + async getRepo(token: string | null, owner: string, repo: string): Promise { + const res = await this.request(token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`); + if (res.status === 404 || res.status === 403 || res.status === 401) { + return null; + } + if (!res.ok) { + throw new GitHubApiError(`GitHub answered ${res.status} for ${owner}/${repo}`, res.status); + } + const json = (await res.json()) as { name: string; private: boolean; owner: { login: string } }; + return { owner: json.owner.login, name: json.name, private: json.private }; + } + + async getPull(token: string | null, owner: string, repo: string, prNumber: number): Promise { + const res = await this.request( + token, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}`, + ); + if (res.status === 404) { + return null; + } + if (!res.ok) { + throw new GitHubApiError(`GitHub answered ${res.status} for ${owner}/${repo}#${prNumber}`, res.status); + } + const json = (await res.json()) as { + number: number; + title: string; + html_url: string; + created_at: string; + body: string | null; + state: string; + user: { login: string } | null; + base: { sha: string; ref: string }; + head: { sha: string; ref: string }; + }; + return { + number: json.number, + title: json.title, + url: json.html_url, + createdAt: json.created_at, + author: json.user?.login ?? '', + body: json.body ?? '', + baseSha: json.base.sha, + headSha: json.head.sha, + headRef: json.head.ref, + baseRef: json.base.ref, + state: json.state, + }; + } +} diff --git a/packages/server/src/html.ts b/packages/server/src/html.ts new file mode 100644 index 00000000..5a519b55 --- /dev/null +++ b/packages/server/src/html.ts @@ -0,0 +1,49 @@ +export function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +const STYLE = ` + :root { color-scheme: light dark; --fg: #1f2328; --muted: #59636e; --bg: #fff; --line: #d1d9e0; --accent: #0969da; } + @media (prefers-color-scheme: dark) { :root { --fg: #e6edf3; --muted: #9198a1; --bg: #0d1117; --line: #3d444d; --accent: #4493f8; } } + body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--fg); background: var(--bg); margin: 0; } + header { display: flex; gap: 16px; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--line); } + header .spacer { flex: 1; } + main { max-width: 880px; margin: 24px auto; padding: 0 16px; } + a { color: var(--accent); } + .muted { color: var(--muted); } + .error { color: #cf222e; } + table { border-collapse: collapse; width: 100%; } + td, th { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; } + input[type=email], input[type=password], input[type=text] { font: inherit; padding: 6px 8px; min-width: 280px; } + button { font: inherit; padding: 6px 12px; cursor: pointer; } + form.inline { display: inline; } + code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } + pre { padding: 8px; border: 1px solid var(--line); overflow-x: auto; } +`; + +export function page(input: { title: string; body: string; user?: { email: string } | null }): string { + const nav = input.user + ? `SessionsSettings + ${escapeHtml(input.user.email)} +
` + : ''; + return ` + + + + +${escapeHtml(input.title)} · diffity + + + + +
diffity${nav}
+
${input.body}
+ +`; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 00000000..8ef09832 --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,52 @@ +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ConfigError, loadConfig } from './config.js'; +import { startServer } from './server.js'; + +const require = createRequire(import.meta.url); +const here = dirname(fileURLToPath(import.meta.url)); +const pkg = require('../package.json') as { version: string }; + +/** The build copies the UI next to the server; running from source uses the CLI's build of it. */ +function findUiDir(): string | null { + const candidates = [join(here, 'ui'), join(here, '../../cli/dist/ui/client')]; + return candidates.find(dir => existsSync(join(dir, 'index.html'))) ?? null; +} + +async function main(): Promise { + let config; + try { + config = loadConfig(); + } catch (err) { + if (err instanceof ConfigError) { + console.error(`diffity-server: ${err.message}`); + process.exit(1); + } + throw err; + } + + if (config.secretKeyGenerated) { + console.warn('Warning: DIFFITY_SECRET_KEY is not set; using a key for this run only. Saved GitHub tokens will not survive a restart.'); + } + if (!config.devLogin) { + console.warn('Warning: no sign-in method is enabled (set DIFFITY_DEV_LOGIN=1 on localhost).'); + } + const uiDir = findUiDir(); + if (!uiDir) { + console.warn('Warning: the review UI is not built; run `npm run build` at the repository root.'); + } + + const running = await startServer(config, { uiDir, version: pkg.version }); + console.log(`diffity-server ${pkg.version} listening on port ${running.port}, public URL ${config.publicUrl.href}`); + console.log(`MCP endpoint: ${new URL('/mcp', config.publicUrl).href}`); + + const stop = () => { + void running.close().then(() => process.exit(0)); + }; + process.on('SIGINT', stop); + process.on('SIGTERM', stop); +} + +void main(); diff --git a/packages/server/src/login.ts b/packages/server/src/login.ts new file mode 100644 index 00000000..40fde6a8 --- /dev/null +++ b/packages/server/src/login.ts @@ -0,0 +1,41 @@ +import type { Request } from 'express'; +import { isAllowedEmail, type Config } from './config.js'; +import { escapeHtml } from './html.js'; + +export type LoginResult = { email: string; name?: string } | { error: string }; + +/** + * How a person proves who they are. Phase 1 has only the dev login; Firebase Google sign-in slots + * in as another implementation, verifying an ID token in `handleLogin`. + */ +export interface LoginProvider { + /** The inside of the login page's form area; `action` is where it must POST. */ + renderForm(input: { action: string; next: string }): string; + handleLogin(req: Request): Promise; +} + +/** Trusts the typed email, so it is only ever enabled on localhost (see config). */ +export class DevLoginProvider implements LoginProvider { + constructor(private readonly config: Pick) {} + + renderForm({ action, next }: { action: string; next: string }): string { + const hint = this.config.allowedEmails.length > 0 + ? 'one of the allowed addresses' + : `an @${this.config.allowedDomain} address`; + return ` +
+ + + +

Development login: no password, localhost only.

+
`; + } + + async handleLogin(req: Request): Promise { + const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''; + if (!isAllowedEmail(this.config, email)) { + return { error: 'That email address is not allowed to sign in here.' }; + } + return { email, name: email.split('@')[0] }; + } +} diff --git a/packages/server/src/mcp.ts b/packages/server/src/mcp.ts new file mode 100644 index 00000000..00bf1179 --- /dev/null +++ b/packages/server/src/mcp.ts @@ -0,0 +1,441 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import { GENERAL_THREAD_FILE_PATH, THREAD_STATUSES, type CommentAuthor, type CommentThread } from '@diffity/api'; +import { AmbiguousIdError, type ReviewSessionRecord } from './reviews.js'; +import { ServiceError, describeSession, type ReviewService } from './service.js'; +import { clampToFile, readAnchor, splitLines } from './anchor.js'; + +export interface McpContext { + service: ReviewService; + userId: string; + /** Who the comments are from, as the connecting client named itself when it registered. */ + agentName: string; + version: string; +} + +function json(value: unknown): CallToolResult { + return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] }; +} + +function text(value: string): CallToolResult { + return { content: [{ type: 'text', text: value }] }; +} + +function failure(message: string): CallToolResult { + return { content: [{ type: 'text', text: message }], isError: true }; +} + +/** Every tool answers a mistake as a result the model can read, never as a protocol error. */ +function guarded(work: (args: A) => Promise): (args: A) => Promise { + return async args => { + try { + return await work(args); + } catch (err) { + if (err instanceof ServiceError || err instanceof AmbiguousIdError) { + return failure(err.message); + } + return failure(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`); + } + }; +} + +const sessionArg = z.string().min(1).describe('Session id (full or its first 8 characters)'); +const line = z.number().int().min(1); +const body = z.string().min(1); + +function summariseThread(thread: CommentThread) { + return { + id: thread.id, + status: thread.status, + file: thread.filePath === GENERAL_THREAD_FILE_PATH ? null : thread.filePath, + side: thread.side, + startLine: thread.startLine, + endLine: thread.endLine, + comments: thread.comments.map(comment => ({ + id: comment.id, + author: comment.author, + kind: comment.kind, + body: comment.body, + createdAt: comment.createdAt, + })), + }; +} + +export function createMcpServer(ctx: McpContext): McpServer { + const { service, userId } = ctx; + const reviews = service.reviews; + const agent: CommentAuthor = { name: ctx.agentName, type: 'agent' }; + const server = new McpServer({ name: 'diffity', version: ctx.version }); + + const session = (id: string): ReviewSessionRecord => service.requireSession(userId, id); + + const thread = (id: string, sessionId?: string): CommentThread => { + const found = reviews.getThread(userId, id); + if (!found) { + throw new ServiceError(`No thread matches ${id}`); + } + if (sessionId !== undefined && found.sessionId !== session(sessionId).id) { + throw new ServiceError(`Thread ${id} belongs to another session`); + } + return found; + }; + + const tour = (id: string) => { + const found = reviews.getTour(userId, id); + if (!found) { + throw new ServiceError(`No walkthrough matches ${id}`); + } + return found; + }; + + const describe = (record: ReviewSessionRecord) => ({ + session: record.id, + url: service.sessionUrl(record.id), + repo: `${record.owner}/${record.repo}`, + kind: record.kind, + pr: record.prNumber, + title: record.prMeta?.title ?? null, + description: describeSession(record), + base: record.baseSha, + head: record.headSha, + createdAt: record.createdAt, + }); + + server.registerTool( + 'create_session', + { + description: + 'Open a review session on pushed code. Give the repository and exactly one of: `pr` (a pull request number); ' + + '`base` and `head` (full commit shas); or `base` and `patch` (a unified diff applied to base, for work that is not pushed). ' + + 'Returns the session id, the URL the user opens to read the review, and the changed files.', + inputSchema: { + repo: z.string().describe('"owner/name" on GitHub'), + pr: z.number().int().positive().optional(), + base: z.string().optional().describe('Full 40-character commit sha'), + head: z.string().optional().describe('Full 40-character commit sha'), + patch: z.string().optional().describe('Unified diff (git diff output) against base'), + }, + }, + guarded(async args => { + const created = await service.createSession(userId, args); + return json({ + ...describe(created.session), + created: created.created, + carriedThreads: created.carried, + files: created.files.map(file => ({ + status: file.status, + path: file.newPath, + ...(file.oldPath !== file.newPath ? { oldPath: file.oldPath } : {}), + })), + }); + }), + ); + + server.registerTool( + 'list_sessions', + { + description: 'List your review sessions, newest first.', + inputSchema: { repo: z.string().optional().describe('Only sessions of this "owner/name"') }, + annotations: { readOnlyHint: true }, + }, + guarded(async ({ repo }) => { + const [owner, name] = repo?.split('/') ?? []; + return json(reviews.listSessions(userId, { owner, repo: name }).map(describe)); + }), + ); + + server.registerTool( + 'get_diff', + { + description: "The session's unified diff; line numbers are in the @@ hunk headers.", + inputSchema: { session: sessionArg, file: z.string().optional().describe('Only this file') }, + annotations: { readOnlyHint: true }, + }, + guarded(async args => { + const raw = await service.diffText(session(args.session), { path: args.file }); + return text(raw.trim() ? raw : 'No changes.'); + }), + ); + + server.registerTool( + 'get_file', + { + description: 'A file as it is on one side of the diff: `new` is the head, `old` the base.', + inputSchema: { session: sessionArg, path: z.string().min(1), side: z.enum(['old', 'new']).default('new') }, + annotations: { readOnlyHint: true }, + }, + guarded(async args => { + const content = await service.readFile(session(args.session), args.side, args.path); + if (content === null) { + throw new ServiceError(`${args.path} does not exist on the ${args.side} side`); + } + return text(content); + }), + ); + + server.registerTool( + 'get_standards', + { + description: "The project's review standards and severity labels, from .diffity.json at the head.", + inputSchema: { session: sessionArg }, + annotations: { readOnlyHint: true }, + }, + guarded(async args => json(await service.standards(session(args.session)))), + ); + + server.registerTool( + 'review_start', + { + description: 'Say a review is under way, so the page shows it is not finished. Call review_done at the end, always.', + inputSchema: { session: sessionArg, note: z.string().optional() }, + }, + guarded(async args => { + reviews.startReview(userId, session(args.session).id, args.note ?? ''); + return text('Review marked as in progress'); + }), + ); + + server.registerTool( + 'review_done', + { + description: 'Say the review is finished, including when nothing was found.', + inputSchema: { session: sessionArg }, + }, + guarded(async args => { + reviews.finishReview(userId, session(args.session).id); + return text('Review marked as finished'); + }), + ); + + server.registerTool( + 'comment', + { + description: + 'Leave an inline finding on a file in the diff. `side` is `new` (default) for added or kept lines, `old` for removed ones.', + inputSchema: { + session: sessionArg, + file: z.string().min(1), + line, + endLine: line.optional(), + side: z.enum(['old', 'new']).default('new'), + body, + }, + }, + guarded(async args => { + if (args.endLine !== undefined && args.endLine < args.line) { + throw new ServiceError('endLine must not be before line'); + } + const record = session(args.session); + await service.assertInDiff(record, args.file, args.side); + const content = await service.readFile(record, args.side, args.file); + const requestedEnd = args.endLine ?? args.line; + const { startLine, endLine } = clampToFile( + content === null ? null : splitLines(content).length, + args.line, + requestedEnd, + ); + const created = reviews.createThread({ + userId, + sessionId: record.id, + filePath: args.file, + side: args.side, + startLine, + endLine, + body: args.body, + author: agent, + // Recorded so the finding can follow its code when the pull request moves on. + anchorContent: args.side === 'new' && content !== null ? readAnchor(content, startLine, endLine) : null, + }); + return json({ + thread: created.id, + startLine, + endLine, + ...(startLine !== args.line || endLine !== requestedEnd + ? { warning: `${args.file} has fewer lines than ${args.line}-${requestedEnd}; anchored to ${startLine}-${endLine}` } + : {}), + }); + }), + ); + + server.registerTool( + 'general_comment', + { + description: 'A comment on the whole diff rather than on any line: the review summary.', + inputSchema: { session: sessionArg, body }, + }, + guarded(async args => { + const created = reviews.createThread({ + userId, + sessionId: session(args.session).id, + filePath: GENERAL_THREAD_FILE_PATH, + side: 'new', + startLine: 0, + endLine: 0, + body: args.body, + author: agent, + }); + return json({ thread: created.id }); + }), + ); + + server.registerTool( + 'reply', + { + description: 'Reply in a thread. An aside is a note for the reader that is never posted to the forge.', + inputSchema: { + id: z.string().min(1).describe('Thread id (full or first 8 characters)'), + body, + session: sessionArg.optional(), + aside: z.boolean().optional(), + }, + }, + guarded(async args => { + const found = thread(args.id, args.session); + const reply = reviews.addReply(userId, found.id, args.body, agent, args.aside ? 'aside' : 'review'); + return json({ thread: found.id, comment: reply.id }); + }), + ); + + server.registerTool( + 'amend', + { + description: "Rewrite a comment's body. Takes a comment id, or a thread id to rewrite the finding that opens it.", + inputSchema: { id: z.string().min(1), body, session: sessionArg.optional() }, + }, + guarded(async args => { + const scoped = args.session === undefined ? undefined : session(args.session).id; + const comment = reviews.findComment(userId, args.id); + let commentId: string; + let sessionId: string; + if (comment) { + commentId = comment.comment.id; + sessionId = comment.sessionId; + } else { + const found = reviews.getThread(userId, args.id); + if (!found || found.comments.length === 0) { + throw new ServiceError(`No comment or thread matches ${args.id}`); + } + commentId = found.comments[0].id; + sessionId = found.sessionId; + } + if (scoped !== undefined && sessionId !== scoped) { + throw new ServiceError(`${args.id} belongs to another session`); + } + reviews.editComment(userId, commentId, args.body); + return json({ comment: commentId }); + }), + ); + + server.registerTool( + 'resolve', + { + description: 'Mark a thread as fixed, optionally saying what was done.', + inputSchema: { id: z.string().min(1), summary: z.string().optional(), session: sessionArg.optional() }, + }, + guarded(async args => { + const found = thread(args.id, args.session); + reviews.updateThreadStatus(userId, found.id, 'resolved', args.summary, args.summary ? agent : undefined); + return text(`Resolved thread ${found.id.slice(0, 8)}`); + }), + ); + + server.registerTool( + 'dismiss', + { + description: "Mark a thread as won't fix, optionally saying why.", + inputSchema: { id: z.string().min(1), reason: z.string().optional(), session: sessionArg.optional() }, + }, + guarded(async args => { + const found = thread(args.id, args.session); + reviews.updateThreadStatus(userId, found.id, 'dismissed', args.reason, args.reason ? agent : undefined); + return text(`Dismissed thread ${found.id.slice(0, 8)}`); + }), + ); + + server.registerTool( + 'list_comments', + { + description: "The session's threads with their comments.", + inputSchema: { session: sessionArg, status: z.enum(THREAD_STATUSES).optional() }, + annotations: { readOnlyHint: true }, + }, + guarded(async args => + json(reviews.threadsForSession(userId, session(args.session).id, args.status).map(summariseThread)), + ), + ); + + server.registerTool( + 'tour_start', + { + description: 'Start a walkthrough, such as the reading order of a review. Add steps with tour_step, then tour_done.', + inputSchema: { session: sessionArg, topic: z.string().min(1), body: z.string().optional() }, + }, + guarded(async args => { + const created = reviews.createTour(userId, session(args.session).id, args.topic, args.body ?? ''); + return json({ tour: created.id }); + }), + ); + + server.registerTool( + 'tour_step', + { + description: + 'Add a step to a walkthrough. `annotation` becomes the file\'s label in the reordered file list: say why it is read here.', + inputSchema: { + tour: z.string().min(1), + file: z.string().min(1), + line, + endLine: line.optional(), + body, + annotation: z.string().optional(), + }, + }, + guarded(async args => { + if (args.endLine !== undefined && args.endLine < args.line) { + throw new ServiceError('endLine must not be before line'); + } + const found = tour(args.tour); + const record = session(found.sessionId); + if ((await service.readFile(record, 'new', args.file)) === null) { + throw new ServiceError(`${args.file} does not exist at the session's head`); + } + const step = reviews.addTourStep(userId, found.id, { + filePath: args.file, + startLine: args.line, + endLine: args.endLine ?? args.line, + body: args.body, + annotation: args.annotation ?? '', + }); + return json({ tour: found.id, step: step.sortOrder }); + }), + ); + + server.registerTool( + 'tour_done', + { + description: 'Mark a walkthrough as ready to read.', + inputSchema: { tour: z.string().min(1) }, + }, + guarded(async args => { + const found = tour(args.tour); + reviews.updateTourStatus(userId, found.id, 'ready'); + return text('Walkthrough marked as ready'); + }), + ); + + server.registerTool( + 'tour_delete', + { + description: 'Remove a walkthrough, to rebuild one that went in wrong.', + inputSchema: { tour: z.string().min(1) }, + }, + guarded(async args => { + const found = tour(args.tour); + reviews.deleteTour(userId, found.id); + return text(`Removed walkthrough ${found.id.slice(0, 8)}`); + }), + ); + + return server; +} diff --git a/packages/server/src/oauth.ts b/packages/server/src/oauth.ts new file mode 100644 index 00000000..0470d3e8 --- /dev/null +++ b/packages/server/src/oauth.ts @@ -0,0 +1,273 @@ +import type { Response } from 'express'; +import type { AuthorizationParams, OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider.js'; +import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js'; +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import type { + OAuthClientInformationFull, + OAuthTokenRevocationRequest, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import { InvalidGrantError, InvalidTargetError, InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'; +import type { Store } from './db.js'; +import { randomToken, sha256 } from './crypto.js'; + +export type AuthorizeHandler = ( + client: OAuthClientInformationFull, + params: AuthorizationParams, + res: Response, +) => Promise; + +export interface OAuthOptions { + /** The MCP endpoint. A token is for this resource and nothing else. */ + resourceUrl: URL; + accessTtlSeconds?: number; + refreshTtlSeconds?: number; + codeTtlSeconds?: number; + now?: () => number; +} + +interface ClientRow { + client_id: string; + client_secret_hash: string | null; + metadata: string; +} + +interface CodeRow { + client_id: string; + user_id: string; + code_challenge: string; + redirect_uri: string; + scopes: string; + resource: string | null; + expires_at: number; +} + +interface TokenRow { + kind: 'access' | 'refresh'; + client_id: string; + user_id: string; + scopes: string; + resource: string | null; + expires_at: number; +} + +function sameResource(a: string, b: string): boolean { + const strip = (value: string) => new URL(value).href.replace(/#.*$/, '').replace(/\/$/, ''); + try { + return strip(a) === strip(b); + } catch { + return false; + } +} + +/** + * The SDK compares a presented client secret with the stored one as plain strings. The table holds + * only a hash, so the secret is hashed on the way in (see `hashPresentedClientSecret`) and the + * store hands out the hash as the client's secret: a hash compares equal only to a hash of the + * same secret, and a presented hash is hashed again and matches nothing. + */ +export class DbClientsStore implements OAuthRegisteredClientsStore { + constructor(private readonly store: Store) {} + + getClient(clientId: string): OAuthClientInformationFull | undefined { + const row = this.store.get('SELECT * FROM oauth_clients WHERE client_id = ?', clientId); + if (!row) { + return undefined; + } + const metadata = JSON.parse(row.metadata) as OAuthClientInformationFull; + return row.client_secret_hash ? { ...metadata, client_secret: row.client_secret_hash } : metadata; + } + + registerClient(client: Omit): OAuthClientInformationFull { + const full = client as OAuthClientInformationFull; + const { client_secret: secret, ...metadata } = full; + this.store.run( + 'INSERT INTO oauth_clients (client_id, client_secret_hash, metadata, created_at) VALUES (?, ?, ?, ?)', + full.client_id, + secret ? sha256(secret) : null, + JSON.stringify(metadata), + new Date().toISOString(), + ); + return full; + } + + clientName(clientId: string): string | null { + const row = this.store.get('SELECT metadata FROM oauth_clients WHERE client_id = ?', clientId); + if (!row) { + return null; + } + const name = (JSON.parse(row.metadata) as { client_name?: unknown }).client_name; + return typeof name === 'string' && name.trim() ? name.trim() : null; + } +} + +/** Installed on `/token` and `/revoke` ahead of the SDK's handlers; see `DbClientsStore`. */ +export function hashPresentedClientSecret(body: unknown): void { + if (body && typeof body === 'object' && typeof (body as { client_secret?: unknown }).client_secret === 'string') { + const record = body as { client_secret: string }; + record.client_secret = sha256(record.client_secret); + } +} + +/** + * An OAuth 2.1 authorization server for the MCP endpoint, backed by the database. Codes and tokens + * are opaque random strings; only their sha256 is stored, so a copy of the database grants nothing. + * Signing in and consenting are the web layer's, handed in as `onAuthorize`. + */ +export class OAuthProvider implements OAuthServerProvider { + readonly clientsStore: DbClientsStore; + private readonly accessTtl: number; + private readonly refreshTtl: number; + private readonly codeTtl: number; + private readonly now: () => number; + onAuthorize: AuthorizeHandler = async () => { + throw new Error('No authorization handler is installed'); + }; + + constructor(private readonly store: Store, private readonly options: OAuthOptions) { + this.clientsStore = new DbClientsStore(store); + this.accessTtl = options.accessTtlSeconds ?? 60 * 60; + this.refreshTtl = options.refreshTtlSeconds ?? 30 * 24 * 60 * 60; + this.codeTtl = options.codeTtlSeconds ?? 10 * 60; + this.now = options.now ?? (() => Math.floor(Date.now() / 1000)); + } + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + if (params.resource && !sameResource(params.resource.href, this.options.resourceUrl.href)) { + throw new InvalidTargetError(`This server only issues tokens for ${this.options.resourceUrl.href}`); + } + await this.onAuthorize(client, params, res); + } + + /** Called once the signed-in user has agreed; the plaintext code goes to the client's redirect. */ + issueCode(clientId: string, userId: string, params: AuthorizationParams): string { + const code = randomToken(); + this.store.run( + `INSERT INTO oauth_codes (code_hash, client_id, user_id, code_challenge, redirect_uri, scopes, resource, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + sha256(code), + clientId, + userId, + params.codeChallenge, + params.redirectUri, + (params.scopes ?? []).join(' '), + params.resource?.href ?? null, + this.now() + this.codeTtl, + ); + return code; + } + + private liveCode(client: OAuthClientInformationFull, code: string): CodeRow { + const row = this.store.get('SELECT * FROM oauth_codes WHERE code_hash = ?', sha256(code)); + if (!row || row.client_id !== client.client_id || row.expires_at < this.now()) { + throw new InvalidGrantError('Invalid or expired authorization code'); + } + return row; + } + + async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise { + return this.liveCode(client, authorizationCode).code_challenge; + } + + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + _codeVerifier?: string, + redirectUri?: string, + resource?: URL, + ): Promise { + const row = this.liveCode(client, authorizationCode); + // Single use: whoever consumes the row first gets the tokens, and a replay finds nothing. + const consumed = this.store.run('DELETE FROM oauth_codes WHERE code_hash = ?', sha256(authorizationCode)).changes; + if (consumed === 0) { + throw new InvalidGrantError('Invalid or expired authorization code'); + } + if (redirectUri !== undefined && redirectUri !== row.redirect_uri) { + throw new InvalidGrantError('redirect_uri does not match the authorization request'); + } + if (resource && row.resource && !sameResource(resource.href, row.resource)) { + throw new InvalidGrantError('resource does not match the authorization request'); + } + return this.issueTokens(row.client_id, row.user_id, row.scopes, row.resource); + } + + async exchangeRefreshToken( + client: OAuthClientInformationFull, + refreshToken: string, + scopes?: string[], + resource?: URL, + ): Promise { + const hash = sha256(refreshToken); + const row = this.store.get("SELECT * FROM oauth_tokens WHERE token_hash = ? AND kind = 'refresh'", hash); + if (!row || row.client_id !== client.client_id || row.expires_at < this.now()) { + throw new InvalidGrantError('Invalid or expired refresh token'); + } + const granted = row.scopes ? row.scopes.split(' ') : []; + if (scopes && scopes.some(scope => !granted.includes(scope))) { + throw new InvalidGrantError('A refresh cannot widen the scopes that were granted'); + } + if (resource && row.resource && !sameResource(resource.href, row.resource)) { + throw new InvalidGrantError('resource does not match the original grant'); + } + // Rotated: a public client's refresh token is a bearer secret, and one that is replayed after + // being used is one that leaked. + if (this.store.run('DELETE FROM oauth_tokens WHERE token_hash = ?', hash).changes === 0) { + throw new InvalidGrantError('Invalid or expired refresh token'); + } + return this.issueTokens(row.client_id, row.user_id, scopes ? scopes.join(' ') : row.scopes, row.resource); + } + + private issueTokens(clientId: string, userId: string, scopes: string, resource: string | null): OAuthTokens { + const accessToken = randomToken(); + const refreshToken = randomToken(); + const now = this.now(); + const insert = (token: string, kind: 'access' | 'refresh', ttl: number) => + this.store.run( + `INSERT INTO oauth_tokens (token_hash, kind, client_id, user_id, scopes, resource, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + sha256(token), + kind, + clientId, + userId, + scopes, + resource, + now + ttl, + new Date(now * 1000).toISOString(), + ); + insert(accessToken, 'access', this.accessTtl); + insert(refreshToken, 'refresh', this.refreshTtl); + return { + access_token: accessToken, + token_type: 'bearer', + expires_in: this.accessTtl, + refresh_token: refreshToken, + ...(scopes ? { scope: scopes } : {}), + }; + } + + async verifyAccessToken(token: string): Promise { + const row = this.store.get("SELECT * FROM oauth_tokens WHERE token_hash = ? AND kind = 'access'", sha256(token)); + if (!row || row.expires_at < this.now()) { + throw new InvalidTokenError('Invalid or expired access token'); + } + return { + token, + clientId: row.client_id, + scopes: row.scopes ? row.scopes.split(' ') : [], + expiresAt: row.expires_at, + ...(row.resource ? { resource: new URL(row.resource) } : {}), + extra: { userId: row.user_id }, + }; + } + + async revokeToken(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise { + this.store.run('DELETE FROM oauth_tokens WHERE token_hash = ? AND client_id = ?', sha256(request.token), client.client_id); + } + + /** Expired rows are dead weight; nothing reads them. */ + purgeExpired(): void { + const now = this.now(); + this.store.run('DELETE FROM oauth_codes WHERE expires_at < ?', now); + this.store.run('DELETE FROM oauth_tokens WHERE expires_at < ?', now); + } +} diff --git a/packages/server/src/reviews.ts b/packages/server/src/reviews.ts new file mode 100644 index 00000000..e19ed103 --- /dev/null +++ b/packages/server/src/reviews.ts @@ -0,0 +1,632 @@ +import { randomUUID } from 'node:crypto'; +import { + isAuthorType, + isCommentKind, + isCommentSide, + isThreadStatus, + isTourStatus, + type Comment, + type CommentAuthor, + type CommentKind, + type CommentSide, + type CommentThread, + type ReviewRun, + type ThreadStatus, + type Tour, + type TourStatus, + type TourStep, +} from '@diffity/api'; +import type { Store } from './db.js'; + +export type SessionKind = 'pr' | 'shas' | 'patch'; + +export interface PrMeta { + title: string; + url: string; + createdAt: string; + author: string; + body: string; + headRef: string; + baseRef: string; +} + +export interface ReviewSessionRecord { + id: string; + userId: string; + repoId: number; + owner: string; + repo: string; + kind: SessionKind; + prNumber: number | null; + prMeta: PrMeta | null; + baseSha: string; + headSha: string; + review: ReviewRun; + createdAt: string; +} + +export class AmbiguousIdError extends Error {} + +interface SessionRow { + id: string; + user_id: string; + repo_id: number; + owner: string; + name: string; + kind: SessionKind; + pr_number: number | null; + pr_meta: string | null; + base_sha: string; + head_sha: string; + review_started_at: string | null; + review_finished_at: string | null; + review_note: string; + created_at: string; +} + +interface ThreadRow { + id: string; + session_id: string; + file_path: string; + side: string; + start_line: number; + end_line: number; + status: string; + anchor_content: string | null; + submitted_at: string | null; + submitted_review_url: string | null; + submitted_head_sha: string | null; + submitted_body: string | null; + github_comment_id: number | null; + created_at: string; + updated_at: string; +} + +interface CommentRow { + id: string; + thread_id: string; + author_name: string; + author_type: string; + body: string; + kind: string; + created_at: string; +} + +interface TourRow { + id: string; + session_id: string; + topic: string; + body: string; + status: string; + created_at: string; +} + +interface TourStepRow { + id: string; + tour_id: string; + sort_order: number; + file_path: string; + start_line: number; + end_line: number; + body: string; + annotation: string; + created_at: string; +} + +const SESSION_SELECT = ` + SELECT s.*, r.owner, r.name FROM sessions s JOIN repos r ON r.id = s.repo_id`; + +function rowToSession(row: SessionRow): ReviewSessionRecord { + let prMeta: PrMeta | null = null; + if (row.pr_meta) { + try { + prMeta = JSON.parse(row.pr_meta) as PrMeta; + } catch { + prMeta = null; + } + } + return { + id: row.id, + userId: row.user_id, + repoId: row.repo_id, + owner: row.owner, + repo: row.name, + kind: row.kind, + prNumber: row.pr_number, + prMeta, + baseSha: row.base_sha, + headSha: row.head_sha, + review: { + inProgress: row.review_started_at !== null && row.review_finished_at === null, + startedAt: row.review_started_at, + note: row.review_note, + }, + createdAt: row.created_at, + }; +} + +function rowToComment(row: CommentRow): Comment { + return { + id: row.id, + author: { name: row.author_name, type: isAuthorType(row.author_type) ? row.author_type : 'user' }, + body: row.body, + kind: isCommentKind(row.kind) ? row.kind : 'review', + createdAt: row.created_at, + liveRequestedAt: null, + liveIntent: null, + liveClaimedAt: null, + liveAnsweredAt: null, + }; +} + +function rowToThread(row: ThreadRow, comments: Comment[]): CommentThread { + return { + id: row.id, + sessionId: row.session_id, + filePath: row.file_path, + side: isCommentSide(row.side) ? row.side : 'new', + startLine: row.start_line, + endLine: row.end_line, + status: isThreadStatus(row.status) ? row.status : 'open', + anchorContent: row.anchor_content, + createdAt: row.created_at, + updatedAt: row.updated_at, + submittedAt: row.submitted_at, + submittedReviewUrl: row.submitted_review_url, + submittedBody: row.submitted_body, + submittedHeadSha: row.submitted_head_sha, + githubCommentId: row.github_comment_id, + comments, + }; +} + +function rowToStep(row: TourStepRow): TourStep { + return { + id: row.id, + tourId: row.tour_id, + sortOrder: row.sort_order, + filePath: row.file_path, + startLine: row.start_line, + endLine: row.end_line, + body: row.body, + annotation: row.annotation, + createdAt: row.created_at, + }; +} + +function placeholders(values: unknown[]): string { + return values.map(() => '?').join(', '); +} + +/** `%` and `_` in a prefix would otherwise widen the match. */ +function likePrefix(prefix: string): string { + return prefix.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_') + '%'; +} + +/** + * Every read and write here names the user it is for, and a row belonging to anyone else is + * treated exactly like one that does not exist. + */ +export class Reviews { + constructor(private readonly store: Store) {} + + /** A full id, or the 8-character prefix the CLI's ids accept, among this user's rows only. */ + private resolveId(table: 'sessions' | 'threads' | 'comments' | 'tours', userId: string, idOrPrefix: string): string | null { + const exact = this.store.get<{ id: string }>(`SELECT id FROM ${table} WHERE id = ? AND user_id = ?`, idOrPrefix, userId); + if (exact) { + return exact.id; + } + if (idOrPrefix.length < 8) { + return null; + } + const matches = this.store.all<{ id: string }>( + `SELECT id FROM ${table} WHERE user_id = ? AND id LIKE ? ESCAPE '\\' LIMIT 2`, + userId, + likePrefix(idOrPrefix), + ); + if (matches.length > 1) { + throw new AmbiguousIdError(`${idOrPrefix} matches more than one ${table.replace(/s$/, '')}; give more of the id`); + } + return matches[0]?.id ?? null; + } + + repoId(owner: string, name: string): number { + this.store.run('INSERT INTO repos (owner, name) VALUES (?, ?) ON CONFLICT (owner, name) DO NOTHING', owner, name); + return this.store.get<{ id: number }>('SELECT id FROM repos WHERE owner = ? AND name = ?', owner, name)!.id; + } + + findOrCreateSession(input: { + userId: string; + owner: string; + repo: string; + kind: SessionKind; + prNumber: number | null; + prMeta: PrMeta | null; + baseSha: string; + headSha: string; + }): { session: ReviewSessionRecord; created: boolean } { + const repoId = this.repoId(input.owner, input.repo); + const existing = this.store.get<{ id: string }>( + 'SELECT id FROM sessions WHERE user_id = ? AND repo_id = ? AND base_sha = ? AND head_sha = ?', + input.userId, + repoId, + input.baseSha, + input.headSha, + ); + if (existing) { + if (input.prMeta) { + this.store.run( + 'UPDATE sessions SET pr_meta = ?, pr_number = COALESCE(pr_number, ?) WHERE id = ?', + JSON.stringify(input.prMeta), + input.prNumber, + existing.id, + ); + } + return { session: this.getSession(input.userId, existing.id)!, created: false }; + } + const id = randomUUID(); + this.store.run( + `INSERT INTO sessions (id, user_id, repo_id, kind, pr_number, pr_meta, base_sha, head_sha, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, + input.userId, + repoId, + input.kind, + input.prNumber, + input.prMeta ? JSON.stringify(input.prMeta) : null, + input.baseSha, + input.headSha, + new Date().toISOString(), + ); + return { session: this.getSession(input.userId, id)!, created: true }; + } + + getSession(userId: string, idOrPrefix: string): ReviewSessionRecord | null { + const id = this.resolveId('sessions', userId, idOrPrefix); + if (!id) { + return null; + } + const row = this.store.get(`${SESSION_SELECT} WHERE s.id = ? AND s.user_id = ?`, id, userId); + return row ? rowToSession(row) : null; + } + + listSessions(userId: string, filter: { owner?: string; repo?: string; limit?: number } = {}): ReviewSessionRecord[] { + const clauses = ['s.user_id = ?']; + const params: (string | number)[] = [userId]; + if (filter.owner && filter.repo) { + clauses.push('lower(r.owner) = lower(?) AND lower(r.name) = lower(?)'); + params.push(filter.owner, filter.repo); + } + params.push(filter.limit ?? 100); + return this.store + .all( + `${SESSION_SELECT} WHERE ${clauses.join(' AND ')} ORDER BY s.created_at DESC, s.rowid DESC LIMIT ?`, + ...params, + ) + .map(rowToSession); + } + + /** Earlier sessions of the same pull request, newest first — where carried work comes from. */ + priorPrSessions(userId: string, repoId: number, prNumber: number, excludeId: string): ReviewSessionRecord[] { + return this.store + .all( + `${SESSION_SELECT} WHERE s.user_id = ? AND s.repo_id = ? AND s.pr_number = ? AND s.id != ? + ORDER BY s.created_at DESC, s.rowid DESC`, + userId, + repoId, + prNumber, + excludeId, + ) + .map(rowToSession); + } + + /** + * Moves rather than copies, so ids stay stable. Resolved and dismissed threads stay with the + * commit where they were dealt with. + */ + moveOpenWork(userId: string, fromSessionIds: string[], toSessionId: string): number { + if (fromSessionIds.length === 0) { + return 0; + } + return this.store.transaction(() => { + const moved = this.store.run( + `UPDATE threads SET session_id = ? WHERE user_id = ? AND status = 'open' AND session_id IN (${placeholders(fromSessionIds)})`, + toSessionId, + userId, + ...fromSessionIds, + ).changes; + this.store.run( + `UPDATE tours SET session_id = ? WHERE user_id = ? AND session_id IN (${placeholders(fromSessionIds)})`, + toSessionId, + userId, + ...fromSessionIds, + ); + return moved; + }); + } + + startReview(userId: string, sessionId: string, note: string): void { + this.store.run( + 'UPDATE sessions SET review_started_at = ?, review_finished_at = NULL, review_note = ? WHERE id = ? AND user_id = ?', + new Date().toISOString(), + note, + sessionId, + userId, + ); + } + + finishReview(userId: string, sessionId: string): void { + this.store.run( + 'UPDATE sessions SET review_finished_at = ? WHERE id = ? AND user_id = ? AND review_started_at IS NOT NULL', + new Date().toISOString(), + sessionId, + userId, + ); + } + + createThread(input: { + userId: string; + sessionId: string; + filePath: string; + side: CommentSide; + startLine: number; + endLine: number; + body: string; + author: CommentAuthor; + anchorContent?: string | null; + kind?: CommentKind; + }): CommentThread { + const threadId = randomUUID(); + const commentId = randomUUID(); + const now = new Date().toISOString(); + this.store.transaction(() => { + this.store.run( + `INSERT INTO threads (id, user_id, session_id, file_path, side, start_line, end_line, anchor_content, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + threadId, + input.userId, + input.sessionId, + input.filePath, + input.side, + input.startLine, + input.endLine, + input.anchorContent ?? null, + now, + now, + ); + this.insertComment(input.userId, commentId, threadId, input.author, input.body, input.kind ?? 'review', now); + }); + return this.getThread(input.userId, threadId)!; + } + + private insertComment( + userId: string, + id: string, + threadId: string, + author: CommentAuthor, + body: string, + kind: CommentKind, + createdAt: string, + ): void { + this.store.run( + `INSERT INTO comments (id, user_id, thread_id, author_name, author_type, body, kind, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + id, + userId, + threadId, + author.name, + author.type, + body, + kind, + createdAt, + ); + } + + private commentsFor(threadIds: string[]): Map { + const map = new Map(); + if (threadIds.length === 0) { + return map; + } + const rows = this.store.all( + `SELECT * FROM comments WHERE thread_id IN (${placeholders(threadIds)}) ORDER BY created_at ASC, rowid ASC`, + ...threadIds, + ); + for (const row of rows) { + const list = map.get(row.thread_id) ?? []; + list.push(rowToComment(row)); + map.set(row.thread_id, list); + } + return map; + } + + getThread(userId: string, idOrPrefix: string): CommentThread | null { + const id = this.resolveId('threads', userId, idOrPrefix); + if (!id) { + return null; + } + const row = this.store.get('SELECT * FROM threads WHERE id = ? AND user_id = ?', id, userId); + if (!row) { + return null; + } + return rowToThread(row, this.commentsFor([row.id]).get(row.id) ?? []); + } + + threadsForSession(userId: string, sessionId: string, status?: ThreadStatus): CommentThread[] { + const rows = status + ? this.store.all( + 'SELECT * FROM threads WHERE user_id = ? AND session_id = ? AND status = ? ORDER BY created_at ASC, rowid ASC', + userId, + sessionId, + status, + ) + : this.store.all( + 'SELECT * FROM threads WHERE user_id = ? AND session_id = ? ORDER BY created_at ASC, rowid ASC', + userId, + sessionId, + ); + const comments = this.commentsFor(rows.map(row => row.id)); + return rows.map(row => rowToThread(row, comments.get(row.id) ?? [])); + } + + addReply(userId: string, threadId: string, body: string, author: CommentAuthor, kind: CommentKind = 'review'): Comment { + const id = randomUUID(); + const now = new Date().toISOString(); + this.store.transaction(() => { + this.insertComment(userId, id, threadId, author, body, kind, now); + // A person answering a finding reopens it; an agent's note on it does not. + if (author.type === 'user') { + this.store.run("UPDATE threads SET status = 'open', updated_at = ? WHERE id = ? AND user_id = ?", now, threadId, userId); + } else { + this.store.run('UPDATE threads SET updated_at = ? WHERE id = ? AND user_id = ?', now, threadId, userId); + } + }); + return rowToComment(this.store.get('SELECT * FROM comments WHERE id = ?', id)!); + } + + updateThreadStatus(userId: string, threadId: string, status: ThreadStatus, summary?: string, summaryAuthor?: CommentAuthor): void { + const now = new Date().toISOString(); + this.store.transaction(() => { + this.store.run('UPDATE threads SET status = ?, updated_at = ? WHERE id = ? AND user_id = ?', status, now, threadId, userId); + if (summary && summaryAuthor) { + this.insertComment(userId, randomUUID(), threadId, summaryAuthor, summary, 'review', now); + } + }); + } + + updateThreadLines(userId: string, threadId: string, startLine: number, endLine: number): void { + this.store.run('UPDATE threads SET start_line = ?, end_line = ? WHERE id = ? AND user_id = ?', startLine, endLine, threadId, userId); + } + + updateThreadPath(userId: string, threadId: string, filePath: string): void { + this.store.run('UPDATE threads SET file_path = ? WHERE id = ? AND user_id = ?', filePath, threadId, userId); + } + + deleteThread(userId: string, threadId: string): void { + this.store.run('DELETE FROM threads WHERE id = ? AND user_id = ?', threadId, userId); + } + + deleteThreadsForSession(userId: string, sessionId: string): void { + this.store.run('DELETE FROM threads WHERE session_id = ? AND user_id = ?', sessionId, userId); + } + + /** The comment, with the thread and session it sits in, if it is this user's. */ + findComment(userId: string, idOrPrefix: string): { comment: Comment; threadId: string; sessionId: string } | null { + const id = this.resolveId('comments', userId, idOrPrefix); + if (!id) { + return null; + } + const row = this.store.get( + `SELECT c.*, t.session_id FROM comments c JOIN threads t ON t.id = c.thread_id + WHERE c.id = ? AND c.user_id = ?`, + id, + userId, + ); + return row ? { comment: rowToComment(row), threadId: row.thread_id, sessionId: row.session_id } : null; + } + + editComment(userId: string, commentId: string, body: string): void { + this.store.run('UPDATE comments SET body = ? WHERE id = ? AND user_id = ?', body, commentId, userId); + } + + /** The last comment of a thread takes the thread with it: an empty thread renders as nothing. */ + deleteComment(userId: string, commentId: string): void { + const row = this.store.get<{ thread_id: string }>('SELECT thread_id FROM comments WHERE id = ? AND user_id = ?', commentId, userId); + if (!row) { + return; + } + this.store.transaction(() => { + this.store.run('DELETE FROM comments WHERE id = ? AND user_id = ?', commentId, userId); + const remaining = this.store.get<{ n: number }>('SELECT COUNT(*) AS n FROM comments WHERE thread_id = ?', row.thread_id); + if (remaining?.n === 0) { + this.store.run('DELETE FROM threads WHERE id = ? AND user_id = ?', row.thread_id, userId); + } + }); + } + + createTour(userId: string, sessionId: string, topic: string, body: string): Tour { + const id = randomUUID(); + this.store.run( + 'INSERT INTO tours (id, user_id, session_id, topic, body, created_at) VALUES (?, ?, ?, ?, ?, ?)', + id, + userId, + sessionId, + topic, + body, + new Date().toISOString(), + ); + return this.getTour(userId, id)!; + } + + getTour(userId: string, idOrPrefix: string): Tour | null { + const id = this.resolveId('tours', userId, idOrPrefix); + if (!id) { + return null; + } + const row = this.store.get('SELECT * FROM tours WHERE id = ? AND user_id = ?', id, userId); + if (!row) { + return null; + } + const steps = this.store.all('SELECT * FROM tour_steps WHERE tour_id = ? ORDER BY sort_order ASC', row.id); + return this.rowToTour(row, steps.map(rowToStep)); + } + + private rowToTour(row: TourRow, steps: TourStep[]): Tour { + return { + id: row.id, + sessionId: row.session_id, + topic: row.topic, + body: row.body, + status: isTourStatus(row.status) ? row.status : 'ready', + createdAt: row.created_at, + steps, + }; + } + + toursForSession(userId: string, sessionId: string): Tour[] { + const rows = this.store.all( + 'SELECT * FROM tours WHERE user_id = ? AND session_id = ? ORDER BY created_at ASC, rowid ASC', + userId, + sessionId, + ); + if (rows.length === 0) { + return []; + } + const steps = this.store.all( + `SELECT * FROM tour_steps WHERE tour_id IN (${placeholders(rows)}) ORDER BY sort_order ASC`, + ...rows.map(row => row.id), + ); + return rows.map(row => this.rowToTour(row, steps.filter(step => step.tour_id === row.id).map(rowToStep))); + } + + addTourStep( + userId: string, + tourId: string, + step: { filePath: string; startLine: number; endLine: number; body: string; annotation: string }, + ): TourStep { + const id = randomUUID(); + const max = this.store.get<{ n: number }>('SELECT COALESCE(MAX(sort_order), 0) AS n FROM tour_steps WHERE tour_id = ?', tourId); + this.store.run( + `INSERT INTO tour_steps (id, user_id, tour_id, sort_order, file_path, start_line, end_line, body, annotation, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, + userId, + tourId, + (max?.n ?? 0) + 1, + step.filePath, + step.startLine, + step.endLine, + step.body, + step.annotation, + new Date().toISOString(), + ); + return rowToStep(this.store.get('SELECT * FROM tour_steps WHERE id = ?', id)!); + } + + updateTourStatus(userId: string, tourId: string, status: TourStatus): void { + this.store.run('UPDATE tours SET status = ? WHERE id = ? AND user_id = ?', status, tourId, userId); + } + + deleteTour(userId: string, tourId: string): void { + this.store.run('DELETE FROM tours WHERE id = ? AND user_id = ?', tourId, userId); + } +} diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts new file mode 100644 index 00000000..ce7f4608 --- /dev/null +++ b/packages/server/src/server.ts @@ -0,0 +1,92 @@ +import { createServer, type Server } from 'node:http'; +import { join } from 'node:path'; +import type { Config } from './config.js'; +import { Store } from './db.js'; +import { Users, WebSessions } from './users.js'; +import { OAuthProvider } from './oauth.js'; +import { Reviews } from './reviews.js'; +import { Mirrors, githubRemoteUrl, type RemoteUrlBuilder } from './git.js'; +import { GitHubApi, StoredTokenAccess, type GitHubAccess } from './github.js'; +import { ReviewService } from './service.js'; +import { DevLoginProvider, type LoginProvider } from './login.js'; +import { createApp } from './app.js'; + +export interface ServerOverrides { + remoteUrl?: RemoteUrlBuilder; + gitHubAccess?: GitHubAccess; + login?: LoginProvider | null; + uiDir?: string | null; + rateLimit?: boolean; + version?: string; +} + +export interface RunningServer { + server: Server; + port: number; + store: Store; + oauth: OAuthProvider; + users: Users; + service: ReviewService; + close(): Promise; +} + +export async function startServer( + config: Config, + overrides: ServerOverrides = {}, + listen: { port?: number; host?: string } = {}, +): Promise { + const store = new Store(join(config.dataDir, 'diffity.db')); + const users = new Users(store, config.secretKey); + const webSessions = new WebSessions(store); + const oauth = new OAuthProvider(store, { resourceUrl: new URL('/mcp', config.publicUrl) }); + const reviews = new Reviews(store); + const mirrors = new Mirrors(config.dataDir, overrides.remoteUrl ?? githubRemoteUrl); + const access = overrides.gitHubAccess ?? new StoredTokenAccess(users, config.devGitHubToken); + const service = new ReviewService(reviews, mirrors, new GitHubApi(config.githubApiUrl), access, config.publicUrl); + const login = overrides.login !== undefined ? overrides.login : config.devLogin ? new DevLoginProvider(config) : null; + + const app = createApp({ + config, + users, + webSessions, + oauth, + service, + login, + uiDir: overrides.uiDir ?? null, + version: overrides.version ?? '0.0.0', + rateLimit: overrides.rateLimit, + gitHubTokenFallback: config.devGitHubToken !== null, + }); + + const server = createServer(app); + const purge = setInterval(() => oauth.purgeExpired(), 60 * 60 * 1000); + purge.unref(); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(listen.port ?? config.port, listen.host ?? config.bindHost, () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : config.port; + + return { + server, + port, + store, + oauth, + users, + service, + close: () => + new Promise(resolve => { + clearInterval(purge); + server.closeAllConnections(); + server.close(() => { + store.close(); + resolve(); + }); + }), + }; +} diff --git a/packages/server/src/service.ts b/packages/server/src/service.ts new file mode 100644 index 00000000..b061b650 --- /dev/null +++ b/packages/server/src/service.ts @@ -0,0 +1,379 @@ +import { parseDiff } from '@diffity/parser'; +import { DEFAULT_SEVERITIES, parseRepoConfig, REPO_CONFIG_FILE } from '@diffity/git'; +import type { CommentThread, DiffResponse, GitHubDetails, Suppressed } from '@diffity/api'; +import { isSha, isSafeRepoPath, isRepoName, type Mirrors, type NameStatus } from './git.js'; +import type { GitHubAccess, GitHubApi } from './github.js'; +import type { PrMeta, ReviewSessionRecord, Reviews } from './reviews.js'; +import { followRename, reanchor, splitLines } from './anchor.js'; + +/** A failure worth telling the caller about in words, with the HTTP status it amounts to. */ +export class ServiceError extends Error { + constructor(message: string, readonly status = 400) { + super(message); + } +} + +export interface CreateSessionInput { + repo: string; + pr?: number; + base?: string; + head?: string; + patch?: string; +} + +export interface CreatedSession { + session: ReviewSessionRecord; + created: boolean; + carried: number; + files: NameStatus[]; +} + +export interface Standards { + severities: string[]; + standards: { path: string; content: string } | null; +} + +const MAX_PATCH_BYTES = 10 * 1024 * 1024; +const CACHE_ENTRIES = 256; + +export function parseRepoSlug(slug: string): { owner: string; repo: string } { + const match = /^([^/\s]+)\/([^/\s]+?)(?:\.git)?$/.exec(slug.trim()); + if (!match || !isRepoName(match[1]) || !isRepoName(match[2])) { + throw new ServiceError(`repo must be "owner/name", got "${slug}"`); + } + return { owner: match[1], repo: match[2] }; +} + +/** What a session's diff is shown as, and what its GitHub details say, independent of any request. */ +export function describeSession(session: ReviewSessionRecord): string { + if (session.prNumber !== null) { + return session.prMeta?.title ? `#${session.prNumber} ${session.prMeta.title}` : `Pull request #${session.prNumber}`; + } + const head = session.kind === 'patch' ? `patch (tree ${session.headSha.slice(0, 7)})` : session.headSha.slice(0, 7); + return `${session.baseSha.slice(0, 7)}..${head}`; +} + +export function gitHubDetailsFor(session: ReviewSessionRecord): GitHubDetails | null { + if (session.prNumber === null || !session.prMeta) { + return null; + } + return { + prNumber: session.prNumber, + prTitle: session.prMeta.title, + prUrl: session.prMeta.url, + prCreatedAt: session.prMeta.createdAt, + headSha: session.headSha, + commentCount: 0, + prAuthor: session.prMeta.author, + viewerDidAuthor: false, + prBody: session.prMeta.body, + reviews: [], + }; +} + +function parseShortStat(stat: string): { files: number; lines: number } { + const files = /(\d+) files? changed/.exec(stat); + const insertions = /(\d+) insertions?\(\+\)/.exec(stat); + const deletions = /(\d+) deletions?\(-\)/.exec(stat); + return { + files: files ? Number(files[1]) : 0, + lines: (insertions ? Number(insertions[1]) : 0) + (deletions ? Number(deletions[1]) : 0), + }; +} + +/** Keeps the most recently used entries. Every key names immutable content, so nothing expires. */ +class Cache { + private readonly entries = new Map>(); + + get(key: string, load: () => Promise): Promise { + const hit = this.entries.get(key); + if (hit) { + this.entries.delete(key); + this.entries.set(key, hit); + return hit; + } + const loading = load(); + this.entries.set(key, loading); + loading.catch(() => this.entries.delete(key)); + if (this.entries.size > CACHE_ENTRIES) { + this.entries.delete(this.entries.keys().next().value!); + } + return loading; + } +} + +export class ReviewService { + private readonly diffCache = new Cache(); + private readonly filesCache = new Cache(); + + constructor( + readonly reviews: Reviews, + private readonly mirrors: Mirrors, + private readonly github: GitHubApi, + private readonly access: GitHubAccess, + readonly publicUrl: URL, + ) {} + + sessionUrl(sessionId: string): string { + return new URL(`/s/${sessionId}/`, this.publicUrl).href; + } + + requireSession(userId: string, idOrPrefix: string): ReviewSessionRecord { + const session = this.reviews.getSession(userId, idOrPrefix); + if (!session) { + throw new ServiceError(`No session matches ${idOrPrefix}`, 404); + } + return session; + } + + async createSession(userId: string, input: CreateSessionInput): Promise { + const slug = parseRepoSlug(input.repo); + const mode = sessionMode(input); + const token = await this.access.tokenFor(userId); + // GitHub is the access check: the mirror is shared, so what a user may see is decided here, + // with their own credentials, before anything is fetched on their behalf. + const repoInfo = await this.github.getRepo(token, slug.owner, slug.repo); + if (!repoInfo) { + throw new ServiceError( + token + ? `${slug.owner}/${slug.repo} does not exist or your GitHub token cannot read it` + : `${slug.owner}/${slug.repo} is not readable without a GitHub token; add one at ${new URL('/settings', this.publicUrl).href}`, + 404, + ); + } + const { owner, name: repo } = repoInfo; + + let baseSha: string; + let headSha: string; + let prMeta: PrMeta | null = null; + let prNumber: number | null = null; + + if (mode === 'pr') { + prNumber = input.pr!; + const pull = await this.github.getPull(token, owner, repo, prNumber); + if (!pull) { + throw new ServiceError(`${owner}/${repo} has no pull request #${prNumber}`, 404); + } + headSha = await this.mirrors.fetchPullHead(owner, repo, token, prNumber); + await this.mirrors.fetchCommits(owner, repo, token, [pull.baseSha]); + // What GitHub shows is the change since the branches diverged, not against the base's tip. + baseSha = await this.mirrors.mergeBase(owner, repo, token, pull.baseSha, headSha); + prMeta = { + title: pull.title, + url: pull.url, + createdAt: pull.createdAt, + author: pull.author, + body: pull.body, + headRef: pull.headRef, + baseRef: pull.baseRef, + }; + } else if (mode === 'shas') { + baseSha = input.base!; + headSha = input.head!; + await this.mirrors.fetchCommits(owner, repo, token, [baseSha, headSha]); + } else { + baseSha = input.base!; + await this.mirrors.fetchCommits(owner, repo, token, [baseSha]); + try { + headSha = await this.mirrors.applyPatch(owner, repo, token, baseSha, input.patch!); + } catch (err) { + throw new ServiceError(err instanceof Error ? err.message : String(err)); + } + } + + const { session, created } = this.reviews.findOrCreateSession({ + userId, + owner, + repo, + kind: mode, + prNumber, + prMeta, + baseSha, + headSha, + }); + const carried = created && prNumber !== null ? await this.carryForward(session) : 0; + return { session, created, carried, files: await this.changedFiles(session) }; + } + + /** + * A pull request that moved on gets a new session, and anything still open has to come along: + * acting on the findings is what moves the head. Lines follow their code where it can be found. + */ + private async carryForward(session: ReviewSessionRecord): Promise { + const priors = this.reviews.priorPrSessions(session.userId, session.repoId, session.prNumber!, session.id); + if (priors.length === 0) { + return 0; + } + const moved = this.reviews.moveOpenWork(session.userId, priors.map(prior => prior.id), session.id); + if (moved === 0) { + return 0; + } + const token = await this.access.tokenFor(session.userId); + const moves = new Map(); + for (const head of new Set(priors.map(prior => prior.headSha))) { + if (head === session.headSha) { + continue; + } + try { + for (const entry of await this.mirrors.renames(session.owner, session.repo, token, head, session.headSha)) { + moves.set(entry.oldPath, entry.newPath); + } + } catch { + // A head the mirror no longer has cannot say what was renamed; the paths stay as they are. + } + } + const threads = this.reviews.threadsForSession(session.userId, session.id, 'open'); + for (const thread of threads) { + const path = followRename(thread.filePath, moves); + if (path !== thread.filePath) { + this.reviews.updateThreadPath(session.userId, thread.id, path); + thread.filePath = path; + } + } + const anchored = threads.filter(thread => thread.side === 'new' && thread.anchorContent && isSafeRepoPath(thread.filePath)); + const contents = await this.mirrors.readFiles( + session.owner, + session.repo, + token, + session.headSha, + [...new Set(anchored.map(thread => thread.filePath))], + ); + for (const thread of anchored) { + const content = contents.get(thread.filePath); + if (content == null) { + continue; + } + const range = reanchor(thread.anchorContent!, splitLines(content), thread.startLine); + if (range && range.startLine !== thread.startLine) { + this.reviews.updateThreadLines(session.userId, thread.id, range.startLine, range.endLine); + } + } + return moved; + } + + private cacheKey(session: ReviewSessionRecord, ...parts: string[]): string { + return [session.owner, session.repo, session.baseSha, session.headSha, ...parts].join('\0'); + } + + async diffText(session: ReviewSessionRecord, options: { ignoreWhitespace?: boolean; path?: string } = {}): Promise { + if (options.path !== undefined && !isSafeRepoPath(options.path)) { + throw new ServiceError(`Not a repository path: ${options.path}`); + } + const token = await this.access.tokenFor(session.userId); + return this.diffCache.get( + this.cacheKey(session, options.ignoreWhitespace ? 'w' : '', options.path ?? ''), + () => this.mirrors.diff(session.owner, session.repo, token, session.baseSha, session.headSha, options), + ); + } + + async changedFiles(session: ReviewSessionRecord): Promise { + const token = await this.access.tokenFor(session.userId); + return this.filesCache.get(this.cacheKey(session, 'names'), () => + this.mirrors.nameStatus(session.owner, session.repo, token, session.baseSha, session.headSha), + ); + } + + /** What `/api/diff` answers: the parsed diff, the base side's line counts, and what `-w` hid. */ + async parsedDiff(session: ReviewSessionRecord, options: { ignoreWhitespace?: boolean; path?: string } = {}): Promise { + const diff = parseDiff(await this.diffText(session, options)); + const token = await this.access.tokenFor(session.userId); + const counted = diff.files.filter(file => file.status !== 'added' && !file.isBinary); + const contents = await this.mirrors.readFiles( + session.owner, + session.repo, + token, + session.baseSha, + counted.map(file => file.oldPath || file.newPath), + ); + for (const file of counted) { + const content = contents.get(file.oldPath || file.newPath); + if (content != null) { + file.oldFileLineCount = splitLines(content).length; + } + } + let suppressed: Suppressed | null = null; + if (options.ignoreWhitespace && options.path === undefined) { + const unfiltered = parseShortStat( + await this.mirrors.shortStat(session.owner, session.repo, token, session.baseSha, session.headSha), + ); + suppressed = { + files: Math.max(0, unfiltered.files - diff.stats.filesChanged), + lines: Math.max(0, unfiltered.lines - diff.stats.totalAdditions - diff.stats.totalDeletions), + }; + } + return { ...diff, suppressed }; + } + + async readFile(session: ReviewSessionRecord, side: 'old' | 'new', path: string): Promise { + if (!isSafeRepoPath(path)) { + return null; + } + const token = await this.access.tokenFor(session.userId); + return this.mirrors.readFile(session.owner, session.repo, token, side === 'old' ? session.baseSha : session.headSha, path); + } + + /** The project's own review rules, as they are at the reviewed head. */ + async standards(session: ReviewSessionRecord): Promise { + const raw = await this.readFile(session, 'new', REPO_CONFIG_FILE); + const review = raw ? parseRepoConfig(raw).review : undefined; + let standards: Standards['standards'] = null; + if (review?.standards) { + const content = await this.readFile(session, 'new', review.standards); + if (content != null) { + standards = { path: review.standards, content }; + } + } + return { severities: review?.severities ?? DEFAULT_SEVERITIES, standards }; + } + + /** + * Refused where the mistake is made: a thread on a file outside the diff renders nowhere, so the + * finding would look written and never be seen. + */ + async assertInDiff(session: ReviewSessionRecord, filePath: string, side: 'old' | 'new'): Promise { + const files = await this.changedFiles(session); + const paths = files.map(file => (side === 'old' ? file.oldPath : file.newPath)); + if (!paths.includes(filePath)) { + const listed = files.slice(0, 20).map(file => ` ${file.newPath}`).join('\n'); + const more = files.length > 20 ? `\n … and ${files.length - 20} more` : ''; + throw new ServiceError( + `File "${filePath}" is not on the ${side} side of this session's diff. The diff has ${files.length} file(s):\n${listed}${more}`, + ); + } + } + + threadsForSession(session: ReviewSessionRecord): CommentThread[] { + return this.reviews.threadsForSession(session.userId, session.id); + } +} + +function sessionMode(input: CreateSessionInput): 'pr' | 'shas' | 'patch' { + const hasPr = input.pr !== undefined; + const hasHead = input.head !== undefined; + const hasPatch = input.patch !== undefined; + if (hasPr && !hasHead && !hasPatch && input.base === undefined) { + if (!Number.isInteger(input.pr) || input.pr! < 1) { + throw new ServiceError('pr must be a positive integer'); + } + return 'pr'; + } + if (!hasPr && input.base !== undefined && hasHead !== hasPatch) { + if (!isSha(input.base)) { + throw new ServiceError('base must be a full 40-character commit sha'); + } + if (hasHead) { + if (!isSha(input.head!)) { + throw new ServiceError('head must be a full 40-character commit sha'); + } + return 'shas'; + } + if (!input.patch!.trim()) { + throw new ServiceError('patch is empty'); + } + if (Buffer.byteLength(input.patch!) > MAX_PATCH_BYTES) { + throw new ServiceError(`patch is larger than ${MAX_PATCH_BYTES / 1024 / 1024} MB`); + } + return 'patch'; + } + throw new ServiceError('Give exactly one of: pr; base and head; base and patch'); +} diff --git a/packages/server/src/ui-api.ts b/packages/server/src/ui-api.ts new file mode 100644 index 00000000..cb153ac9 --- /dev/null +++ b/packages/server/src/ui-api.ts @@ -0,0 +1,303 @@ +import { createHash } from 'node:crypto'; +import express, { type Request, type Response, type Router } from 'express'; +import { + isThreadStatus, + parseCreateThreadRequest, + parseDeleteThreadsRequest, + parseEditCommentRequest, + parseReplyRequest, + parseUpdateThreadStatusRequest, + THREAD_STATUSES, + type CommentAuthor, + type DiffFileResponse, + type DiffFingerprint, + type FileContentResponse, + type LiveStatusResponse, + type ParseResult, + type RepoInfoResponse, +} from '@diffity/api'; +import type { ReviewSessionRecord } from './reviews.js'; +import { describeSession, gitHubDetailsFor, type ReviewService } from './service.js'; +import { splitLines } from './anchor.js'; +import type { User } from './users.js'; + +export interface UiApiDeps { + service: ReviewService; + userFor: (req: Request) => User | null; +} + +interface Scoped { + user: User; + session: ReviewSessionRecord; +} + +function fail(res: Response, status: number, message: string): void { + res.status(status).json({ error: message }); +} + +function parsed(res: Response, result: ParseResult): T | null { + if (!result.ok) { + fail(res, 400, result.error); + return null; + } + return result.value; +} + +/** The `/api/*` subset the review UI needs, scoped to one session of the signed-in user. */ +export function uiApiRouter(deps: UiApiDeps): Router { + const { service } = deps; + const reviews = service.reviews; + const router = express.Router({ mergeParams: true }); + router.use(express.json({ limit: '2mb' })); + + const scope = (req: Request, res: Response): Scoped | null => { + const user = deps.userFor(req); + if (!user) { + fail(res, 401, 'Sign in first'); + return null; + } + const session = reviews.getSession(user.id, String(req.params.sid)); + // Somebody else's session answers exactly like one that does not exist. + if (!session || session.id !== req.params.sid) { + fail(res, 404, 'Session not found'); + return null; + } + return { user, session }; + }; + + const handle = (work: (scoped: Scoped, req: Request, res: Response) => Promise | void) => + async (req: Request, res: Response): Promise => { + const scoped = scope(req, res); + if (!scoped) { + return; + } + try { + await work(scoped, req, res); + } catch (err) { + if (!res.headersSent) { + fail(res, 500, err instanceof Error ? err.message : String(err)); + } + } + }; + + const author = (user: User): CommentAuthor => ({ name: user.name, type: 'user' }); + + const sessionMatches = (res: Response, session: ReviewSessionRecord, asked: unknown): boolean => { + if (asked !== undefined && asked !== null && asked !== '' && asked !== session.id) { + fail(res, 400, 'session does not match this review'); + return false; + } + return true; + }; + + const threadInSession = (res: Response, { user, session }: Scoped, threadId: string) => { + const thread = reviews.getThread(user.id, threadId); + if (!thread || thread.sessionId !== session.id) { + fail(res, 404, 'Thread not found'); + return null; + } + return thread; + }; + + const commentInSession = (res: Response, { user, session }: Scoped, commentId: string) => { + const comment = reviews.findComment(user.id, commentId); + if (!comment || comment.sessionId !== session.id) { + fail(res, 404, 'Comment not found'); + return null; + } + return comment; + }; + + router.get('/info', handle(({ session }, _req, res) => { + res.json({ + name: `${session.owner}/${session.repo}`, + branch: session.prMeta?.headRef ?? session.headSha.slice(0, 7), + root: `github.com/${session.owner}/${session.repo}`, + description: describeSession(session), + capabilities: { reviews: true, revert: false, staleness: false }, + sessionId: session.id, + review: session.review, + github: session.prNumber !== null ? { owner: session.owner, repo: session.repo } : null, + editor: null, + hosted: true, + } satisfies RepoInfoResponse); + })); + + router.get('/diff', handle(async ({ session }, req, res) => { + res.json(await service.parsedDiff(session, { ignoreWhitespace: req.query.whitespace === 'hide' })); + })); + + router.get('/diff/file', handle(async ({ session }, req, res) => { + const path = typeof req.query.path === 'string' ? req.query.path : ''; + if (!path) { + fail(res, 400, 'Missing path'); + return; + } + const diff = await service.parsedDiff(session, { ignoreWhitespace: req.query.whitespace === 'hide', path }); + res.json({ file: diff.files[0] ?? null } satisfies DiffFileResponse); + })); + + // Both ends are fixed commits, so the diff can never go stale; the fingerprint says so. + router.get('/diff-fingerprint', handle(({ session }, _req, res) => { + res.json({ + fingerprint: createHash('sha1').update(`${session.baseSha}..${session.headSha}`).digest('hex'), + files: {}, + } satisfies DiffFingerprint); + })); + + const serveFile = (side: 'old' | 'new') => handle(async ({ session }, req, res) => { + const raw = (req.params as { path?: string | string[] }).path; + const decoded = Array.isArray(raw) ? raw.join('/') : raw ?? ''; + const chosen = side === 'old' && req.query.side === 'new' ? 'new' : side; + const content = await service.readFile(session, chosen, decoded); + if (content === null) { + fail(res, 404, `File not found: ${decoded}`); + return; + } + res.json({ path: decoded, content: splitLines(content) } satisfies FileContentResponse); + }); + + // The UI asks for the base side here, the way the CLI answers it for a ref. + router.get('/file/{*path}', serveFile('old')); + // The rich markdown and SVG preview reads the new side through the tree route. + router.get('/tree/file/{*path}', serveFile('new')); + + router.get('/threads', handle(({ user, session }, req, res) => { + if (!sessionMatches(res, session, req.query.session)) { + return; + } + const status = typeof req.query.status === 'string' && req.query.status ? req.query.status : undefined; + if (status !== undefined && !isThreadStatus(status)) { + fail(res, 400, `status must be one of: ${THREAD_STATUSES.join(', ')}`); + return; + } + res.json(reviews.threadsForSession(user.id, session.id, status)); + })); + + router.post('/threads', handle(({ user, session }, req, res) => { + const body = parsed(res, parseCreateThreadRequest(req.body)); + if (!body || !sessionMatches(res, session, body.sessionId)) { + return; + } + res.json(reviews.createThread({ + userId: user.id, + sessionId: session.id, + filePath: body.filePath, + side: body.side, + startLine: body.startLine, + endLine: body.endLine, + body: body.body, + author: author(user), + anchorContent: body.anchorContent, + kind: body.kind ?? 'review', + })); + })); + + router.delete('/threads', handle(({ user, session }, req, res) => { + const body = parsed(res, parseDeleteThreadsRequest(req.body)); + if (!body || !sessionMatches(res, session, body.sessionId)) { + return; + } + reviews.deleteThreadsForSession(user.id, session.id); + res.json({ ok: true }); + })); + + router.post('/threads/:id/reply', handle((scoped, req, res) => { + const thread = threadInSession(res, scoped, String(req.params.id)); + const body = thread && parsed(res, parseReplyRequest(req.body)); + if (!thread || !body) { + return; + } + res.json(reviews.addReply(scoped.user.id, thread.id, body.body, author(scoped.user), body.kind ?? 'review')); + })); + + router.patch('/threads/:id/status', handle((scoped, req, res) => { + const thread = threadInSession(res, scoped, String(req.params.id)); + const body = thread && parsed(res, parseUpdateThreadStatusRequest(req.body)); + if (!thread || !body) { + return; + } + reviews.updateThreadStatus( + scoped.user.id, + thread.id, + body.status, + body.summary, + body.summary ? { name: 'System', type: 'user' } : undefined, + ); + res.json({ ok: true }); + })); + + router.delete('/threads/:id', handle((scoped, req, res) => { + const thread = threadInSession(res, scoped, String(req.params.id)); + if (!thread) { + return; + } + reviews.deleteThread(scoped.user.id, thread.id); + res.json({ ok: true }); + })); + + router.patch('/comments/:id', handle((scoped, req, res) => { + const comment = commentInSession(res, scoped, String(req.params.id)); + const body = comment && parsed(res, parseEditCommentRequest(req.body)); + if (!comment || !body) { + return; + } + reviews.editComment(scoped.user.id, comment.comment.id, body.body); + res.json({ ok: true }); + })); + + router.delete('/comments/:id', handle((scoped, req, res) => { + const comment = commentInSession(res, scoped, String(req.params.id)); + if (!comment) { + return; + } + reviews.deleteComment(scoped.user.id, comment.comment.id); + res.json({ ok: true }); + })); + + router.get('/tours', handle(({ user, session }, req, res) => { + if (!sessionMatches(res, session, req.query.session)) { + return; + } + res.json(reviews.toursForSession(user.id, session.id)); + })); + + router.get('/tours/:id', handle(({ user, session }, req, res) => { + const tour = reviews.getTour(user.id, String(req.params.id)); + if (!tour || tour.sessionId !== session.id) { + fail(res, 404, 'Tour not found'); + return; + } + res.json(tour); + })); + + router.get('/live/status', handle((_scoped, _req, res) => { + res.json({ + enabled: false, + listening: false, + working: false, + waiting: 0, + mayChangeCode: false, + viewerPresent: false, + } satisfies LiveStatusResponse); + })); + + // Presence only matters to a parked live agent, which this server does not have. + router.post('/viewer', handle((_scoped, _req, res) => { + res.json({ ok: true }); + })); + router.post('/viewer/gone', handle((_scoped, _req, res) => { + res.json({ ok: true }); + })); + + router.get('/github/details', handle(({ session }, _req, res) => { + res.json(gitHubDetailsFor(session)); + })); + + router.post(['/github/create-review', '/github/pull-comments'], handle((_scoped, _req, res) => { + fail(res, 501, 'Posting to GitHub is not available on the hosted server yet'); + })); + + router.use((_req: Request, res: Response) => fail(res, 404, 'Not found')); + return router; +} diff --git a/packages/server/src/users.ts b/packages/server/src/users.ts new file mode 100644 index 00000000..570f313c --- /dev/null +++ b/packages/server/src/users.ts @@ -0,0 +1,116 @@ +import { randomUUID } from 'node:crypto'; +import type { Store } from './db.js'; +import { decrypt, encrypt, randomToken, sha256 } from './crypto.js'; + +export interface UserSettings { + shareReviews: 'private'; +} + +export interface User { + id: string; + email: string; + name: string; + settings: UserSettings; +} + +interface UserRow { + id: string; + email: string; + name: string; + settings: string; +} + +const DEFAULT_SETTINGS: UserSettings = { shareReviews: 'private' }; + +function rowToUser(row: UserRow): User { + let settings: UserSettings = DEFAULT_SETTINGS; + try { + settings = { ...DEFAULT_SETTINGS, ...(JSON.parse(row.settings) as Partial) }; + } catch { + // A hand-edited row falls back to the defaults rather than locking its owner out. + } + return { id: row.id, email: row.email, name: row.name, settings }; +} + +export class Users { + constructor(private readonly store: Store, private readonly secretKey: Buffer) {} + + findOrCreate(email: string, name?: string): User { + const normalised = email.trim().toLowerCase(); + const existing = this.store.get('SELECT * FROM users WHERE email = ?', normalised); + if (existing) { + return rowToUser(existing); + } + const id = randomUUID(); + this.store.run( + 'INSERT INTO users (id, email, name, created_at) VALUES (?, ?, ?, ?)', + id, + normalised, + name?.trim() || normalised, + new Date().toISOString(), + ); + return this.get(id)!; + } + + get(id: string): User | null { + const row = this.store.get('SELECT * FROM users WHERE id = ?', id); + return row ? rowToUser(row) : null; + } + + setGitHubToken(userId: string, token: string | null): void { + this.store.run( + 'UPDATE users SET github_token = ? WHERE id = ?', + token ? encrypt(this.secretKey, token) : null, + userId, + ); + } + + /** Null as well when the token was sealed with a key this process no longer has. */ + gitHubToken(userId: string): string | null { + const row = this.store.get<{ github_token: string | null }>('SELECT github_token FROM users WHERE id = ?', userId); + return row?.github_token ? decrypt(this.secretKey, row.github_token) : null; + } +} + +const WEB_SESSION_DAYS = 30; + +export class WebSessions { + constructor(private readonly store: Store) {} + + /** The cookie value; the table holds only its hash. */ + create(userId: string, now = Date.now()): string { + const token = randomToken(); + this.store.run( + 'INSERT INTO web_sessions (id_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)', + sha256(token), + userId, + new Date(now).toISOString(), + now + WEB_SESSION_DAYS * 24 * 60 * 60 * 1000, + ); + return token; + } + + userFor(token: string | undefined, now = Date.now()): string | null { + if (!token) { + return null; + } + const row = this.store.get<{ user_id: string; expires_at: number }>( + 'SELECT user_id, expires_at FROM web_sessions WHERE id_hash = ?', + sha256(token), + ); + if (!row || row.expires_at < now) { + return null; + } + return row.user_id; + } + + destroy(token: string | undefined): void { + if (token) { + this.store.run('DELETE FROM web_sessions WHERE id_hash = ?', sha256(token)); + } + } + + static maxAgeSeconds(): number { + return WEB_SESSION_DAYS * 24 * 60 * 60; + } +} diff --git a/packages/server/src/web.ts b/packages/server/src/web.ts new file mode 100644 index 00000000..0d54b33b --- /dev/null +++ b/packages/server/src/web.ts @@ -0,0 +1,106 @@ +import type { NextFunction, Request, Response } from 'express'; + +export const SESSION_COOKIE = 'diffity_session'; + +/** + * Repository content is rendered in the review UI, so nothing it contains may reach the network. + * The same policy the CLI serves its UI under. + */ +export const UI_CONTENT_SECURITY_POLICY = [ + "default-src 'self'", + "base-uri 'none'", + "object-src 'none'", + "frame-src 'none'", + "frame-ancestors 'none'", + "form-action 'none'", + "img-src 'self' data:", + "font-src 'self' data:", + "style-src 'self' 'unsafe-inline'", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'", + "connect-src 'self'", +].join('; '); + +/** The server's own pages: no script at all, forms only to this origin unless widened for one. */ +export function pageContentSecurityPolicy(extraFormTargets: string[] = []): string { + return [ + "default-src 'none'", + "base-uri 'none'", + "frame-ancestors 'none'", + `form-action 'self'${extraFormTargets.map(target => ` ${target}`).join('')}`, + "img-src 'self'", + "style-src 'unsafe-inline'", + ].join('; '); +} + +export function parseCookies(header: string | undefined): Record { + const cookies: Record = {}; + for (const part of (header ?? '').split(';')) { + const index = part.indexOf('='); + if (index === -1) { + continue; + } + const name = part.slice(0, index).trim(); + const value = part.slice(index + 1).trim(); + if (!name) { + continue; + } + try { + cookies[name] = decodeURIComponent(value); + } catch { + cookies[name] = value; + } + } + return cookies; +} + +export function sessionCookie(value: string, options: { secure: boolean; maxAgeSeconds: number }): string { + return [ + `${SESSION_COOKIE}=${encodeURIComponent(value)}`, + 'Path=/', + 'HttpOnly', + 'SameSite=Lax', + `Max-Age=${options.maxAgeSeconds}`, + ...(options.secure ? ['Secure'] : []), + ].join('; '); +} + +/** + * A cross-site page cannot forge these, and the UI's own fetches and forms always satisfy them. + * The host comparison covers reaching a localhost server through another loopback name. + */ +export function isSameOriginRequest(req: Request, publicUrl: URL): boolean { + const site = req.headers['sec-fetch-site']; + if (typeof site === 'string' && site !== 'same-origin') { + return false; + } + const origin = req.headers.origin; + if (!origin) { + return true; + } + if (origin === publicUrl.origin) { + return true; + } + try { + return new URL(origin).host === req.headers.host; + } catch { + return false; + } +} + +export function requireSameOrigin(publicUrl: URL) { + return (req: Request, res: Response, next: NextFunction): void => { + if (req.method !== 'GET' && req.method !== 'HEAD' && !isSameOriginRequest(req, publicUrl)) { + res.status(403).json({ error: 'Cross-origin request rejected' }); + return; + } + next(); + }; +} + +/** Only a path on this server: an absolute or protocol-relative URL would be an open redirect. */ +export function safeNext(value: unknown): string { + if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) { + return '/'; + } + return value; +} diff --git a/packages/server/tests/config.test.ts b/packages/server/tests/config.test.ts new file mode 100644 index 00000000..9bdf698e --- /dev/null +++ b/packages/server/tests/config.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { ConfigError, isAllowedEmail, loadConfig } from '../src/config.js'; +import { decrypt, encrypt, randomToken, safeEqual, sha256 } from '../src/crypto.js'; + +const key = randomBytes(32).toString('base64'); + +describe('loadConfig', () => { + it('fills in the localhost defaults', () => { + const config = loadConfig({ DIFFITY_DATA_DIR: '/data' }); + expect(config.publicUrl.href).toBe('http://localhost:5390/'); + expect(config.port).toBe(5390); + expect(config.bindHost).toBe('127.0.0.1'); + expect(config.dataDir).toBe('/data'); + expect(config.allowedDomain).toBe('naturalcycles.com'); + expect(config.allowedEmails).toEqual([]); + expect(config.devLogin).toBe(false); + expect(config.devGitHubToken).toBeNull(); + expect(config.githubApiUrl).toBe('https://api.github.com'); + expect(config.secretKeyGenerated).toBe(true); + expect(config.secretKey).toHaveLength(32); + }); + + it('reads every variable', () => { + const config = loadConfig({ + DIFFITY_DATA_DIR: '/data', + DIFFITY_PUBLIC_URL: 'http://127.0.0.1:8080', + PORT: '8080', + DIFFITY_SECRET_KEY: key, + DIFFITY_DEV_LOGIN: 'true', + DIFFITY_ALLOWED_DOMAIN: 'Example.COM', + DIFFITY_ALLOWED_EMAILS: ' A@x.io, b@y.io ,', + DIFFITY_DEV_GITHUB_TOKEN: 'ghp_x', + GITHUB_API_URL: 'http://127.0.0.1:1/', + }); + expect(config.port).toBe(8080); + expect(config.bindHost).toBe('127.0.0.1'); + expect(config.secretKeyGenerated).toBe(false); + expect(config.secretKey.toString('base64')).toBe(key); + expect(config.devLogin).toBe(true); + expect(config.allowedDomain).toBe('example.com'); + expect(config.allowedEmails).toEqual(['a@x.io', 'b@y.io']); + expect(config.devGitHubToken).toBe('ghp_x'); + expect(config.githubApiUrl).toBe('http://127.0.0.1:1'); + }); + + it('binds every interface for a public URL, or where told to', () => { + expect(loadConfig({ DIFFITY_DATA_DIR: '/d', DIFFITY_PUBLIC_URL: 'https://d.example.com', DIFFITY_SECRET_KEY: key }).bindHost) + .toBe('0.0.0.0'); + expect(loadConfig({ DIFFITY_DATA_DIR: '/d', DIFFITY_BIND: '0.0.0.0' }).bindHost).toBe('0.0.0.0'); + }); + + it.each([ + [{}, 'DIFFITY_DATA_DIR is required'], + [{ DIFFITY_DATA_DIR: '/d', DIFFITY_PUBLIC_URL: 'not a url' }, 'not a URL'], + [{ DIFFITY_DATA_DIR: '/d', DIFFITY_PUBLIC_URL: 'ftp://x' }, 'http or https'], + [{ DIFFITY_DATA_DIR: '/d', DIFFITY_PUBLIC_URL: 'https://x.io/sub' }, 'must be an origin'], + [{ DIFFITY_DATA_DIR: '/d', PORT: 'abc' }, 'PORT must be'], + [{ DIFFITY_DATA_DIR: '/d', DIFFITY_SECRET_KEY: 'c2hvcnQ=' }, '32 bytes'], + [{ DIFFITY_DATA_DIR: '/d', DIFFITY_PUBLIC_URL: 'https://diffity.example.com' }, 'DIFFITY_SECRET_KEY is required'], + [ + { DIFFITY_DATA_DIR: '/d', DIFFITY_PUBLIC_URL: 'https://diffity.example.com', DIFFITY_SECRET_KEY: key, DIFFITY_DEV_LOGIN: '1' }, + 'DIFFITY_DEV_LOGIN is only allowed', + ], + [ + { DIFFITY_DATA_DIR: '/d', DIFFITY_PUBLIC_URL: 'https://diffity.example.com', DIFFITY_SECRET_KEY: key, DIFFITY_DEV_GITHUB_TOKEN: 't' }, + 'DIFFITY_DEV_GITHUB_TOKEN is only allowed', + ], + ])('rejects %j', (env, message) => { + expect(() => loadConfig(env)).toThrow(ConfigError); + expect(() => loadConfig(env)).toThrow(message); + }); +}); + +describe('isAllowedEmail', () => { + it('takes the domain rule when no list is given', () => { + const config = { allowedDomain: 'naturalcycles.com', allowedEmails: [] }; + expect(isAllowedEmail(config, 'Fredrik@NaturalCycles.com')).toBe(true); + expect(isAllowedEmail(config, 'someone@naturalcycles.com.evil.io')).toBe(false); + expect(isAllowedEmail(config, 'someone@evilnaturalcycles.com')).toBe(false); + expect(isAllowedEmail(config, 'not an email')).toBe(false); + }); + + it('lets an explicit list replace the domain', () => { + const config = { allowedDomain: 'naturalcycles.com', allowedEmails: ['guest@x.io'] }; + expect(isAllowedEmail(config, 'guest@x.io')).toBe(true); + expect(isAllowedEmail(config, 'fredrik@naturalcycles.com')).toBe(false); + }); +}); + +describe('crypto', () => { + it('round-trips a sealed value and refuses another key or a tampered one', () => { + const k = randomBytes(32); + const sealed = encrypt(k, 'ghp_secret'); + expect(sealed).not.toContain('ghp_secret'); + expect(decrypt(k, sealed)).toBe('ghp_secret'); + expect(decrypt(randomBytes(32), sealed)).toBeNull(); + const raw = Buffer.from(sealed, 'base64'); + raw[raw.length - 1] ^= 1; + expect(decrypt(k, raw.toString('base64'))).toBeNull(); + }); + + it('makes opaque tokens and stable hashes', () => { + expect(randomToken()).not.toBe(randomToken()); + expect(sha256('a')).toBe(sha256('a')); + expect(sha256('a')).toHaveLength(64); + expect(safeEqual('abc', 'abc')).toBe(true); + expect(safeEqual('abc', 'abd')).toBe(false); + expect(safeEqual('abc', 'abcd')).toBe(false); + }); +}); diff --git a/packages/server/tests/db.test.ts b/packages/server/tests/db.test.ts new file mode 100644 index 00000000..a2cd82f3 --- /dev/null +++ b/packages/server/tests/db.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { join } from 'node:path'; +import { statSync } from 'node:fs'; +import { MIGRATIONS, Store } from '../src/db.js'; +import { removeDir, tempDir } from './helpers.js'; + +let dir: string; + +afterEach(() => removeDir(dir)); + +describe('migrations', () => { + it('creates every table once, and reopening applies nothing twice', () => { + dir = tempDir('db'); + const path = join(dir, 'nested', 'diffity.db'); + const store = new Store(path); + expect(store.schemaVersion()).toBe(MIGRATIONS.at(-1)!.version); + const tables = store.all<{ name: string }>("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").map(r => r.name); + expect(tables).toEqual(expect.arrayContaining([ + 'users', 'web_sessions', 'oauth_clients', 'oauth_codes', 'oauth_tokens', + 'repos', 'sessions', 'threads', 'comments', 'tours', 'tour_steps', 'schema_version', + ])); + store.close(); + + const again = new Store(path); + expect(again.all('SELECT * FROM schema_version')).toHaveLength(MIGRATIONS.length); + again.close(); + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it('applies later migrations in version order, and a failing one leaves nothing behind', () => { + dir = tempDir('db'); + const path = join(dir, 'diffity.db'); + const store = new Store(path, [ + { version: 2, sql: 'ALTER TABLE t ADD COLUMN b TEXT' }, + { version: 1, sql: 'CREATE TABLE t (a TEXT)' }, + ]); + expect(store.schemaVersion()).toBe(2); + store.close(); + + expect(() => new Store(path, [ + { version: 1, sql: 'CREATE TABLE t (a TEXT)' }, + { version: 2, sql: 'ALTER TABLE t ADD COLUMN b TEXT' }, + { version: 3, sql: 'CREATE TABLE u (x TEXT); SELECT * FROM missing_table' }, + ])).toThrow('Migration 3 failed'); + const reopened = new Store(path, []); + expect(reopened.schemaVersion()).toBe(2); + expect(reopened.all("SELECT name FROM sqlite_master WHERE name = 'u'")).toEqual([]); + reopened.close(); + }); + + it('rolls a failed transaction back', () => { + dir = tempDir('db'); + const store = new Store(join(dir, 'diffity.db'), [{ version: 1, sql: 'CREATE TABLE t (a TEXT)' }]); + expect(() => store.transaction(() => { + store.run('INSERT INTO t VALUES (?)', 'x'); + throw new Error('boom'); + })).toThrow('boom'); + expect(store.all('SELECT * FROM t')).toEqual([]); + store.close(); + }); +}); diff --git a/packages/server/tests/git.test.ts b/packages/server/tests/git.test.ts new file mode 100644 index 00000000..d39fe7ee --- /dev/null +++ b/packages/server/tests/git.test.ts @@ -0,0 +1,148 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { isSafeRepoPath, isRepoName, isSha, Mirrors, parseNameStatus, runGit } from '../src/git.js'; +import { git, makeFixture, removeDir, tempDir, type Fixture } from './helpers.js'; + +let fixture: Fixture; +let dataDir: string; +let mirrors: Mirrors; + +beforeAll(() => { + fixture = makeFixture(); + dataDir = tempDir('mirrors'); + mirrors = new Mirrors(dataDir, fixture.remoteUrl); +}); + +afterAll(() => { + removeDir(fixture.root); + removeDir(dataDir); +}); + +describe('mirror', () => { + it('clones blob-less on first use, fetches commits by sha and pins them', async () => { + await mirrors.fetchCommits('acme', 'widgets', null, [fixture.base, fixture.head1]); + const gitDir = mirrors.pathFor('acme', 'widgets'); + expect(existsSync(join(gitDir, 'HEAD'))).toBe(true); + expect(await runGit(['config', 'remote.origin.promisor'], { gitDir })).toContain('true'); + const pinned = await runGit(['for-each-ref', '--format=%(objectname)', 'refs/diffity/keep/'], { gitDir }); + expect(pinned).toContain(fixture.base); + expect(pinned).toContain(fixture.head1); + // Nothing but the mirror is left behind in its directory. + expect(readdirSync(join(dataDir, 'mirrors', 'acme'))).toEqual(['widgets.git']); + }); + + it('never writes a credential into the mirror config', async () => { + await mirrors.fetchCommits('acme', 'widgets', 'secret-token', [fixture.head2]); + const config = await runGit(['config', '--list', '--local'], { gitDir: mirrors.pathFor('acme', 'widgets') }); + expect(config).not.toContain('secret-token'); + expect(config).not.toContain('extraheader'); + }); + + it('fetches a pull request head, following it when it moves', async () => { + expect(await mirrors.fetchPullHead('acme', 'widgets', null, 1)).toBe(fixture.head1); + fixture.pushPull(fixture.head2); + expect(await mirrors.fetchPullHead('acme', 'widgets', null, 1)).toBe(fixture.head2); + fixture.pushPull(fixture.head1); + }); + + it('computes the merge base', async () => { + await mirrors.fetchCommits('acme', 'widgets', null, [fixture.mainTip]); + expect(await mirrors.mergeBase('acme', 'widgets', null, fixture.mainTip, fixture.head1)).toBe(fixture.base); + }); + + it('refuses a sha that is not in the repository, and a short one', async () => { + await expect(mirrors.fetchCommits('acme', 'widgets', null, ['0'.repeat(40)])).rejects.toThrow(); + await expect(mirrors.fetchCommits('acme', 'widgets', null, ['abc123'])).rejects.toThrow('Not a full commit sha'); + }); + + it('refuses a path that would leave the mirrors directory', () => { + expect(() => mirrors.pathFor('..', 'x')).toThrow('Not a repository name'); + expect(() => mirrors.pathFor('acme', 'a/b')).toThrow('Not a repository name'); + }); + + it('leaves no half-cloned mirror when the remote does not exist', async () => { + await expect(mirrors.fetchCommits('acme', 'missing', null, [fixture.base])).rejects.toThrow(); + expect(readdirSync(join(dataDir, 'mirrors', 'acme'))).toEqual(['widgets.git']); + }); +}); + +describe('reading', () => { + it('diffs two commits in the format the parser expects', async () => { + const raw = await mirrors.diff('acme', 'widgets', null, fixture.base, fixture.head1); + expect(raw).toContain('diff --git a/src.ts b/src.ts'); + expect(raw).toContain('+line 10 changed by the feature'); + expect(raw).toContain('b/added.ts'); + }); + + it('limits a diff to one path, taken literally', async () => { + const raw = await mirrors.diff('acme', 'widgets', null, fixture.base, fixture.head1, { path: 'added.ts' }); + expect(raw).toContain('added.ts'); + expect(raw).not.toContain('src.ts'); + expect(await mirrors.diff('acme', 'widgets', null, fixture.base, fixture.head1, { path: '*.ts' })).toBe(''); + }); + + it('names the changed files, renames included', async () => { + const files = await mirrors.nameStatus('acme', 'widgets', null, fixture.base, fixture.head2); + expect(files).toEqual(expect.arrayContaining([ + { status: 'M', oldPath: 'src.ts', newPath: 'src.ts' }, + { status: 'A', oldPath: 'added.ts', newPath: 'added.ts' }, + ])); + const renames = await mirrors.renames('acme', 'widgets', null, fixture.head1, fixture.head2); + expect(renames).toEqual([expect.objectContaining({ oldPath: 'old-name.ts', newPath: 'new-name.ts' })]); + expect(await mirrors.shortStat('acme', 'widgets', null, fixture.base, fixture.head1)).toMatch(/2 files changed/); + }); + + it('reads a file at a revision, and one missing there as null', async () => { + expect(await mirrors.readFile('acme', 'widgets', null, fixture.head1, 'added.ts')).toBe('export const added = 1;\n'); + expect(await mirrors.readFile('acme', 'widgets', null, fixture.base, 'added.ts')).toBeNull(); + expect(await mirrors.readFile('acme', 'widgets', null, fixture.base, '../etc/passwd')).toBeNull(); + }); + + it('reads many files in one call', async () => { + const files = await mirrors.readFiles('acme', 'widgets', null, fixture.head1, ['added.ts', 'nope.ts', 'README.md', '/abs']); + expect(files.get('added.ts')).toBe('export const added = 1;\n'); + expect(files.get('README.md')).toBe('# widgets\n'); + expect(files.get('nope.ts')).toBeNull(); + expect(files.get('/abs')).toBeNull(); + expect((await mirrors.readFiles('acme', 'widgets', null, fixture.head1, [])).size).toBe(0); + }); +}); + +describe('patch sessions', () => { + it('applies a patch to the base in a throwaway index and diffs the tree it makes', async () => { + const patch = git(fixture.work, ['diff', fixture.base, fixture.head1]) + '\n'; + const tree = await mirrors.applyPatch('acme', 'widgets', null, fixture.base, patch); + expect(tree).toBe(git(fixture.work, ['rev-parse', `${fixture.head1}^{tree}`])); + const raw = await mirrors.diff('acme', 'widgets', null, fixture.base, tree); + expect(raw).toContain('+line 10 changed by the feature'); + expect(await mirrors.readFile('acme', 'widgets', null, tree, 'added.ts')).toBe('export const added = 1;\n'); + expect(readdirSync(join(dataDir, 'tmp'))).toEqual([]); + }); + + it('says so when the patch does not apply', async () => { + const bad = 'diff --git a/src.ts b/src.ts\n--- a/src.ts\n+++ b/src.ts\n@@ -1,1 +1,1 @@\n-not there\n+x\n'; + await expect(mirrors.applyPatch('acme', 'widgets', null, fixture.base, bad)).rejects.toThrow('does not apply'); + expect(readdirSync(join(dataDir, 'tmp'))).toEqual([]); + }); +}); + +describe('validators', () => { + it('accepts only full shas, plain names and contained paths', () => { + expect(isSha('a'.repeat(40))).toBe(true); + expect(isSha('A'.repeat(40))).toBe(false); + expect(isRepoName('diffity.js')).toBe(true); + expect(isRepoName('..')).toBe(false); + expect(isSafeRepoPath('src/a.ts')).toBe(true); + for (const bad of ['', '/a', 'a/../b', './a', 'a//b', 'a\\b', 'a\0b']) { + expect(isSafeRepoPath(bad)).toBe(false); + } + }); + + it('parses name-status lines, renames included', () => { + expect(parseNameStatus('M\ta.ts\nR087\told.ts\tnew.ts\n\nbad\n')).toEqual([ + { status: 'M', oldPath: 'a.ts', newPath: 'a.ts' }, + { status: 'R087', oldPath: 'old.ts', newPath: 'new.ts' }, + ]); + }); +}); diff --git a/packages/server/tests/helpers.ts b/packages/server/tests/helpers.ts new file mode 100644 index 00000000..90976421 --- /dev/null +++ b/packages/server/tests/helpers.ts @@ -0,0 +1,309 @@ +import { execFileSync } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { createServer, type Server } from 'node:http'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Config } from '../src/config.js'; +import { startServer, type RunningServer, type ServerOverrides } from '../src/server.js'; + +const GIT_ENV = { + ...process.env, + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', + GIT_AUTHOR_NAME: 'T', + GIT_AUTHOR_EMAIL: 't@t', + GIT_COMMITTER_NAME: 'T', + GIT_COMMITTER_EMAIL: 't@t', +}; + +export function git(cwd: string, args: string[], input?: string): string { + return execFileSync('git', args, { cwd, env: GIT_ENV, input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); +} + +export function tempDir(prefix: string): string { + return mkdtempSync(join(tmpdir(), `diffity-server-${prefix}-`)); +} + +export function removeDir(dir: string): void { + rmSync(dir, { recursive: true, force: true }); +} + +export interface Fixture { + root: string; + remotes: string; + work: string; + /** The commit main was at when feature branched. */ + base: string; + /** main moved on after feature branched, so it is not the merge base. */ + mainTip: string; + /** feature's first commit, pushed as refs/pull/1/head. */ + head1: string; + /** feature's second commit, which moves and renames things. */ + head2: string; + remoteUrl: (owner: string, repo: string) => string; + /** Moves refs/pull/1/head to another commit, as a push to the pull request would. */ + pushPull(sha: string): void; +} + +/** + * A bare "GitHub" remote for acme/widgets, reached over file://, with the upload-pack settings + * GitHub itself has: partial clone filters and fetching by sha. + */ +export function makeFixture(): Fixture { + const root = tempDir('fixture'); + const remotes = join(root, 'remotes'); + const bare = join(remotes, 'acme', 'widgets.git'); + mkdirSync(join(remotes, 'acme'), { recursive: true }); + git(root, ['init', '--bare', '-b', 'main', bare]); + git(bare, ['config', 'uploadpack.allowFilter', 'true']); + git(bare, ['config', 'uploadpack.allowAnySHA1InWant', 'true']); + + const work = join(root, 'work'); + git(root, ['init', '-b', 'main', work]); + const lines = Array.from({ length: 30 }, (_, i) => `line ${i + 1} of the widget`); + writeFileSync(join(work, 'src.ts'), lines.join('\n') + '\n'); + writeFileSync(join(work, 'old-name.ts'), 'export const moved = true;\n'); + writeFileSync(join(work, 'README.md'), '# widgets\n'); + writeFileSync(join(work, 'STANDARDS.md'), 'Every P1 must have a test.\n'); + writeFileSync(join(work, '.diffity.json'), JSON.stringify({ review: { standards: 'STANDARDS.md', severities: ['blocker', 'nit'] } })); + git(work, ['add', '.']); + git(work, ['commit', '-m', 'base']); + const base = git(work, ['rev-parse', 'HEAD']); + + git(work, ['checkout', '-b', 'feature']); + lines[9] = 'line 10 changed by the feature'; + writeFileSync(join(work, 'src.ts'), lines.join('\n') + '\n'); + writeFileSync(join(work, 'added.ts'), 'export const added = 1;\n'); + git(work, ['add', '.']); + git(work, ['commit', '-m', 'feature 1']); + const head1 = git(work, ['rev-parse', 'HEAD']); + + // Three lines inserted above line 10 move it to 13; old-name.ts is renamed. + const moved = [...lines.slice(0, 5), 'inserted a', 'inserted b', 'inserted c', ...lines.slice(5)]; + writeFileSync(join(work, 'src.ts'), moved.join('\n') + '\n'); + git(work, ['mv', 'old-name.ts', 'new-name.ts']); + writeFileSync(join(work, 'added.ts'), 'export const added = 2;\n'); + git(work, ['add', '.']); + git(work, ['commit', '-m', 'feature 2']); + const head2 = git(work, ['rev-parse', 'HEAD']); + + git(work, ['checkout', 'main']); + writeFileSync(join(work, 'README.md'), '# widgets\n\nmain moved on\n'); + git(work, ['commit', '-am', 'main moves on']); + const mainTip = git(work, ['rev-parse', 'HEAD']); + + git(work, ['push', bare, 'main', 'feature']); + git(work, ['push', bare, `${head1}:refs/pull/1/head`]); + + return { + root, + remotes, + work, + base, + mainTip, + head1, + head2, + remoteUrl: (owner, repo) => `file://${join(remotes, owner.toLowerCase(), `${repo.toLowerCase()}.git`)}`, + pushPull: sha => git(work, ['push', '--force', bare, `${sha}:refs/pull/1/head`]), + }; +} + +export interface FakeRepo { + owner: string; + name: string; + private: boolean; + /** Tokens that may read it; a public repository is readable with none. */ + tokens: string[]; + pulls: Record; +} + +export interface FakeGitHub { + url: string; + requests: string[]; + close(): Promise; +} + +/** `GET /repos/{o}/{r}` and `GET /repos/{o}/{r}/pulls/{n}`, answering 404 to anyone not allowed. */ +export async function startFakeGitHub(repos: FakeRepo[]): Promise { + const requests: string[] = []; + const server: Server = createServer((req, res) => { + requests.push(`${req.method} ${req.url}`); + const token = /^Bearer (.+)$/.exec(req.headers.authorization ?? '')?.[1] ?? null; + const match = /^\/repos\/([^/]+)\/([^/]+)(?:\/pulls\/(\d+))?$/.exec(req.url ?? ''); + const repo = match + ? repos.find(r => r.owner.toLowerCase() === match[1].toLowerCase() && r.name.toLowerCase() === match[2].toLowerCase()) + : undefined; + const allowed = repo && (!repo.private || (token !== null && repo.tokens.includes(token))); + const send = (status: number, body: unknown) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + if (!match || !repo || !allowed) { + send(404, { message: 'Not Found' }); + return; + } + if (!match[3]) { + send(200, { name: repo.name, private: repo.private, owner: { login: repo.owner } }); + return; + } + const pull = repo.pulls[Number(match[3])]; + if (!pull) { + send(404, { message: 'Not Found' }); + return; + } + send(200, { + number: Number(match[3]), + title: pull.title, + html_url: `https://github.com/${repo.owner}/${repo.name}/pull/${match[3]}`, + created_at: '2026-09-01T10:00:00Z', + body: 'What the change is for', + state: 'open', + user: { login: pull.author ?? 'octocat' }, + base: { sha: pull.baseSha, ref: 'main' }, + head: { sha: pull.headSha, ref: 'feature' }, + }); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return { + url: `http://127.0.0.1:${port}`, + requests, + close: () => new Promise(resolve => server.close(() => resolve())), + }; +} + +export function testConfig(dataDir: string, githubApiUrl: string, overrides: Partial = {}): Config { + return { + publicUrl: new URL('http://localhost:5390'), + port: 0, + bindHost: '127.0.0.1', + dataDir, + secretKey: randomBytes(32), + secretKeyGenerated: false, + devLogin: true, + allowedDomain: 'example.com', + allowedEmails: [], + devGitHubToken: null, + githubApiUrl, + ...overrides, + }; +} + +/** A UI build of one line: enough to see the base injected and the page served. */ +export function fakeUiDir(root: string): string { + const dir = join(root, 'ui'); + mkdirSync(join(dir, 'assets'), { recursive: true }); + writeFileSync(join(dir, 'index.html'), 'diffity'); + writeFileSync(join(dir, 'assets', 'app.js'), 'console.log(1);'); + writeFileSync(join(dir, 'favicon.svg'), ''); + return dir; +} + +export interface TestServer extends RunningServer { + base: string; + dataDir: string; + config: Config; +} + +export async function startTestServer( + dataDir: string, + githubApiUrl: string, + overrides: ServerOverrides = {}, + configOverrides: Partial = {}, +): Promise { + const port = await freePort(); + const config = testConfig(dataDir, githubApiUrl, { publicUrl: new URL(`http://127.0.0.1:${port}`), ...configOverrides }); + const running = await startServer(config, { rateLimit: false, ...overrides }, { port, host: '127.0.0.1' }); + return { ...running, base: `http://127.0.0.1:${running.port}`, dataDir, config }; +} + +/** The public URL has to name the port before the server listens on it. */ +function freePort(): Promise { + return new Promise((resolve, reject) => { + const probe = createServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const address = probe.address(); + const port = typeof address === 'object' && address ? address.port : 0; + probe.close(() => resolve(port)); + }); + }); +} + +/** Signs in through the dev login form and answers the session cookie. */ +export async function login(base: string, email: string): Promise { + const res = await fetch(`${base}/login`, { + method: 'POST', + redirect: 'manual', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: base, 'Sec-Fetch-Site': 'same-origin' }, + body: new URLSearchParams({ email, next: '/' }).toString(), + }); + const cookie = res.headers.get('set-cookie'); + if (res.status !== 303 || !cookie) { + throw new Error(`login failed: ${res.status} ${await res.text()}`); + } + return cookie.split(';')[0]; +} + +export function pkcePair(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString('base64url'); + return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') }; +} + +/** + * The whole OAuth dance a connecting agent performs: register, authorize with PKCE while signed + * in, consent, and trade the code for tokens. + */ +export async function connectAgent( + base: string, + cookie: string, + clientName = 'Test Agent', +): Promise<{ clientId: string; accessToken: string; refreshToken: string; verifier: string }> { + const redirectUri = 'http://127.0.0.1:9/callback'; + const registered = await fetch(`${base}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_name: clientName, redirect_uris: [redirectUri], token_endpoint_auth_method: 'none' }), + }); + const client = (await registered.json()) as { client_id: string }; + const { verifier, challenge } = pkcePair(); + const authorize = await fetch( + `${base}/authorize?${new URLSearchParams({ + client_id: client.client_id, + redirect_uri: redirectUri, + response_type: 'code', + code_challenge: challenge, + code_challenge_method: 'S256', + state: 'xyz', + })}`, + { headers: { cookie }, redirect: 'manual' }, + ); + const html = await authorize.text(); + const request = /name="request" value="([^"]+)"/.exec(html)?.[1]; + if (!request) { + throw new Error(`no consent form: ${authorize.status} ${html.slice(0, 200)}`); + } + const consent = await fetch(`${base}/oauth/consent`, { + method: 'POST', + redirect: 'manual', + headers: { cookie, 'Content-Type': 'application/x-www-form-urlencoded', Origin: base, 'Sec-Fetch-Site': 'same-origin' }, + body: new URLSearchParams({ request, decision: 'allow' }).toString(), + }); + const location = new URL(consent.headers.get('location')!); + const code = location.searchParams.get('code')!; + const token = await fetch(`${base}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + code_verifier: verifier, + client_id: client.client_id, + redirect_uri: redirectUri, + }).toString(), + }); + const tokens = (await token.json()) as { access_token: string; refresh_token: string }; + return { clientId: client.client_id, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, verifier }; +} diff --git a/packages/server/tests/http.test.ts b/packages/server/tests/http.test.ts new file mode 100644 index 00000000..830e57f4 --- /dev/null +++ b/packages/server/tests/http.test.ts @@ -0,0 +1,346 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { CommentThread, RepoInfoResponse, Tour } from '@diffity/api'; +import type { ReviewSessionRecord } from '../src/reviews.js'; +import { + fakeUiDir, + login, + makeFixture, + removeDir, + startFakeGitHub, + startTestServer, + tempDir, + type FakeGitHub, + type Fixture, + type TestServer, +} from './helpers.js'; + +let fixture: Fixture; +let github: FakeGitHub; +let dataDir: string; +let server: TestServer; +let aliceCookie: string; +let bobCookie: string; +let aliceId: string; +let bobId: string; +let session: ReviewSessionRecord; +let prSession: ReviewSessionRecord; +let bobSession: ReviewSessionRecord; + +async function api( + path: string, + init: RequestInit & { cookie?: string; json?: unknown } = {}, +): Promise<{ status: number; body: any; headers: Headers }> { + const headers = new Headers(init.headers); + if (init.cookie) { + headers.set('cookie', init.cookie); + } + if (init.json !== undefined) { + headers.set('Content-Type', 'application/json'); + } + if (init.method && init.method !== 'GET' && !headers.has('Sec-Fetch-Site')) { + headers.set('Sec-Fetch-Site', 'same-origin'); + } + const res = await fetch(`${server.base}${path}`, { + ...init, + headers, + redirect: 'manual', + body: init.json !== undefined ? JSON.stringify(init.json) : init.body, + }); + const text = await res.text(); + let body: unknown = text; + try { + body = JSON.parse(text); + } catch { + // An HTML page or a plain answer. + } + return { status: res.status, body, headers: res.headers }; +} + +beforeAll(async () => { + fixture = makeFixture(); + github = await startFakeGitHub([ + { + owner: 'acme', + name: 'widgets', + private: false, + tokens: [], + pulls: { 1: { title: 'Change line ten', baseSha: fixture.mainTip, headSha: fixture.head1 } }, + }, + ]); + dataDir = tempDir('http'); + server = await startTestServer(dataDir, github.url, { remoteUrl: fixture.remoteUrl, uiDir: fakeUiDir(dataDir) }); + aliceCookie = await login(server.base, 'alice@example.com'); + bobCookie = await login(server.base, 'bob@example.com'); + aliceId = server.users.findOrCreate('alice@example.com').id; + bobId = server.users.findOrCreate('bob@example.com').id; + session = (await server.service.createSession(aliceId, { repo: 'acme/widgets', base: fixture.base, head: fixture.head2 })).session; + prSession = (await server.service.createSession(aliceId, { repo: 'acme/widgets', pr: 1 })).session; + bobSession = (await server.service.createSession(bobId, { repo: 'acme/widgets', base: fixture.base, head: fixture.head1 })).session; +}); + +afterAll(async () => { + await server.close(); + await github.close(); + removeDir(fixture.root); + removeDir(dataDir); +}); + +describe('web pages', () => { + it('answers the health check without signing in', async () => { + expect(await api('/healthz')).toMatchObject({ status: 200, body: { ok: true } }); + }); + + it('sends a visitor to sign in, and back to where they were going', async () => { + const res = await api('/'); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe('/login?next=%2F'); + const form = await api('/login?next=/settings'); + expect(form.body).toContain('name="next" value="/settings"'); + expect(form.headers.get('content-security-policy')).toContain("form-action 'self'"); + }); + + it('refuses an email outside the allowed domain, a cross-site login, and an open redirect', async () => { + const post = (email: string, next: string, site = 'same-origin') => fetch(`${server.base}/login`, { + method: 'POST', + redirect: 'manual', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Sec-Fetch-Site': site }, + body: new URLSearchParams({ email, next }).toString(), + }); + expect((await post('eve@evil.io', '/')).status).toBe(403); + expect((await post('alice@example.com', '/', 'cross-site')).status).toBe(403); + const redirected = await post('alice@example.com', '//evil.io/steal'); + expect(redirected.status).toBe(303); + expect(redirected.headers.get('location')).toBe('/'); + const cookie = redirected.headers.get('set-cookie')!; + expect(cookie).toContain('HttpOnly'); + expect(cookie).toContain('SameSite=Lax'); + expect(cookie).not.toContain('Secure'); + }); + + it('lists the signed-in user’s sessions only', async () => { + const page = await api('/', { cookie: aliceCookie }); + expect(page.body).toContain(`/s/${session.id}/`); + expect(page.body).toContain('#1 Change line ten'); + expect(page.body).not.toContain(bobSession.id); + }); + + it('keeps a pasted GitHub token encrypted, and removes it on request', async () => { + expect((await api('/settings', { cookie: aliceCookie })).body).toContain('No token set'); + const save = await api('/settings/github-token', { + method: 'POST', + cookie: aliceCookie, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'token=ghp_pasted', + }); + expect(save.status).toBe(303); + expect(server.users.gitHubToken(aliceId)).toBe('ghp_pasted'); + expect(JSON.stringify(server.store.all('SELECT github_token FROM users'))).not.toContain('ghp_pasted'); + expect((await api('/settings', { cookie: aliceCookie })).body).toContain('Your own token is set'); + await api('/settings/github-token', { + method: 'POST', + cookie: aliceCookie, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'clear=1', + }); + expect(server.users.gitHubToken(aliceId)).toBeNull(); + }); + + it('signs out', async () => { + const cookie = await login(server.base, 'carol@example.com'); + expect((await api('/', { cookie })).status).toBe(200); + const out = await api('/logout', { method: 'POST', cookie }); + expect(out.status).toBe(303); + expect((await api('/', { cookie })).status).toBe(302); + }); +}); + +describe('the review page', () => { + it('serves the UI with its base injected, to its owner only', async () => { + const page = await api(`/s/${session.id}/diff?ref=work`, { cookie: aliceCookie }); + expect(page.status).toBe(200); + expect(page.body).toContain(``); + expect(page.headers.get('content-security-policy')).toContain("connect-src 'self'"); + expect((await api(`/s/${session.id}`, { cookie: aliceCookie })).status).toBe(200); + + expect((await api(`/s/${session.id}/`, { cookie: bobCookie })).status).toBe(404); + expect((await api(`/s/${session.id.slice(0, 8)}/`, { cookie: aliceCookie })).status).toBe(404); + const anonymous = await api(`/s/${session.id}/`); + expect(anonymous.status).toBe(302); + expect(anonymous.headers.get('location')).toBe(`/login?next=${encodeURIComponent(`/s/${session.id}/`)}`); + }); + + it('serves the build’s assets to anyone', async () => { + expect((await api('/assets/app.js')).status).toBe(200); + expect((await api('/favicon.svg')).status).toBe(200); + }); +}); + +describe('the UI API', () => { + const base = () => `/s/${session.id}/api`; + + it('needs a signed-in owner', async () => { + expect((await api(`${base()}/info`)).status).toBe(401); + expect((await api(`${base()}/info`, { cookie: bobCookie })).status).toBe(404); + expect((await api(`${base()}/threads?session=${session.id}`, { cookie: bobCookie })).status).toBe(404); + expect((await api(`${base()}/diff`, { cookie: bobCookie })).status).toBe(404); + }); + + it('describes the session as a hosted review of fixed commits', async () => { + const info = (await api(`${base()}/info`, { cookie: aliceCookie })).body as RepoInfoResponse; + expect(info).toMatchObject({ + name: 'acme/widgets', + sessionId: session.id, + capabilities: { reviews: true, revert: false, staleness: false }, + github: null, + editor: null, + hosted: true, + }); + const pr = (await api(`/s/${prSession.id}/api/info`, { cookie: aliceCookie })).body as RepoInfoResponse; + expect(pr).toMatchObject({ github: { owner: 'acme', repo: 'widgets' }, branch: 'feature', description: '#1 Change line ten' }); + }); + + it('answers the diff, one file of it, and a fingerprint that never moves', async () => { + const diff = await api(`${base()}/diff?ref=work`, { cookie: aliceCookie }); + expect(diff.body.files.map((f: { newPath: string }) => f.newPath).sort()).toEqual(['added.ts', 'new-name.ts', 'src.ts']); + expect(diff.body.suppressed).toBeNull(); + expect((await api(`${base()}/diff?whitespace=hide`, { cookie: aliceCookie })).body.suppressed).toEqual({ files: 0, lines: 0 }); + const one = await api(`${base()}/diff/file?path=added.ts`, { cookie: aliceCookie }); + expect(one.body.file.newPath).toBe('added.ts'); + expect((await api(`${base()}/diff/file?path=README.md`, { cookie: aliceCookie })).body).toEqual({ file: null }); + expect((await api(`${base()}/diff/file`, { cookie: aliceCookie })).status).toBe(400); + const a = (await api(`${base()}/diff-fingerprint`, { cookie: aliceCookie })).body; + const b = (await api(`${base()}/diff-fingerprint`, { cookie: aliceCookie })).body; + expect(a).toEqual(b); + }); + + it('answers file content at the base, and at the head through the tree route', async () => { + const old = await api(`${base()}/file/src.ts?ref=work`, { cookie: aliceCookie }); + expect(old.body.content[9]).toBe('line 10 of the widget'); + expect(old.body.content).toHaveLength(30); + expect((await api(`${base()}/file/added.ts`, { cookie: aliceCookie })).status).toBe(404); + expect((await api(`${base()}/file/added.ts?side=new`, { cookie: aliceCookie })).body.content).toEqual(['export const added = 2;']); + expect((await api(`${base()}/tree/file/${encodeURIComponent('src.ts')}`, { cookie: aliceCookie })).body.content[12]) + .toBe('line 10 changed by the feature'); + expect((await api(`${base()}/file/..%2F..%2Fetc%2Fpasswd`, { cookie: aliceCookie })).status).toBe(404); + }); + + it('keeps comments as the UI expects them, authored by the signed-in user', async () => { + const created = await api(`${base()}/threads`, { + method: 'POST', + cookie: aliceCookie, + json: { + sessionId: session.id, filePath: 'src.ts', side: 'new', startLine: 13, endLine: 13, + body: 'Looks odd', author: { name: 'Mallory', type: 'agent' }, anchorContent: 'line 10 changed by the feature', + }, + }); + expect(created.status).toBe(200); + const thread = created.body as CommentThread; + expect(thread.comments[0].author).toEqual({ name: 'alice', type: 'user' }); + expect(thread.comments[0].liveRequestedAt).toBeNull(); + + const reply = await api(`${base()}/threads/${thread.id}/reply`, { + method: 'POST', cookie: aliceCookie, json: { body: 'More', author: { name: 'x', type: 'user' }, kind: 'aside' }, + }); + expect(reply.body).toMatchObject({ body: 'More', kind: 'aside', author: { name: 'alice', type: 'user' } }); + + expect((await api(`${base()}/threads/${thread.id}/status`, { + method: 'PATCH', cookie: aliceCookie, json: { status: 'resolved', summary: 'Done' }, + })).status).toBe(200); + expect((await api(`${base()}/threads?session=${session.id}&status=resolved`, { cookie: aliceCookie })).body).toHaveLength(1); + expect((await api(`${base()}/threads?status=bogus`, { cookie: aliceCookie })).status).toBe(400); + + const commentId = thread.comments[0].id; + expect((await api(`${base()}/comments/${commentId}`, { method: 'PATCH', cookie: aliceCookie, json: { body: 'Edited' } })).status).toBe(200); + const listed = (await api(`${base()}/threads?session=${session.id}`, { cookie: aliceCookie })).body as CommentThread[]; + expect(listed[0].comments.map(c => c.body)).toEqual(['Edited', 'More', 'Done']); + expect(listed[0].comments[2].author).toEqual({ name: 'System', type: 'user' }); + + expect((await api(`${base()}/comments/${commentId}`, { method: 'DELETE', cookie: aliceCookie })).status).toBe(200); + expect((await api(`${base()}/threads/${thread.id}`, { method: 'DELETE', cookie: aliceCookie })).status).toBe(200); + expect((await api(`${base()}/threads?session=${session.id}`, { cookie: aliceCookie })).body).toEqual([]); + }); + + it('refuses a body that names another session, a malformed one, and invalid JSON', async () => { + const wrong = await api(`${base()}/threads`, { + method: 'POST', cookie: aliceCookie, + json: { sessionId: prSession.id, filePath: 'a', side: 'new', startLine: 1, endLine: 1, body: 'x', author: { name: 'a', type: 'user' } }, + }); + expect(wrong.status).toBe(400); + expect((await api(`${base()}/threads?session=${prSession.id}`, { cookie: aliceCookie })).status).toBe(400); + const malformed = await api(`${base()}/threads`, { method: 'POST', cookie: aliceCookie, json: { sessionId: session.id } }); + expect(malformed.status).toBe(400); + expect(malformed.body.error).toContain('filePath'); + const invalid = await api(`${base()}/threads`, { + method: 'POST', cookie: aliceCookie, headers: { 'Content-Type': 'application/json' }, body: '{nope', + }); + expect(invalid.status).toBe(400); + expect(invalid.body.error).toBe('Request body must be valid JSON'); + }); + + it('deletes every thread of the session and nothing else', async () => { + const create = (sid: string) => api(`/s/${sid}/api/threads`, { + method: 'POST', cookie: aliceCookie, + json: { sessionId: sid, filePath: 'src.ts', side: 'new', startLine: 1, endLine: 1, body: 'x', author: { name: 'a', type: 'user' } }, + }); + await create(session.id); + await create(prSession.id); + expect((await api(`${base()}/threads`, { method: 'DELETE', cookie: aliceCookie, json: { sessionId: session.id } })).status).toBe(200); + expect((await api(`${base()}/threads?session=${session.id}`, { cookie: aliceCookie })).body).toEqual([]); + expect((await api(`/s/${prSession.id}/api/threads?session=${prSession.id}`, { cookie: aliceCookie })).body).toHaveLength(1); + }); + + it('keeps a thread, comment or tour of one session out of another’s URL', async () => { + const thread = server.service.reviews.createThread({ + userId: aliceId, sessionId: prSession.id, filePath: 'src.ts', side: 'new', startLine: 1, endLine: 1, + body: 'In the PR session', author: { name: 'a', type: 'agent' }, + }); + const tour = server.service.reviews.createTour(aliceId, prSession.id, 'Order', ''); + expect((await api(`${base()}/threads/${thread.id}/reply`, { + method: 'POST', cookie: aliceCookie, json: { body: 'x', author: { name: 'a', type: 'user' } }, + })).status).toBe(404); + expect((await api(`${base()}/threads/${thread.id}/status`, { method: 'PATCH', cookie: aliceCookie, json: { status: 'dismissed' } })).status).toBe(404); + expect((await api(`${base()}/threads/${thread.id}`, { method: 'DELETE', cookie: aliceCookie })).status).toBe(404); + expect((await api(`${base()}/comments/${thread.comments[0].id}`, { method: 'PATCH', cookie: aliceCookie, json: { body: 'x' } })).status).toBe(404); + expect((await api(`${base()}/comments/${thread.comments[0].id}`, { method: 'DELETE', cookie: aliceCookie })).status).toBe(404); + expect((await api(`${base()}/tours/${tour.id}`, { cookie: aliceCookie })).status).toBe(404); + // And bob, through his own session, reaches none of it either. + const bobBase = `/s/${bobSession.id}/api`; + expect((await api(`${bobBase}/threads/${thread.id}`, { method: 'DELETE', cookie: bobCookie })).status).toBe(404); + expect((await api(`${bobBase}/tours/${tour.id}`, { cookie: bobCookie })).status).toBe(404); + expect(server.service.reviews.getThread(aliceId, thread.id)?.status).toBe('open'); + }); + + it('lists tours and serves one', async () => { + const tour = server.service.reviews.createTour(aliceId, session.id, 'Reading order', 'why'); + server.service.reviews.addTourStep(aliceId, tour.id, { filePath: 'src.ts', startLine: 10, endLine: 10, body: 'b', annotation: 'a' }); + const listed = (await api(`${base()}/tours?session=${session.id}`, { cookie: aliceCookie })).body as Tour[]; + expect(listed.map(t => t.topic)).toEqual(['Reading order']); + expect(((await api(`${base()}/tours/${tour.id}`, { cookie: aliceCookie })).body as Tour).steps).toHaveLength(1); + }); + + it('answers the live, presence and GitHub routes the page polls', async () => { + expect((await api(`${base()}/live/status?ref=work`, { cookie: aliceCookie })).body).toMatchObject({ enabled: false, listening: false }); + expect((await api(`${base()}/viewer`, { method: 'POST', cookie: aliceCookie })).body).toEqual({ ok: true }); + expect((await api(`${base()}/viewer/gone`, { method: 'POST', cookie: aliceCookie })).body).toEqual({ ok: true }); + expect((await api(`${base()}/github/details`, { cookie: aliceCookie })).body).toBeNull(); + expect((await api(`/s/${prSession.id}/api/github/details`, { cookie: aliceCookie })).body).toMatchObject({ + prNumber: 1, prTitle: 'Change line ten', headSha: fixture.head1, + }); + expect((await api(`${base()}/github/create-review`, { method: 'POST', cookie: aliceCookie, json: {} })).status).toBe(501); + expect((await api(`${base()}/revert-file`, { method: 'POST', cookie: aliceCookie, json: {} })).status).toBe(404); + }); + + it('refuses a write from another site', async () => { + const forged = (headers: Record) => api(`${base()}/threads`, { + method: 'POST', cookie: aliceCookie, headers, + json: { sessionId: session.id, filePath: 'src.ts', side: 'new', startLine: 1, endLine: 1, body: 'x', author: { name: 'a', type: 'user' } }, + }); + expect((await forged({ 'Sec-Fetch-Site': 'cross-site' })).status).toBe(403); + expect((await forged({ 'Sec-Fetch-Site': 'same-origin', Origin: 'https://evil.example' })).status).toBe(403); + expect((await forged({ 'Sec-Fetch-Site': 'same-origin', Origin: server.base })).status).toBe(200); + }); + + it('answers anything else with a JSON 404', async () => { + expect(await api('/nope')).toMatchObject({ status: 404, body: { error: 'Not found' } }); + }); +}); diff --git a/packages/server/tests/mcp.test.ts b/packages/server/tests/mcp.test.ts new file mode 100644 index 00000000..4429a956 --- /dev/null +++ b/packages/server/tests/mcp.test.ts @@ -0,0 +1,233 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + connectAgent, + fakeUiDir, + git, + login, + makeFixture, + removeDir, + startFakeGitHub, + startTestServer, + tempDir, + type FakeGitHub, + type Fixture, + type TestServer, +} from './helpers.js'; + +let fixture: Fixture; +let github: FakeGitHub; +let dataDir: string; +let server: TestServer; +let alice: Client; +let bob: Client; + +async function mcpClient(accessToken: string): Promise { + const transport = new StreamableHTTPClientTransport(new URL(`${server.base}/mcp`), { + requestInit: { headers: { Authorization: `Bearer ${accessToken}` } }, + }); + const client = new Client({ name: 'test', version: '1.0.0' }); + await client.connect(transport); + return client; +} + +interface ToolResult { + isError?: boolean; + content: { type: string; text: string }[]; +} + +async function call(client: Client, name: string, args: Record): Promise { + return (await client.callTool({ name, arguments: args })) as ToolResult; +} + +async function ok>(client: Client, name: string, args: Record): Promise { + const result = await call(client, name, args); + if (result.isError) { + throw new Error(`${name} failed: ${result.content[0]?.text}`); + } + const textContent = result.content[0]?.text ?? ''; + try { + return JSON.parse(textContent) as T; + } catch { + return textContent as T; + } +} + +async function failed(client: Client, name: string, args: Record): Promise { + const result = await call(client, name, args); + expect(result.isError).toBe(true); + return result.content[0].text; +} + +beforeAll(async () => { + fixture = makeFixture(); + github = await startFakeGitHub([ + { + owner: 'acme', + name: 'widgets', + private: true, + tokens: ['alice-token', 'bob-token'], + pulls: { 1: { title: 'Change line ten', baseSha: fixture.mainTip, headSha: fixture.head1 } }, + }, + ]); + dataDir = tempDir('mcp'); + server = await startTestServer(dataDir, github.url, { remoteUrl: fixture.remoteUrl, uiDir: fakeUiDir(dataDir) }); + const aliceCookie = await login(server.base, 'alice@example.com'); + const bobCookie = await login(server.base, 'bob@example.com'); + server.users.setGitHubToken(server.users.findOrCreate('alice@example.com').id, 'alice-token'); + server.users.setGitHubToken(server.users.findOrCreate('bob@example.com').id, 'bob-token'); + alice = await mcpClient((await connectAgent(server.base, aliceCookie, 'Test Agent')).accessToken); + bob = await mcpClient((await connectAgent(server.base, bobCookie)).accessToken); +}); + +afterAll(async () => { + await alice?.close(); + await bob?.close(); + await server.close(); + await github.close(); + removeDir(fixture.root); + removeDir(dataDir); +}); + +describe('authentication', () => { + it('answers an unauthenticated request with 401 and where to find the authorization server', async () => { + const res = await fetch(`${server.base}/mcp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }); + expect(res.status).toBe(401); + expect(res.headers.get('www-authenticate')).toContain( + `resource_metadata="${server.base}/.well-known/oauth-protected-resource/mcp"`, + ); + }); + + it('refuses a made-up token', async () => { + const res = await fetch(`${server.base}/mcp`, { + method: 'POST', + headers: { Authorization: 'Bearer nope', 'Content-Type': 'application/json' }, + body: '{}', + }); + expect(res.status).toBe(401); + }); + + it('is stateless, so only POST is served', async () => { + const res = await fetch(`${server.base}/mcp`, { headers: { Authorization: 'Bearer nope' } }); + expect(res.status).toBe(401); + }); +}); + +describe('a review through the tools', () => { + it('lists every tool', async () => { + const { tools } = await alice.listTools(); + expect(tools.map(tool => tool.name).sort()).toEqual([ + 'amend', 'comment', 'create_session', 'dismiss', 'general_comment', 'get_diff', 'get_file', 'get_standards', + 'list_comments', 'list_sessions', 'reply', 'resolve', 'review_done', 'review_start', 'tour_delete', 'tour_done', + 'tour_start', 'tour_step', + ]); + }); + + it('runs create_session → comment → list_comments → tour → review_done on two commits', async () => { + const created = await ok<{ session: string; url: string; files: { path: string }[]; base: string; head: string }>( + alice, 'create_session', { repo: 'acme/widgets', base: fixture.base, head: fixture.head1 }, + ); + expect(created.url).toBe(`${server.base}/s/${created.session}/`); + expect(created.files.map(f => f.path).sort()).toEqual(['added.ts', 'src.ts']); + const session = created.session.slice(0, 8); + + await ok(alice, 'review_start', { session, note: 'first pass' }); + expect(await ok(alice, 'get_diff', { session, file: 'added.ts' })).toContain('+export const added = 1;'); + expect(await ok(alice, 'get_file', { session, path: 'added.ts' })).toBe('export const added = 1;\n'); + expect(await failed(alice, 'get_file', { session, path: 'added.ts', side: 'old' })).toContain('does not exist on the old side'); + expect(await ok(alice, 'get_standards', { session })).toMatchObject({ severities: ['blocker', 'nit'] }); + + const finding = await ok<{ thread: string }>(alice, 'comment', { + session, file: 'src.ts', line: 10, body: 'P1: why change line ten?', + }); + const clamped = await ok<{ thread: string; startLine: number; warning?: string }>(alice, 'comment', { + session, file: 'added.ts', line: 5, endLine: 9, body: 'Past the end', + }); + expect(clamped).toMatchObject({ startLine: 1 }); + expect(clamped.warning).toContain('fewer lines'); + expect(await failed(alice, 'comment', { session, file: 'README.md', line: 1, body: 'x' })).toContain('not on the new side'); + expect(await failed(alice, 'comment', { session, file: 'src.ts', line: 5, endLine: 4, body: 'x' })).toContain('endLine'); + await ok(alice, 'general_comment', { session, body: 'One finding.' }); + + await ok(alice, 'reply', { id: finding.thread.slice(0, 8), body: 'An aside', aside: true, session }); + await ok(alice, 'amend', { id: finding.thread, body: 'P1: line ten changed without a test' }); + await ok(alice, 'dismiss', { id: clamped.thread, reason: 'noise' }); + + const threads = await ok<{ id: string; file: string | null; startLine: number; comments: { body: string; author: { name: string; type: string } }[] }[]>( + alice, 'list_comments', { session, status: 'open' }, + ); + const onTen = threads.find(t => t.id === finding.thread)!; + expect(onTen.comments.map(c => c.body)).toEqual(['P1: line ten changed without a test', 'An aside']); + expect(onTen.comments[0].author).toEqual({ name: 'Test Agent', type: 'agent' }); + expect(threads.some(t => t.file === null)).toBe(true); + expect(threads.some(t => t.id === clamped.thread)).toBe(false); + + const tour = await ok<{ tour: string }>(alice, 'tour_start', { session, topic: 'Reading order' }); + await ok(alice, 'tour_step', { tour: tour.tour, file: 'src.ts', line: 10, body: 'The change', annotation: 'the change' }); + expect(await failed(alice, 'tour_step', { tour: tour.tour, file: 'missing.ts', line: 1, body: 'x' })).toContain('does not exist'); + await ok(alice, 'tour_done', { tour: tour.tour }); + await ok(alice, 'review_done', { session }); + + const listed = await ok<{ session: string }[]>(alice, 'list_sessions', { repo: 'acme/widgets' }); + expect(listed.map(s => s.session)).toContain(created.session); + + await ok(alice, 'resolve', { id: finding.thread, summary: 'Added a test' }); + expect(await ok(alice, 'list_comments', { session, status: 'resolved' })).toHaveLength(1); + }); + + it('reviews a patch that was never pushed', async () => { + const patch = git(fixture.work, ['diff', fixture.base, fixture.head2]) + '\n'; + const created = await ok<{ session: string; kind: string; files: { path: string }[] }>( + alice, 'create_session', { repo: 'acme/widgets', base: fixture.base, patch }, + ); + expect(created.kind).toBe('patch'); + expect(created.files.map(f => f.path)).toContain('new-name.ts'); + const finding = await ok<{ thread: string }>(alice, 'comment', { session: created.session, file: 'src.ts', line: 13, body: 'x' }); + expect(finding.thread).toBeTruthy(); + expect(await failed(alice, 'create_session', { repo: 'acme/widgets', base: fixture.base, patch: 'not a patch\n' })).toMatch(/patch/i); + }); + + it('reviews a pull request', async () => { + const created = await ok<{ pr: number; base: string; head: string; title: string }>(alice, 'create_session', { repo: 'acme/widgets', pr: 1 }); + expect(created).toMatchObject({ pr: 1, base: fixture.base, head: fixture.head1, title: 'Change line ten' }); + }); + + it('turns every mistake into a readable error result', async () => { + expect(await failed(alice, 'create_session', { repo: 'acme/widgets' })).toContain('exactly one of'); + expect(await failed(alice, 'create_session', { repo: 'acme/nothing', pr: 1 })).toContain('cannot read it'); + expect(await failed(alice, 'get_diff', { session: 'deadbeef' })).toContain('No session matches'); + expect(await failed(alice, 'reply', { id: 'deadbeef', body: 'x' })).toContain('No thread matches'); + expect(await failed(alice, 'amend', { id: 'deadbeef', body: 'x' })).toContain('No comment or thread'); + expect(await failed(alice, 'tour_done', { tour: 'deadbeef' })).toContain('No walkthrough'); + }); +}); + +describe('isolation', () => { + it('gives another user nothing of this user’s sessions, threads or tours', async () => { + const created = await ok<{ session: string }>(alice, 'create_session', { repo: 'acme/widgets', base: fixture.base, head: fixture.head2 }); + const finding = await ok<{ thread: string }>(alice, 'comment', { session: created.session, file: 'added.ts', line: 1, body: 'Mine' }); + const tour = await ok<{ tour: string }>(alice, 'tour_start', { session: created.session, topic: 'Mine' }); + + const bobs = await ok<{ session: string }[]>(bob, 'list_sessions', {}); + expect(bobs.map(s => s.session)).not.toContain(created.session); + expect(await failed(bob, 'get_diff', { session: created.session })).toContain('No session matches'); + expect(await failed(bob, 'list_comments', { session: created.session })).toContain('No session matches'); + expect(await failed(bob, 'comment', { session: created.session, file: 'added.ts', line: 1, body: 'x' })).toContain('No session matches'); + expect(await failed(bob, 'reply', { id: finding.thread, body: 'hijack' })).toContain('No thread matches'); + expect(await failed(bob, 'resolve', { id: finding.thread })).toContain('No thread matches'); + expect(await failed(bob, 'amend', { id: finding.thread, body: 'hijack' })).toContain('No comment or thread'); + expect(await failed(bob, 'tour_step', { tour: tour.tour, file: 'added.ts', line: 1, body: 'x' })).toContain('No walkthrough'); + expect(await failed(bob, 'tour_delete', { tour: tour.tour })).toContain('No walkthrough'); + + const mine = await ok<{ id: string; comments: { body: string }[] }[]>(alice, 'list_comments', { session: created.session }); + expect(mine).toHaveLength(1); + expect(mine[0].comments.map(c => c.body)).toEqual(['Mine']); + expect(await failed(alice, 'reply', { id: finding.thread, body: 'x', session: (await ok<{ session: string }>(alice, 'create_session', { repo: 'acme/widgets', pr: 1 })).session })).toContain('another session'); + await ok(alice, 'tour_delete', { tour: tour.tour }); + }); +}); diff --git a/packages/server/tests/oauth.test.ts b/packages/server/tests/oauth.test.ts new file mode 100644 index 00000000..2f9bfc7f --- /dev/null +++ b/packages/server/tests/oauth.test.ts @@ -0,0 +1,300 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { join } from 'node:path'; +import { Store } from '../src/db.js'; +import { OAuthProvider, hashPresentedClientSecret } from '../src/oauth.js'; +import { sha256 } from '../src/crypto.js'; +import { Users } from '../src/users.js'; +import { + connectAgent, + login, + pkcePair, + removeDir, + startFakeGitHub, + startTestServer, + tempDir, + type FakeGitHub, + type TestServer, +} from './helpers.js'; +import { randomBytes } from 'node:crypto'; + +let github: FakeGitHub; +let dataDir: string; +let server: TestServer; +let aliceCookie: string; +let bobCookie: string; + +const redirectUri = 'http://127.0.0.1:9/callback'; + +beforeAll(async () => { + github = await startFakeGitHub([]); + dataDir = tempDir('oauth'); + server = await startTestServer(dataDir, github.url); + aliceCookie = await login(server.base, 'alice@example.com'); + bobCookie = await login(server.base, 'bob@example.com'); +}); + +afterAll(async () => { + await server.close(); + await github.close(); + removeDir(dataDir); +}); + +async function register(body: Record = {}): Promise<{ client_id: string; client_secret?: string }> { + const res = await fetch(`${server.base}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_name: 'Claude Code', redirect_uris: [redirectUri], token_endpoint_auth_method: 'none', ...body }), + }); + expect(res.status).toBe(201); + return (await res.json()) as { client_id: string; client_secret?: string }; +} + +function authorizePath(clientId: string, challenge: string, extra: Record = {}): string { + return `/authorize?${new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + code_challenge: challenge, + code_challenge_method: 'S256', + state: 'st4te', + ...extra, + })}`; +} + +async function consent(cookie: string, path: string, decision: 'allow' | 'deny'): Promise { + const page = await fetch(`${server.base}${path}`, { headers: { cookie }, redirect: 'manual' }); + expect(page.status).toBe(200); + const request = /name="request" value="([^"]+)"/.exec(await page.text())![1]; + const res = await fetch(`${server.base}/oauth/consent`, { + method: 'POST', + redirect: 'manual', + headers: { cookie, 'Content-Type': 'application/x-www-form-urlencoded', 'Sec-Fetch-Site': 'same-origin' }, + body: new URLSearchParams({ request, decision }).toString(), + }); + expect(res.status).toBe(302); + return new URL(res.headers.get('location')!); +} + +async function token(body: Record): Promise<{ status: number; json: Record }> { + const res = await fetch(`${server.base}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(body).toString(), + }); + return { status: res.status, json: (await res.json()) as Record }; +} + +describe('metadata', () => { + it('advertises the authorization server and the protected resource', async () => { + const as = await (await fetch(`${server.base}/.well-known/oauth-authorization-server`)).json(); + expect(as).toMatchObject({ + issuer: `${server.base}/`, + authorization_endpoint: `${server.base}/authorize`, + token_endpoint: `${server.base}/token`, + registration_endpoint: `${server.base}/register`, + revocation_endpoint: `${server.base}/revoke`, + code_challenge_methods_supported: ['S256'], + }); + for (const path of ['/.well-known/oauth-protected-resource/mcp', '/.well-known/oauth-protected-resource']) { + const prm = await (await fetch(`${server.base}${path}`)).json(); + expect(prm).toMatchObject({ resource: `${server.base}/mcp`, authorization_servers: [`${server.base}/`] }); + } + }); +}); + +describe('authorization code with PKCE', () => { + it('sends a visitor who is not signed in to the login page, and back', async () => { + const client = await register(); + const { challenge } = pkcePair(); + const res = await fetch(`${server.base}${authorizePath(client.client_id, challenge)}`, { redirect: 'manual' }); + expect(res.status).toBe(302); + const location = new URL(res.headers.get('location')!, server.base); + expect(location.pathname).toBe('/login'); + const next = location.searchParams.get('next')!; + expect(next).toMatch(/^\/authorize\?/); + expect(new URLSearchParams(next.split('?')[1]).get('code_challenge')).toBe(challenge); + }); + + it('asks the signed-in user, names the client, and hands out a code only when allowed', async () => { + const client = await register(); + const { verifier, challenge } = pkcePair(); + const page = await fetch(`${server.base}${authorizePath(client.client_id, challenge)}`, { headers: { cookie: aliceCookie } }); + const html = await page.text(); + expect(html).toContain('Connect Claude Code?'); + expect(html).toContain('alice@example.com'); + expect(page.headers.get('content-security-policy')).toContain("form-action 'self' http://127.0.0.1:9"); + + const denied = await consent(aliceCookie, authorizePath(client.client_id, challenge), 'deny'); + expect(denied.searchParams.get('error')).toBe('access_denied'); + expect(denied.searchParams.get('state')).toBe('st4te'); + expect(denied.searchParams.get('code')).toBeNull(); + + const allowed = await consent(aliceCookie, authorizePath(client.client_id, challenge), 'allow'); + expect(allowed.origin + allowed.pathname).toBe(redirectUri); + expect(allowed.searchParams.get('state')).toBe('st4te'); + const code = allowed.searchParams.get('code')!; + + const wrong = await token({ grant_type: 'authorization_code', code, code_verifier: pkcePair().verifier, client_id: client.client_id }); + expect(wrong.status).toBe(400); + expect(wrong.json.error).toBe('invalid_grant'); + + const ok = await token({ grant_type: 'authorization_code', code, code_verifier: verifier, client_id: client.client_id, redirect_uri: redirectUri }); + expect(ok.status).toBe(200); + expect(ok.json).toMatchObject({ token_type: 'bearer', expires_in: 3600 }); + expect(typeof ok.json.access_token).toBe('string'); + + const replay = await token({ grant_type: 'authorization_code', code, code_verifier: verifier, client_id: client.client_id }); + expect(replay.json.error).toBe('invalid_grant'); + + const auth = await server.oauth.verifyAccessToken(ok.json.access_token as string); + const alice = server.users.findOrCreate('alice@example.com'); + expect(auth.extra?.userId).toBe(alice.id); + expect(auth.clientId).toBe(client.client_id); + }); + + it('refuses a code for another client, or with another redirect_uri', async () => { + const client = await register(); + const other = await register(); + const { verifier, challenge } = pkcePair(); + const code = (await consent(aliceCookie, authorizePath(client.client_id, challenge), 'allow')).searchParams.get('code')!; + expect((await token({ grant_type: 'authorization_code', code, code_verifier: verifier, client_id: other.client_id })).json.error) + .toBe('invalid_grant'); + const code2 = (await consent(aliceCookie, authorizePath(client.client_id, challenge), 'allow')).searchParams.get('code')!; + expect((await token({ + grant_type: 'authorization_code', code: code2, code_verifier: verifier, client_id: client.client_id, redirect_uri: 'http://127.0.0.1:9/other', + })).json.error).toBe('invalid_grant'); + }); + + it('issues tokens only for the MCP endpoint', async () => { + const client = await register(); + const { challenge } = pkcePair(); + const res = await fetch(`${server.base}${authorizePath(client.client_id, challenge, { resource: 'https://elsewhere.example/mcp' })}`, { + headers: { cookie: aliceCookie }, + redirect: 'manual', + }); + expect(res.status).toBe(302); + expect(new URL(res.headers.get('location')!).searchParams.get('error')).toBe('invalid_target'); + + const same = await fetch(`${server.base}${authorizePath(client.client_id, challenge, { resource: `${server.base}/mcp` })}`, { + headers: { cookie: aliceCookie }, + }); + expect(same.status).toBe(200); + }); + + it('refuses a consent from another site, from another user, or given twice', async () => { + const client = await register(); + const { challenge } = pkcePair(); + const page = await fetch(`${server.base}${authorizePath(client.client_id, challenge)}`, { headers: { cookie: aliceCookie } }); + const request = /name="request" value="([^"]+)"/.exec(await page.text())![1]; + const post = (cookie: string, site: string) => fetch(`${server.base}/oauth/consent`, { + method: 'POST', + redirect: 'manual', + headers: { cookie, 'Content-Type': 'application/x-www-form-urlencoded', 'Sec-Fetch-Site': site }, + body: new URLSearchParams({ request, decision: 'allow' }).toString(), + }); + expect((await post(aliceCookie, 'cross-site')).status).toBe(403); + expect((await post(bobCookie, 'same-origin')).status).toBe(400); + expect((await post(aliceCookie, 'same-origin')).status).toBe(302); + expect((await post(aliceCookie, 'same-origin')).status).toBe(400); + }); +}); + +describe('refresh and revocation', () => { + it('rotates the refresh token on use and refuses the old one', async () => { + const agent = await connectAgent(server.base, aliceCookie); + const first = await token({ grant_type: 'refresh_token', refresh_token: agent.refreshToken, client_id: agent.clientId }); + expect(first.status).toBe(200); + expect(first.json.refresh_token).not.toBe(agent.refreshToken); + const replay = await token({ grant_type: 'refresh_token', refresh_token: agent.refreshToken, client_id: agent.clientId }); + expect(replay.json.error).toBe('invalid_grant'); + const second = await token({ grant_type: 'refresh_token', refresh_token: first.json.refresh_token as string, client_id: agent.clientId }); + expect(second.status).toBe(200); + await expect(server.oauth.verifyAccessToken(second.json.access_token as string)).resolves.toBeTruthy(); + }); + + it('revokes an access token', async () => { + const agent = await connectAgent(server.base, aliceCookie); + await expect(server.oauth.verifyAccessToken(agent.accessToken)).resolves.toBeTruthy(); + const res = await fetch(`${server.base}/revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token: agent.accessToken, client_id: agent.clientId }).toString(), + }); + expect(res.status).toBe(200); + await expect(server.oauth.verifyAccessToken(agent.accessToken)).rejects.toThrow('Invalid or expired access token'); + }); +}); + +describe('confidential clients', () => { + it('stores only the secret’s hash, and accepts the secret but not the hash', async () => { + const client = await register({ token_endpoint_auth_method: 'client_secret_post' }); + expect(client.client_secret).toBeTruthy(); + const row = server.store.get<{ client_secret_hash: string }>('SELECT client_secret_hash FROM oauth_clients WHERE client_id = ?', client.client_id)!; + expect(row.client_secret_hash).toBe(sha256(client.client_secret!)); + + const { verifier, challenge } = pkcePair(); + const code = (await consent(aliceCookie, authorizePath(client.client_id, challenge), 'allow')).searchParams.get('code')!; + const withHash = await token({ + grant_type: 'authorization_code', code, code_verifier: verifier, client_id: client.client_id, client_secret: row.client_secret_hash, + }); + expect(withHash.json.error).toBe('invalid_client'); + const withSecret = await token({ + grant_type: 'authorization_code', code, code_verifier: verifier, client_id: client.client_id, client_secret: client.client_secret!, + }); + expect(withSecret.status).toBe(200); + }); + + it('leaves a body without a secret alone', () => { + const body: Record = { client_id: 'x' }; + hashPresentedClientSecret(body); + hashPresentedClientSecret(undefined); + expect(body).toEqual({ client_id: 'x' }); + }); +}); + +describe('storage', () => { + it('never holds a code or token in plaintext', async () => { + const agent = await connectAgent(server.base, aliceCookie); + const dump = JSON.stringify([ + server.store.all('SELECT * FROM oauth_codes'), + server.store.all('SELECT * FROM oauth_tokens'), + ]); + expect(dump).not.toContain(agent.accessToken); + expect(dump).not.toContain(agent.refreshToken); + expect(dump).toContain(sha256(agent.accessToken)); + }); +}); + +describe('expiry', () => { + it('refuses expired codes, access tokens and refresh tokens, and purges them', async () => { + const dir = tempDir('oauth-expiry'); + const store = new Store(join(dir, 'diffity.db')); + let now = 1_000_000; + const provider = new OAuthProvider(store, { resourceUrl: new URL('http://localhost/mcp'), now: () => now }); + const user = new Users(store, randomBytes(32)).findOrCreate('a@example.com'); + const client = provider.clientsStore.registerClient({ + client_id: 'c1', redirect_uris: [redirectUri], token_endpoint_auth_method: 'none', + } as never); + const params = { codeChallenge: 'x', redirectUri, scopes: [] }; + + const code = provider.issueCode(client.client_id, user.id, params); + now += 601; + await expect(provider.challengeForAuthorizationCode(client, code)).rejects.toThrow('expired'); + + const fresh = provider.issueCode(client.client_id, user.id, params); + const tokens = await provider.exchangeAuthorizationCode(client, fresh); + now += 3601; + await expect(provider.verifyAccessToken(tokens.access_token)).rejects.toThrow('expired'); + await expect(provider.exchangeRefreshToken(client, tokens.refresh_token!, ['wider'])).rejects.toThrow('cannot widen'); + now += 30 * 24 * 3600; + await expect(provider.exchangeRefreshToken(client, tokens.refresh_token!)).rejects.toThrow('expired'); + + provider.purgeExpired(); + expect(store.all('SELECT * FROM oauth_tokens')).toEqual([]); + expect(store.all('SELECT * FROM oauth_codes')).toEqual([]); + expect(provider.clientsStore.clientName('c1')).toBeNull(); + expect(provider.clientsStore.clientName('missing')).toBeNull(); + store.close(); + removeDir(dir); + }); +}); diff --git a/packages/server/tests/reviews.test.ts b/packages/server/tests/reviews.test.ts new file mode 100644 index 00000000..5167f964 --- /dev/null +++ b/packages/server/tests/reviews.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { Store } from '../src/db.js'; +import { AmbiguousIdError, Reviews, type ReviewSessionRecord } from '../src/reviews.js'; +import { Users, WebSessions } from '../src/users.js'; +import { removeDir, tempDir } from './helpers.js'; + +let dir: string; +let store: Store; +let reviews: Reviews; +let users: Users; +let alice: string; +let bob: string; +let session: ReviewSessionRecord; + +const agent = { name: 'Agent', type: 'agent' as const }; + +beforeEach(() => { + dir = tempDir('reviews'); + store = new Store(join(dir, 'diffity.db')); + reviews = new Reviews(store); + users = new Users(store, randomBytes(32)); + alice = users.findOrCreate('Alice@Example.com', 'Alice').id; + bob = users.findOrCreate('bob@example.com').id; + session = reviews.findOrCreateSession({ + userId: alice, owner: 'acme', repo: 'widgets', kind: 'shas', prNumber: null, prMeta: null, + baseSha: 'a'.repeat(40), headSha: 'b'.repeat(40), + }).session; +}); + +afterEach(() => { + store.close(); + removeDir(dir); +}); + +function thread(userId = alice, sessionId = session.id) { + return reviews.createThread({ + userId, sessionId, filePath: 'a.ts', side: 'new', startLine: 1, endLine: 2, body: 'Finding', author: agent, + }); +} + +describe('users', () => { + it('finds a user by normalised email and keeps the settings default', () => { + const again = users.findOrCreate('alice@example.com'); + expect(again.id).toBe(alice); + expect(again.name).toBe('Alice'); + expect(again.settings).toEqual({ shareReviews: 'private' }); + expect(users.get('missing')).toBeNull(); + }); + + it('stores the GitHub token encrypted, and cannot read it back with another key', () => { + users.setGitHubToken(alice, 'ghp_plaintext'); + expect(JSON.stringify(store.all('SELECT * FROM users'))).not.toContain('ghp_plaintext'); + expect(users.gitHubToken(alice)).toBe('ghp_plaintext'); + expect(new Users(store, randomBytes(32)).gitHubToken(alice)).toBeNull(); + users.setGitHubToken(alice, null); + expect(users.gitHubToken(alice)).toBeNull(); + }); + + it('keeps only a hash of the web session cookie, and honours expiry and sign-out', () => { + const sessions = new WebSessions(store); + const token = sessions.create(alice, 1_000); + expect(JSON.stringify(store.all('SELECT * FROM web_sessions'))).not.toContain(token); + expect(sessions.userFor(token, 2_000)).toBe(alice); + expect(sessions.userFor(token, 1_000 + WebSessions.maxAgeSeconds() * 1000 + 1)).toBeNull(); + expect(sessions.userFor(undefined)).toBeNull(); + expect(sessions.userFor('forged')).toBeNull(); + sessions.destroy(token); + sessions.destroy(undefined); + expect(sessions.userFor(token, 2_000)).toBeNull(); + }); +}); + +describe('isolation', () => { + it('hides every session, thread, comment and tour of another user', () => { + const t = thread(); + const tour = reviews.createTour(alice, session.id, 'Order', ''); + reviews.addTourStep(alice, tour.id, { filePath: 'a.ts', startLine: 1, endLine: 1, body: '', annotation: '' }); + + expect(reviews.getSession(bob, session.id)).toBeNull(); + expect(reviews.getSession(bob, session.id.slice(0, 8))).toBeNull(); + expect(reviews.listSessions(bob)).toEqual([]); + expect(reviews.getThread(bob, t.id)).toBeNull(); + expect(reviews.threadsForSession(bob, session.id)).toEqual([]); + expect(reviews.findComment(bob, t.comments[0].id)).toBeNull(); + expect(reviews.getTour(bob, tour.id)).toBeNull(); + expect(reviews.toursForSession(bob, session.id)).toEqual([]); + }); + + it('lets no write of another user land', () => { + const t = thread(); + const commentId = t.comments[0].id; + const tour = reviews.createTour(alice, session.id, 'Order', ''); + + reviews.updateThreadStatus(bob, t.id, 'dismissed'); + reviews.editComment(bob, commentId, 'hijacked'); + reviews.deleteComment(bob, commentId); + reviews.deleteThread(bob, t.id); + reviews.deleteThreadsForSession(bob, session.id); + reviews.updateTourStatus(bob, tour.id, 'ready'); + reviews.deleteTour(bob, tour.id); + reviews.startReview(bob, session.id, 'not mine'); + reviews.moveOpenWork(bob, [session.id], session.id); + + const after = reviews.getThread(alice, t.id)!; + expect(after.status).toBe('open'); + expect(after.comments[0].body).toBe('Finding'); + expect(reviews.getTour(alice, tour.id)?.status).toBe('building'); + expect(reviews.getSession(alice, session.id)?.review.inProgress).toBe(false); + }); +}); + +describe('ids', () => { + it('accepts a full id or an 8-character prefix, and refuses an ambiguous one', () => { + const t = thread(); + expect(reviews.getThread(alice, t.id.slice(0, 8))?.id).toBe(t.id); + expect(reviews.getThread(alice, t.id.slice(0, 7))).toBeNull(); + expect(reviews.getSession(alice, session.id.slice(0, 8))?.id).toBe(session.id); + + const first = reviews.createTour(alice, session.id, 'One', ''); + store.run("UPDATE tours SET id = 'abcdefgh-1' WHERE id = ?", first.id); + const second = reviews.createTour(alice, session.id, 'Two', ''); + store.run("UPDATE tours SET id = 'abcdefgh-2' WHERE id = ?", second.id); + expect(() => reviews.getTour(alice, 'abcdefgh')).toThrow(AmbiguousIdError); + expect(reviews.getTour(alice, 'abcdefgh-2')?.topic).toBe('Two'); + expect(reviews.getTour(bob, 'abcdefgh')).toBeNull(); + }); + + it('takes a prefix literally rather than as a pattern', () => { + thread(); + expect(reviews.getThread(alice, '%%%%%%%%')).toBeNull(); + expect(reviews.getThread(alice, '________')).toBeNull(); + }); +}); + +describe('threads', () => { + it('reopens a finding when a person replies, not when an agent does', () => { + const t = thread(); + reviews.updateThreadStatus(alice, t.id, 'resolved', 'Fixed it', agent); + reviews.addReply(alice, t.id, 'Noted', agent, 'aside'); + expect(reviews.getThread(alice, t.id)?.status).toBe('resolved'); + reviews.addReply(alice, t.id, 'Not fixed', { name: 'Alice', type: 'user' }); + const after = reviews.getThread(alice, t.id)!; + expect(after.status).toBe('open'); + expect(after.comments.map(c => c.body)).toEqual(['Finding', 'Fixed it', 'Noted', 'Not fixed']); + expect(after.comments[2].kind).toBe('aside'); + expect(reviews.threadsForSession(alice, session.id, 'resolved')).toEqual([]); + }); + + it('takes the thread away with its last comment', () => { + const t = thread(); + reviews.deleteComment(alice, t.comments[0].id); + expect(reviews.getThread(alice, t.id)).toBeNull(); + }); + + it('records a review run on the session', () => { + reviews.startReview(alice, session.id, 'first pass'); + expect(reviews.getSession(alice, session.id)?.review).toMatchObject({ inProgress: true, note: 'first pass' }); + reviews.finishReview(alice, session.id); + expect(reviews.getSession(alice, session.id)?.review.inProgress).toBe(false); + }); +}); + +describe('tours', () => { + it('numbers steps in the order they are added and lists them with the tour', () => { + const tour = reviews.createTour(alice, session.id, 'Order', 'why'); + reviews.addTourStep(alice, tour.id, { filePath: 'b.ts', startLine: 3, endLine: 4, body: 'second file', annotation: 'x' }); + reviews.addTourStep(alice, tour.id, { filePath: 'a.ts', startLine: 1, endLine: 1, body: 'first', annotation: '' }); + reviews.updateTourStatus(alice, tour.id, 'ready'); + const [listed] = reviews.toursForSession(alice, session.id); + expect(listed.status).toBe('ready'); + expect(listed.steps.map(s => [s.sortOrder, s.filePath])).toEqual([[1, 'b.ts'], [2, 'a.ts']]); + reviews.deleteTour(alice, tour.id); + expect(reviews.toursForSession(alice, session.id)).toEqual([]); + }); +}); diff --git a/packages/server/tests/service.test.ts b/packages/server/tests/service.test.ts new file mode 100644 index 00000000..c3822bca --- /dev/null +++ b/packages/server/tests/service.test.ts @@ -0,0 +1,211 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { join } from 'node:path'; +import { Store } from '../src/db.js'; +import { Mirrors } from '../src/git.js'; +import { GitHubApi, StoredTokenAccess } from '../src/github.js'; +import { Reviews } from '../src/reviews.js'; +import { ReviewService, ServiceError, describeSession, gitHubDetailsFor, parseRepoSlug } from '../src/service.js'; +import { Users } from '../src/users.js'; +import { git, makeFixture, removeDir, startFakeGitHub, tempDir, type FakeGitHub, type Fixture } from './helpers.js'; +import { randomBytes } from 'node:crypto'; + +let fixture: Fixture; +let github: FakeGitHub; +let dataDir: string; +let store: Store; +let users: Users; +let reviews: Reviews; +let service: ReviewService; +let alice: string; +let bob: string; +let mallory: string; + +beforeAll(async () => { + fixture = makeFixture(); + github = await startFakeGitHub([ + { + owner: 'Acme', + name: 'widgets', + private: true, + tokens: ['alice-token', 'bob-token'], + pulls: { 1: { title: 'Change line ten', baseSha: fixture.mainTip, headSha: fixture.head1 } }, + }, + ]); + dataDir = tempDir('service'); + store = new Store(join(dataDir, 'diffity.db')); + users = new Users(store, randomBytes(32)); + reviews = new Reviews(store); + alice = users.findOrCreate('alice@example.com').id; + bob = users.findOrCreate('bob@example.com').id; + mallory = users.findOrCreate('mallory@example.com').id; + users.setGitHubToken(alice, 'alice-token'); + users.setGitHubToken(bob, 'bob-token'); + service = new ReviewService( + reviews, + new Mirrors(dataDir, fixture.remoteUrl), + new GitHubApi(github.url), + new StoredTokenAccess(users, null), + new URL('http://localhost:5390'), + ); +}); + +afterAll(async () => { + store.close(); + await github.close(); + removeDir(fixture.root); + removeDir(dataDir); +}); + +describe('createSession', () => { + it('reviews a pull request from its merge base, with the names GitHub uses', async () => { + const created = await service.createSession(alice, { repo: 'acme/widgets', pr: 1 }); + expect(created.created).toBe(true); + expect(created.session).toMatchObject({ + owner: 'Acme', + repo: 'widgets', + kind: 'pr', + prNumber: 1, + baseSha: fixture.base, + headSha: fixture.head1, + }); + expect(created.session.prMeta?.title).toBe('Change line ten'); + expect(created.files.map(file => file.newPath).sort()).toEqual(['added.ts', 'src.ts']); + expect(service.sessionUrl(created.session.id)).toBe(`http://localhost:5390/s/${created.session.id}/`); + expect(describeSession(created.session)).toBe('#1 Change line ten'); + expect(gitHubDetailsFor(created.session)).toMatchObject({ prNumber: 1, headSha: fixture.head1, prAuthor: 'octocat' }); + + const again = await service.createSession(alice, { repo: 'acme/widgets', pr: 1 }); + expect(again.created).toBe(false); + expect(again.session.id).toBe(created.session.id); + }); + + it('reviews two explicit commits', async () => { + const created = await service.createSession(alice, { repo: 'acme/widgets', base: fixture.base, head: fixture.head2 }); + expect(created.session.kind).toBe('shas'); + expect(created.session.prNumber).toBeNull(); + expect(gitHubDetailsFor(created.session)).toBeNull(); + expect(describeSession(created.session)).toBe(`${fixture.base.slice(0, 7)}..${fixture.head2.slice(0, 7)}`); + expect((await service.diffText(created.session)).length).toBeGreaterThan(0); + }); + + it('reviews a patch that was never pushed', async () => { + const patch = git(fixture.work, ['diff', fixture.base, fixture.head1]) + '\n'; + const created = await service.createSession(alice, { repo: 'acme/widgets', base: fixture.base, patch }); + expect(created.session.kind).toBe('patch'); + expect(created.session.headSha).toBe(git(fixture.work, ['rev-parse', `${fixture.head1}^{tree}`])); + expect(describeSession(created.session)).toContain('patch (tree'); + await expect(service.createSession(alice, { repo: 'acme/widgets', base: fixture.base, patch: 'garbage\n' })) + .rejects.toThrow(ServiceError); + }); + + it('keeps each user in their own session of the same change', async () => { + const mine = await service.createSession(alice, { repo: 'acme/widgets', base: fixture.base, head: fixture.head1 }); + const theirs = await service.createSession(bob, { repo: 'acme/widgets', base: fixture.base, head: fixture.head1 }); + expect(theirs.session.id).not.toBe(mine.session.id); + expect(reviews.getSession(bob, mine.session.id)).toBeNull(); + expect(() => service.requireSession(bob, mine.session.id)).toThrow('No session matches'); + }); + + it('checks access with the caller’s own token before fetching anything', async () => { + const before = github.requests.length; + await expect(service.createSession(mallory, { repo: 'acme/widgets', pr: 1 })).rejects.toThrow('is not readable without a GitHub token'); + users.setGitHubToken(mallory, 'wrong-token'); + await expect(service.createSession(mallory, { repo: 'acme/widgets', pr: 1 })).rejects.toThrow('your GitHub token cannot read it'); + expect(github.requests.slice(before).every(request => request.startsWith('GET /repos/acme/widgets'))).toBe(true); + expect(github.requests.slice(before).some(request => request.includes('/pulls/'))).toBe(false); + }); + + it.each([ + [{ repo: 'nope' }, 'repo must be'], + [{ repo: '../x/y' }, 'repo must be'], + [{ repo: 'acme/widgets' }, 'exactly one of'], + [{ repo: 'acme/widgets', pr: 1, head: 'a'.repeat(40) }, 'exactly one of'], + [{ repo: 'acme/widgets', pr: 0 }, 'positive integer'], + [{ repo: 'acme/widgets', base: 'short', head: 'a'.repeat(40) }, 'base must be'], + [{ repo: 'acme/widgets', base: 'a'.repeat(40), head: 'short' }, 'head must be'], + [{ repo: 'acme/widgets', base: 'a'.repeat(40), patch: ' ' }, 'patch is empty'], + [{ repo: 'acme/widgets', base: 'a'.repeat(40), head: 'b'.repeat(40), patch: 'x' }, 'exactly one of'], + ])('refuses %j', async (input, message) => { + await expect(service.createSession(alice, input)).rejects.toThrow(message); + }); + + it('says when the pull request does not exist', async () => { + await expect(service.createSession(alice, { repo: 'acme/widgets', pr: 99 })).rejects.toThrow('no pull request #99'); + }); + + it('parses repository slugs', () => { + expect(parseRepoSlug('NaturalCycles/diffity.git')).toEqual({ owner: 'NaturalCycles', repo: 'diffity' }); + }); +}); + +describe('carry-forward', () => { + it('moves open findings to the pull request’s newer head, following renames and moved lines', async () => { + const first = (await service.createSession(bob, { repo: 'acme/widgets', pr: 1 })).session; + const onLine10 = reviews.createThread({ + userId: bob, sessionId: first.id, filePath: 'src.ts', side: 'new', startLine: 10, endLine: 10, + body: 'Why this change?', author: { name: 'Agent', type: 'agent' }, anchorContent: 'line 10 changed by the feature', + }); + const onRenamed = reviews.createThread({ + userId: bob, sessionId: first.id, filePath: 'old-name.ts', side: 'new', startLine: 1, endLine: 1, + body: 'Name this better', author: { name: 'Agent', type: 'agent' }, anchorContent: 'export const moved = true;', + }); + const done = reviews.createThread({ + userId: bob, sessionId: first.id, filePath: 'added.ts', side: 'new', startLine: 1, endLine: 1, + body: 'Resolved already', author: { name: 'Agent', type: 'agent' }, + }); + reviews.updateThreadStatus(bob, done.id, 'resolved'); + const tour = reviews.createTour(bob, first.id, 'Reading order', ''); + + fixture.pushPull(fixture.head2); + try { + const second = await service.createSession(bob, { repo: 'acme/widgets', pr: 1 }); + expect(second.created).toBe(true); + expect(second.session.headSha).toBe(fixture.head2); + expect(second.carried).toBe(2); + + const carried = reviews.threadsForSession(bob, second.session.id); + expect(carried.map(t => t.id).sort()).toEqual([onLine10.id, onRenamed.id].sort()); + expect(carried.find(t => t.id === onLine10.id)).toMatchObject({ startLine: 13, endLine: 13 }); + expect(carried.find(t => t.id === onRenamed.id)).toMatchObject({ filePath: 'new-name.ts' }); + expect(reviews.threadsForSession(bob, first.id).map(t => t.id)).toEqual([done.id]); + expect(reviews.getTour(bob, tour.id)?.sessionId).toBe(second.session.id); + + // Alice's session of the same pull request is hers alone and is not touched. + const alices = reviews.listSessions(alice).filter(s => s.prNumber === 1); + expect(alices.every(s => s.headSha === fixture.head1)).toBe(true); + } finally { + fixture.pushPull(fixture.head1); + } + }); +}); + +describe('reading a session', () => { + it('answers the parsed diff with base line counts, and what hiding whitespace suppressed', async () => { + const { session } = await service.createSession(alice, { repo: 'acme/widgets', base: fixture.base, head: fixture.head1 }); + const diff = await service.parsedDiff(session); + const src = diff.files.find(file => file.newPath === 'src.ts')!; + expect(src.oldFileLineCount).toBe(30); + expect(diff.suppressed).toBeNull(); + const hidden = await service.parsedDiff(session, { ignoreWhitespace: true }); + expect(hidden.suppressed).toEqual({ files: 0, lines: 0 }); + const one = await service.parsedDiff(session, { path: 'added.ts' }); + expect(one.files.map(file => file.newPath)).toEqual(['added.ts']); + await expect(service.diffText(session, { path: '../x' })).rejects.toThrow('Not a repository path'); + }); + + it('reads the project standards at the head, and the defaults without them', async () => { + const { session } = await service.createSession(alice, { repo: 'acme/widgets', base: fixture.base, head: fixture.head1 }); + expect(await service.standards(session)).toEqual({ + severities: ['blocker', 'nit'], + standards: { path: 'STANDARDS.md', content: 'Every P1 must have a test.\n' }, + }); + expect(await service.readFile(session, 'old', 'added.ts')).toBeNull(); + expect(await service.readFile(session, 'new', '/etc/passwd')).toBeNull(); + }); + + it('refuses a comment on a file outside the diff, naming the files it has', async () => { + const { session } = await service.createSession(alice, { repo: 'acme/widgets', base: fixture.base, head: fixture.head1 }); + await expect(service.assertInDiff(session, 'src.ts', 'new')).resolves.toBeUndefined(); + await expect(service.assertInDiff(session, 'README.md', 'new')).rejects.toThrow(/not on the new side[\s\S]*src\.ts/); + }); +}); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 00000000..170d18ac --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../api" }, + { "path": "../git" }, + { "path": "../parser" } + ] +} diff --git a/packages/server/tsconfig.typecheck.json b/packages/server/tsconfig.typecheck.json new file mode 100644 index 00000000..3f13684c --- /dev/null +++ b/packages/server/tsconfig.typecheck.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "build.ts"] +} diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts new file mode 100644 index 00000000..7823b977 --- /dev/null +++ b/packages/server/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + testTimeout: 30_000, + hookTimeout: 60_000, + }, +}); diff --git a/packages/skills/diffity-review-remote/SKILL.md b/packages/skills/diffity-review-remote/SKILL.md new file mode 100644 index 00000000..5f906229 --- /dev/null +++ b/packages/skills/diffity-review-remote/SKILL.md @@ -0,0 +1,183 @@ +--- +name: diffity-review-remote +description: Review a pushed pull request, commit range or patch on a hosted diffity server through its MCP tools, and hand the user the review URL +user-invocable: true +--- + +# Diffity Remote Review Skill + +You are reviewing a change and leaving inline findings on a **hosted diffity server**, through its MCP +tools. There is no local `diffity` binary and no local server: the review lives on the server, the +user reads it in their browser, and you read the code from your own checkout. + +## Arguments + +- `target` (optional): what to review — a pull request (`#123`, a PR URL), a commit range + (`..`), or nothing. With nothing, review **the pull request for the current branch** if + there is one; if the work is not pushed, review it as a patch (see Step 1). +- `focus` (optional): one of `security`, `performance`, `naming`, `errors`, `types`, `logic`. If + omitted, review everything. + +## Tools + +The connector is usually added as `diffity`, so in Claude Code the tools are named +`mcp__diffity__`. + +``` +create_session { repo: "owner/name", pr? , base?, head?, patch? } → { session, url, base, head, files } +list_sessions { repo? } +get_diff { session, file? } unified diff; line numbers are in the @@ headers +get_file { session, path, side? } side "new" (head, default) or "old" (base) +get_standards { session } the project's standards and severity labels +review_start { session, note? } +review_done { session } +comment { session, file, line, endLine?, side?, body } +general_comment { session, body } +reply { id, body, aside? } +amend { id, body } a comment id, or a thread id for its finding +resolve { id, summary? } +dismiss { id, reason? } +list_comments { session, status? } +tour_start { session, topic, body? } → { tour } +tour_step { tour, file, line, endLine?, body, annotation? } +tour_done { tour } +tour_delete { tour } +``` + +- `create_session` takes the repository and **exactly one** of: `pr`; `base` and `head` (full + 40-character shas, both pushed); or `base` and `patch` (a unified diff against a pushed base). +- `session`, thread and tour ids accept the full id or its first 8 characters. +- Every tool reports a mistake as an error result with a message. Read it and correct the call; do + not retry blindly. + +If the tools are not available, tell the user to add the connector — +`claude mcp add --transport http diffity /mcp` — and stop. + +## Instructions + +### Step 1: Create the session + +1. Work out the repository: `git remote get-url origin` gives `owner/name`. +2. Decide what to review: + - A pull request: `gh pr view --json number,url` for the current branch, or the one named. Call + `create_session { repo, pr }`. The server pins the diff to the pull request's merge base, so it + matches what GitHub shows. + - A range whose commits are pushed: `create_session { repo, base, head }` with full shas + (`git rev-parse`). + - Work that is not pushed: pick a pushed base (`git merge-base origin/ HEAD`), and + send `git diff ` as `patch`. The server applies it to that base. +3. State which one you chose in your first message, so the user can correct you cheaply. +4. Keep the `session` id and the `url` from the result for everything that follows. + +If `create_session` says the repository cannot be read, the user has to add a GitHub token on the +server's `/settings` page. Tell them so, with the URL, and stop. + +### Step 2: Say that you have started + +Call `review_start { session, note: "" }` straight away. The page then shows +that a review is under way; without it a reader cannot tell "nothing found" from "not finished +looking". + +Call `review_done { session }` as the last thing you do — **after** the comments and the reading +order are in, including when you found nothing, and including when you give up early. + +### Step 3: Review the diff + +1. Get the diff from the server with `get_diff { session }`. This is the diff the reader sees, and + the line numbers you comment on must be the ones in its `@@` headers. Review from your own + checkout for context — the files around the change, callers, tests — but make sure the checkout + is at the reviewed head (`git fetch` and compare with the `head` the session returned); when it is + not, read files with `get_file` instead. +2. Read the project's standards with `get_standards { session }`. Whatever it returns outranks the + generic guidance here: it is what this team has agreed to review against. +3. Read the CLAUDE.md files that apply: the root one and those in directories with changed files. + +#### Adapt to the size of the change + +- **Small** (under ~100 changed lines, 1-3 files): review each file in order. +- **Medium** (100-500 lines, 3-10 files): group files by area, core logic first. +- **Large** (500+ lines or 10+ files): group by area, core logic first, then every remaining file. + For a mechanically repeated change, verify the pattern on the first instances, then check every + remaining one for deviations. + +Whatever the size, **read and review every changed file**. + +#### Understand the change before judging it + +Summarise the change for yourself first: what it is trying to do, which files carry the core logic +and which follow from it, what the author intended (commit messages, the pull request description). +Read each changed file in full, not just its hunks. For any changed signature, export, return type or +behaviour, find the callers and check they still hold. + +#### How to analyse + +- **Data flow** — where each value comes from and goes; null where the code assumes not; branches of + an upstream conditional the change does not handle. +- **State and lifecycle** — states that cannot be reached or left, resources not cleaned up on some + path, concurrent access, ordering invariants. +- **Contracts** — does the code still satisfy what callers expect; do API responses match clients. +- **Boundaries** — validation of user input and external data; injection (SQL, shell, XSS, path + traversal). +- **Edge cases that will happen** — empty inputs, zero, off-by-one, division by input. + +#### Completeness + +- New behaviour without tests, a bug fix without a regression test, changed behaviour with stale + tests: flag as the project's mildest severity unless its CLAUDE.md requires tests. +- Missing pieces clearly needed for the change to work: a migration, a config default, a client + update, a lockfile. + +#### What to flag, and how to validate it + +Flag real problems: code that will not compile or run, logic errors, security holes, demonstrable +races or data loss, CLAUDE.md violations you can quote, broken contracts, missing tests, incomplete +changes. Skip style, linter-catchable issues and problems in unchanged code. + +Before posting a finding, verify it: re-read the surrounding code, grep for the "missing" import, +read the actual call sites, confirm the CLAUDE.md rule applies to the file. A repeated pattern gets +one comment on its first occurrence and a mention in the summary. + +### Step 4: Leave the findings + +1. Order them by severity, most severe first, then by file order. +2. Prefix each with a severity label from `get_standards` (`P1: …`, `P2: …`, `P3: …` by default). The + most severe label means *this must not merge*; do not inflate. +3. Leave each as `comment { session, file, line, endLine?, side?, body }`: + - `side: "new"` (the default) for added or kept lines, `"old"` for removed ones. + - The file must be in the session's diff; the tool says so, with the file list, when it is not. + - **Lead with the problem.** Two or three sentences, around 60 words: the problem, its + consequence, the fix. At most one small code suggestion. Anything longer belongs in the general + comment or the walkthrough. +4. Then decide on a general comment (`general_comment { session, body }`): + - No findings → "No issues found. Checked for bugs and CLAUDE.md compliance." + - 1-2 findings → skip it unless there is a cross-cutting concern. + - 3+ findings, or a large diff → a short paragraph of themes, verdict first, no recap of the inline + findings and no severity prefixes. + - Name a severity only where a finding with it is open, and keep any count right. + +### Step 5: Set the reading order + +Unless the change is a single file, record the order it should be read in: + +``` +tour_start { session, topic: "Reading order", body: "" } +tour_step { tour, file, line, endLine?, body: "", annotation: "<3-6 words: why here>" } +tour_done { tour } +``` + +The `annotation` becomes the file's label in the reordered file list, so make it say *why* the file +is read at that point ("the primitive", "first consumer"). Point each step at the most important +lines, not line 1. If a step went in wrong, `tour_delete` and build it again. + +Then call `review_done { session }`. + +### Step 6: Hand over the review + +Give the user the session `url` and the counts, using the labels you used: + +> Review ready: +> +> Found: 1 P1, 2 P2. The file list is in reading order; the P1 is on the last stop. + +The user signs in on that page the first time. Findings are not posted to GitHub from the hosted +server yet; the page is where they are read. diff --git a/packages/ui/package.json b/packages/ui/package.json index 6fc1ec7b..a5904140 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/ui", - "version": "0.10.37", + "version": "0.10.38", "type": "module", "private": true, "scripts": { diff --git a/packages/ui/src/components/diff/diff-page.tsx b/packages/ui/src/components/diff/diff-page.tsx index 4a52bc95..7ac8b0de 100644 --- a/packages/ui/src/components/diff/diff-page.tsx +++ b/packages/ui/src/components/diff/diff-page.tsx @@ -698,6 +698,7 @@ export function DiffPage() { onGoToAnswer={handleGoToAnswer} sessionId={sessionId} onGitHubPulled={() => queryClient.invalidateQueries({ queryKey: ['threads'] })} + postingAvailable={!info?.hosted} /> {isStale && } diff --git a/packages/ui/src/components/layout/github-dialog.tsx b/packages/ui/src/components/layout/github-dialog.tsx index 0a744e94..8ed3c263 100644 --- a/packages/ui/src/components/layout/github-dialog.tsx +++ b/packages/ui/src/components/layout/github-dialog.tsx @@ -31,6 +31,8 @@ interface GitHubDialogProps { sessionId: string | null; /** An agent is still writing findings, so the review is not ready to leave the machine. */ reviewInProgress?: boolean; + /** False on the hosted server, which does not post to the forge from the page yet. */ + postingAvailable?: boolean; onPulled: () => void; onClose: () => void; } @@ -48,7 +50,7 @@ function lineLabel(thread: CommentThread): string { } export function GitHubDialog(props: GitHubDialogProps) { - const { details, threads, sessionId, reviewInProgress, onPulled, onClose } = props; + const { details, threads, sessionId, reviewInProgress, postingAvailable = true, onPulled, onClose } = props; const [commentCount, setCommentCount] = useState(details.commentCount); const [submitting, setSubmitting] = useState(false); const [pulling, setPulling] = useState(false); @@ -203,6 +205,12 @@ export function GitHubDialog(props: GitHubDialogProps) {
+ {!postingAvailable && ( +
+ Posting this review to GitHub is not available here yet. +
+ )} + {postingAvailable && (<>
@@ -351,6 +359,7 @@ export function GitHubDialog(props: GitHubDialogProps) { )}
+ )}
void; + /** False on the hosted server, which does not post to the forge from the page yet. */ + postingAvailable?: boolean; } function extractCodeContext(diff: ParsedDiff | undefined, filePath: string, side: 'old' | 'new', startLine: number, endLine: number): string[] { @@ -147,6 +149,7 @@ export function Toolbar(props: ToolbarProps) { onGoToAnswer, sessionId, onGitHubPulled, + postingAvailable, } = props; const [showGitHub, setShowGitHub] = useState(false); @@ -247,6 +250,7 @@ export function Toolbar(props: ToolbarProps) { onGitHubPulled?.()} diff --git a/packages/ui/src/components/tree/markdown-preview.tsx b/packages/ui/src/components/tree/markdown-preview.tsx index b0b22cb6..f5061564 100644 --- a/packages/ui/src/components/tree/markdown-preview.tsx +++ b/packages/ui/src/components/tree/markdown-preview.tsx @@ -8,6 +8,7 @@ import { useHighlighter } from '../../hooks/use-highlighter'; import { getTheme } from '../../hooks/use-theme'; import { MermaidDiagram } from '../mermaid-diagram'; import { markdownSanitizeSchema } from '../../lib/markdown-sanitize'; +import { apiPath } from '../../lib/base'; interface MarkdownPreviewProps { content: string[]; @@ -33,7 +34,7 @@ function resolveImageSrc(src: string | undefined, filePath: string | undefined): resolved.push(part); } } - return `/api/tree/raw/${resolved.map(encodeURIComponent).join('/')}`; + return apiPath(`/api/tree/raw/${resolved.map(encodeURIComponent).join('/')}`); } interface Frontmatter { diff --git a/packages/ui/src/entry.client.tsx b/packages/ui/src/entry.client.tsx new file mode 100644 index 00000000..3970a559 --- /dev/null +++ b/packages/ui/src/entry.client.tsx @@ -0,0 +1,26 @@ +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { HydratedRouter } from "react-router/dom"; +import { appBase } from "./lib/base"; + +declare global { + interface Window { + __reactRouterContext?: { basename?: string }; + } +} + +// One build serves every hosted review under its own path, so the router's basename is decided +// by the page rather than at build time. With no base the build's own "/" stands. +const base = appBase(); +if (base && window.__reactRouterContext) { + window.__reactRouterContext.basename = base; +} + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/packages/ui/src/hooks/use-viewer-presence.ts b/packages/ui/src/hooks/use-viewer-presence.ts index f15bafe7..ccea88a0 100644 --- a/packages/ui/src/hooks/use-viewer-presence.ts +++ b/packages/ui/src/hooks/use-viewer-presence.ts @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { apiPath } from '../lib/base'; const BEAT_MS = 15_000; @@ -17,7 +18,7 @@ export function useViewerPresence(enabled: boolean): void { } const beat = (): void => { - void fetch('/api/viewer', { method: 'POST', keepalive: true }).catch(() => {}); + void fetch(apiPath('/api/viewer'), { method: 'POST', keepalive: true }).catch(() => {}); }; beat(); @@ -29,7 +30,7 @@ export function useViewerPresence(enabled: boolean): void { } }; const onHide = (): void => { - navigator.sendBeacon?.('/api/viewer/gone'); + navigator.sendBeacon?.(apiPath('/api/viewer/gone')); }; document.addEventListener('visibilitychange', onVisible); diff --git a/packages/ui/src/lib/api.ts b/packages/ui/src/lib/api.ts index 72b38e63..27f98a12 100644 --- a/packages/ui/src/lib/api.ts +++ b/packages/ui/src/lib/api.ts @@ -20,6 +20,7 @@ import type { TreePathsResponse, } from '@diffity/api'; import type { DiffFile } from '@diffity/parser'; +import { apiPath } from './base'; export type { DiffResponse, @@ -38,7 +39,7 @@ export type { } from '@diffity/api'; export async function apiFetch(url: string, init?: RequestInit): Promise { - const res = await fetch(url, init); + const res = await fetch(apiPath(url), init); if (!res.ok) { throw new Error(await errorMessage(res)); } @@ -46,7 +47,7 @@ export async function apiFetch(url: string, init?: RequestInit): Promise { } async function apiVoid(url: string, init?: RequestInit): Promise { - const res = await fetch(url, init); + const res = await fetch(apiPath(url), init); if (!res.ok) { throw new Error(await errorMessage(res)); } @@ -109,7 +110,7 @@ export function openInEditor(filePath: string, line?: number): Promise<{ ok: boo export async function fetchThreads(sessionId: string, status?: ThreadStatus): Promise { - const res = await fetch(buildUrl('/api/threads', { session: sessionId, status })); + const res = await fetch(apiPath(buildUrl('/api/threads', { session: sessionId, status }))); if (!res.ok) { return []; } @@ -211,7 +212,7 @@ export async function fetchFileContent(filePath: string, ref?: string): Promise< } export async function fetchGitHubDetails(): Promise { - const res = await fetch('/api/github/details'); + const res = await fetch(apiPath('/api/github/details')); if (!res.ok) { return null; } diff --git a/packages/ui/src/lib/base.ts b/packages/ui/src/lib/base.ts new file mode 100644 index 00000000..3ac0d530 --- /dev/null +++ b/packages/ui/src/lib/base.ts @@ -0,0 +1,34 @@ +declare global { + interface Window { + /** Set by the hosted server, which serves each review under its own path. */ + __DIFFITY_BASE__?: string; + } +} + +/** A path prefix with no trailing slash; empty when the page is served at the root. */ +export function normaliseBase(value: unknown): string { + if (typeof value !== 'string' || !value.startsWith('/')) { + return ''; + } + return value.replace(/\/+$/, ''); +} + +export function prefixApiPath(base: string, url: string): string { + return base && url.startsWith('/api/') ? `${base}${url}` : url; +} + +const BASE = normaliseBase(typeof window === 'undefined' ? undefined : window.__DIFFITY_BASE__); + +/** Where the page's routes and API live. Read once: the server writes it before any script runs. */ +export function appBase(): string { + return BASE; +} + +export function apiPath(url: string): string { + return prefixApiPath(BASE, url); +} + +/** For the few places that navigate with the browser rather than the router. */ +export function pagePath(path: string): string { + return `${BASE}${path}`; +} diff --git a/packages/ui/src/root.tsx b/packages/ui/src/root.tsx index a5c89d66..806c23c7 100644 --- a/packages/ui/src/root.tsx +++ b/packages/ui/src/root.tsx @@ -5,6 +5,7 @@ import { Toaster } from "sonner"; import NProgress from "nprogress"; import { queryClient } from "./lib/query-client"; import { ErrorPage } from "./components/error-page"; +import { pagePath } from "./lib/base"; import "nprogress/nprogress.css"; import "./styles/app.css"; @@ -79,7 +80,7 @@ export function ErrorBoundary() { error={error} actions={[ { label: "Reload page", primary: true, onClick: () => window.location.reload() }, - { label: "Go to diff view", onClick: () => { window.location.href = "/diff"; } }, + { label: "Go to diff view", onClick: () => { window.location.href = pagePath("/diff"); } }, ]} /> ); diff --git a/packages/ui/tests/base-path.test.ts b/packages/ui/tests/base-path.test.ts new file mode 100644 index 00000000..5d408691 --- /dev/null +++ b/packages/ui/tests/base-path.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { normaliseBase, prefixApiPath } from '../src/lib/base'; + +describe('normaliseBase', () => { + it('keeps an absolute path without its trailing slash', () => { + expect(normaliseBase('/s/abc')).toBe('/s/abc'); + expect(normaliseBase('/s/abc/')).toBe('/s/abc'); + }); + + it('treats anything else as no base', () => { + expect(normaliseBase(undefined)).toBe(''); + expect(normaliseBase('')).toBe(''); + expect(normaliseBase('/')).toBe(''); + expect(normaliseBase('https://evil.example')).toBe(''); + expect(normaliseBase(42)).toBe(''); + }); +}); + +describe('prefixApiPath', () => { + it('prefixes API paths only, and nothing without a base', () => { + expect(prefixApiPath('/s/abc', '/api/diff?ref=work')).toBe('/s/abc/api/diff?ref=work'); + expect(prefixApiPath('/s/abc', '/favicon.svg')).toBe('/favicon.svg'); + expect(prefixApiPath('', '/api/diff')).toBe('/api/diff'); + }); +}); + +describe('the API client', () => { + afterEach(() => { + delete window.__DIFFITY_BASE__; + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + async function requestedUrls(base: string | undefined): Promise { + if (base !== undefined) { + window.__DIFFITY_BASE__ = base; + } + vi.resetModules(); + const fetchMock = vi.fn(async () => new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } })); + vi.stubGlobal('fetch', fetchMock); + const api = await import('../src/lib/api'); + await api.fetchRepoInfo('work'); + await api.fetchThreads('sid'); + await api.fetchGitHubDetails(); + await api.updateThreadStatus('t1', 'resolved'); + return fetchMock.mock.calls.map(call => String((call as unknown[])[0])); + } + + it('asks the server at the root when the page has no base, as the CLI serves it', async () => { + expect(await requestedUrls(undefined)).toEqual([ + '/api/info?ref=work', + '/api/threads?session=sid', + '/api/github/details', + '/api/threads/t1/status', + ]); + }); + + it('asks under the base the hosted server injected', async () => { + expect(await requestedUrls('/s/abc')).toEqual([ + '/s/abc/api/info?ref=work', + '/s/abc/api/threads?session=sid', + '/s/abc/api/github/details', + '/s/abc/api/threads/t1/status', + ]); + }); +}); diff --git a/scripts/build.ts b/scripts/build.ts index b4b8c13c..27598f6d 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -15,6 +15,7 @@ const steps = [ 'npm run build -w @diffity/git', 'npm run build -w @diffity/github', 'npm run build -w @diffity/ui', + 'npm run build -w @diffity/server', 'npm run build -w @naturalcycles/diffity', ]; diff --git a/scripts/release.ts b/scripts/release.ts index 56b8e3dd..408449b9 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -13,6 +13,7 @@ const packagePaths = [ 'packages/git', 'packages/github', 'packages/parser', + 'packages/server', 'packages/ui', ]; diff --git a/skills/diffity-review-remote/SKILL.md b/skills/diffity-review-remote/SKILL.md new file mode 100644 index 00000000..3d6d3e23 --- /dev/null +++ b/skills/diffity-review-remote/SKILL.md @@ -0,0 +1,185 @@ +--- +name: diffity-review-remote +description: >- + Review a pushed pull request, commit range or patch on a hosted diffity server + through its MCP tools, and hand the user the review URL +user-invocable: true +--- + +# Diffity Remote Review Skill + +You are reviewing a change and leaving inline findings on a **hosted diffity server**, through its MCP +tools. There is no local `diffity` binary and no local server: the review lives on the server, the +user reads it in their browser, and you read the code from your own checkout. + +## Arguments + +- `target` (optional): what to review — a pull request (`#123`, a PR URL), a commit range + (`..`), or nothing. With nothing, review **the pull request for the current branch** if + there is one; if the work is not pushed, review it as a patch (see Step 1). +- `focus` (optional): one of `security`, `performance`, `naming`, `errors`, `types`, `logic`. If + omitted, review everything. + +## Tools + +The connector is usually added as `diffity`, so in Claude Code the tools are named +`mcp__diffity__`. + +``` +create_session { repo: "owner/name", pr? , base?, head?, patch? } → { session, url, base, head, files } +list_sessions { repo? } +get_diff { session, file? } unified diff; line numbers are in the @@ headers +get_file { session, path, side? } side "new" (head, default) or "old" (base) +get_standards { session } the project's standards and severity labels +review_start { session, note? } +review_done { session } +comment { session, file, line, endLine?, side?, body } +general_comment { session, body } +reply { id, body, aside? } +amend { id, body } a comment id, or a thread id for its finding +resolve { id, summary? } +dismiss { id, reason? } +list_comments { session, status? } +tour_start { session, topic, body? } → { tour } +tour_step { tour, file, line, endLine?, body, annotation? } +tour_done { tour } +tour_delete { tour } +``` + +- `create_session` takes the repository and **exactly one** of: `pr`; `base` and `head` (full + 40-character shas, both pushed); or `base` and `patch` (a unified diff against a pushed base). +- `session`, thread and tour ids accept the full id or its first 8 characters. +- Every tool reports a mistake as an error result with a message. Read it and correct the call; do + not retry blindly. + +If the tools are not available, tell the user to add the connector — +`claude mcp add --transport http diffity /mcp` — and stop. + +## Instructions + +### Step 1: Create the session + +1. Work out the repository: `git remote get-url origin` gives `owner/name`. +2. Decide what to review: + - A pull request: `gh pr view --json number,url` for the current branch, or the one named. Call + `create_session { repo, pr }`. The server pins the diff to the pull request's merge base, so it + matches what GitHub shows. + - A range whose commits are pushed: `create_session { repo, base, head }` with full shas + (`git rev-parse`). + - Work that is not pushed: pick a pushed base (`git merge-base origin/ HEAD`), and + send `git diff ` as `patch`. The server applies it to that base. +3. State which one you chose in your first message, so the user can correct you cheaply. +4. Keep the `session` id and the `url` from the result for everything that follows. + +If `create_session` says the repository cannot be read, the user has to add a GitHub token on the +server's `/settings` page. Tell them so, with the URL, and stop. + +### Step 2: Say that you have started + +Call `review_start { session, note: "" }` straight away. The page then shows +that a review is under way; without it a reader cannot tell "nothing found" from "not finished +looking". + +Call `review_done { session }` as the last thing you do — **after** the comments and the reading +order are in, including when you found nothing, and including when you give up early. + +### Step 3: Review the diff + +1. Get the diff from the server with `get_diff { session }`. This is the diff the reader sees, and + the line numbers you comment on must be the ones in its `@@` headers. Review from your own + checkout for context — the files around the change, callers, tests — but make sure the checkout + is at the reviewed head (`git fetch` and compare with the `head` the session returned); when it is + not, read files with `get_file` instead. +2. Read the project's standards with `get_standards { session }`. Whatever it returns outranks the + generic guidance here: it is what this team has agreed to review against. +3. Read the CLAUDE.md files that apply: the root one and those in directories with changed files. + +#### Adapt to the size of the change + +- **Small** (under ~100 changed lines, 1-3 files): review each file in order. +- **Medium** (100-500 lines, 3-10 files): group files by area, core logic first. +- **Large** (500+ lines or 10+ files): group by area, core logic first, then every remaining file. + For a mechanically repeated change, verify the pattern on the first instances, then check every + remaining one for deviations. + +Whatever the size, **read and review every changed file**. + +#### Understand the change before judging it + +Summarise the change for yourself first: what it is trying to do, which files carry the core logic +and which follow from it, what the author intended (commit messages, the pull request description). +Read each changed file in full, not just its hunks. For any changed signature, export, return type or +behaviour, find the callers and check they still hold. + +#### How to analyse + +- **Data flow** — where each value comes from and goes; null where the code assumes not; branches of + an upstream conditional the change does not handle. +- **State and lifecycle** — states that cannot be reached or left, resources not cleaned up on some + path, concurrent access, ordering invariants. +- **Contracts** — does the code still satisfy what callers expect; do API responses match clients. +- **Boundaries** — validation of user input and external data; injection (SQL, shell, XSS, path + traversal). +- **Edge cases that will happen** — empty inputs, zero, off-by-one, division by input. + +#### Completeness + +- New behaviour without tests, a bug fix without a regression test, changed behaviour with stale + tests: flag as the project's mildest severity unless its CLAUDE.md requires tests. +- Missing pieces clearly needed for the change to work: a migration, a config default, a client + update, a lockfile. + +#### What to flag, and how to validate it + +Flag real problems: code that will not compile or run, logic errors, security holes, demonstrable +races or data loss, CLAUDE.md violations you can quote, broken contracts, missing tests, incomplete +changes. Skip style, linter-catchable issues and problems in unchanged code. + +Before posting a finding, verify it: re-read the surrounding code, grep for the "missing" import, +read the actual call sites, confirm the CLAUDE.md rule applies to the file. A repeated pattern gets +one comment on its first occurrence and a mention in the summary. + +### Step 4: Leave the findings + +1. Order them by severity, most severe first, then by file order. +2. Prefix each with a severity label from `get_standards` (`P1: …`, `P2: …`, `P3: …` by default). The + most severe label means *this must not merge*; do not inflate. +3. Leave each as `comment { session, file, line, endLine?, side?, body }`: + - `side: "new"` (the default) for added or kept lines, `"old"` for removed ones. + - The file must be in the session's diff; the tool says so, with the file list, when it is not. + - **Lead with the problem.** Two or three sentences, around 60 words: the problem, its + consequence, the fix. At most one small code suggestion. Anything longer belongs in the general + comment or the walkthrough. +4. Then decide on a general comment (`general_comment { session, body }`): + - No findings → "No issues found. Checked for bugs and CLAUDE.md compliance." + - 1-2 findings → skip it unless there is a cross-cutting concern. + - 3+ findings, or a large diff → a short paragraph of themes, verdict first, no recap of the inline + findings and no severity prefixes. + - Name a severity only where a finding with it is open, and keep any count right. + +### Step 5: Set the reading order + +Unless the change is a single file, record the order it should be read in: + +``` +tour_start { session, topic: "Reading order", body: "" } +tour_step { tour, file, line, endLine?, body: "", annotation: "<3-6 words: why here>" } +tour_done { tour } +``` + +The `annotation` becomes the file's label in the reordered file list, so make it say *why* the file +is read at that point ("the primitive", "first consumer"). Point each step at the most important +lines, not line 1. If a step went in wrong, `tour_delete` and build it again. + +Then call `review_done { session }`. + +### Step 6: Hand over the review + +Give the user the session `url` and the counts, using the labels you used: + +> Review ready: +> +> Found: 1 P1, 2 P2. The file list is in reading order; the P1 is on the last stop. + +The user signs in on that page the first time. Findings are not posted to GitHub from the hosted +server yet; the page is where they are read.