From fdd9ba8d5683f024366194554fa77bb3a7fd6155 Mon Sep 17 00:00:00 2001 From: Jan Willem Mannaerts Date: Thu, 26 Feb 2026 21:38:37 +0100 Subject: [PATCH] Initial commit: Pokerface sprint planning poker for Jira Full-stack app with Express/Socket.io backend, React frontend, NATS JetStream for state, and Atlassian Jira OAuth integration. Includes security hardening: NATS auth support, KV bucket TTL enforcement, CAS retry for race conditions, error message sanitization, and OAuth state stored in NATS KV. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 8 + Dockerfile | 23 + backend/.env.example | 12 + backend/package-lock.json | 1648 ++++++++++++++ backend/package.json | 24 + backend/src/index.js | 294 +++ backend/src/lib/errors.js | 5 + backend/src/lib/nats.js | 78 + backend/src/middleware/auth.js | 74 + backend/src/routes/jira.js | 175 ++ backend/src/routes/poker.js | 65 + backend/src/routes/rooms.js | 98 + backend/src/services/jiraService.js | 285 +++ backend/src/services/pokerService.js | 250 +++ backend/src/services/roomService.js | 124 + frontend/.env.example | 1 + frontend/index.html | 13 + frontend/package-lock.json | 2375 ++++++++++++++++++++ frontend/package.json | 23 + frontend/public/favicon.svg | 5 + frontend/src/App.jsx | 137 ++ frontend/src/components/AdfRenderer.jsx | 194 ++ frontend/src/components/DarkModeToggle.jsx | 19 + frontend/src/components/LegalPage.jsx | 239 ++ frontend/src/components/Lobby.jsx | 269 +++ frontend/src/components/LoginScreen.jsx | 72 + frontend/src/components/PokerRoom.jsx | 255 +++ frontend/src/components/Room.jsx | 210 ++ frontend/src/components/SearchSelect.jsx | 107 + frontend/src/main.jsx | 10 + frontend/src/services/api.js | 55 + frontend/src/services/socket.js | 25 + frontend/src/styles.css | 58 + frontend/vite.config.js | 22 + package-lock.json | 330 +++ package.json | 14 + 36 files changed, 7596 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 backend/.env.example create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/src/index.js create mode 100644 backend/src/lib/errors.js create mode 100644 backend/src/lib/nats.js create mode 100644 backend/src/middleware/auth.js create mode 100644 backend/src/routes/jira.js create mode 100644 backend/src/routes/poker.js create mode 100644 backend/src/routes/rooms.js create mode 100644 backend/src/services/jiraService.js create mode 100644 backend/src/services/pokerService.js create mode 100644 backend/src/services/roomService.js create mode 100644 frontend/.env.example create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/components/AdfRenderer.jsx create mode 100644 frontend/src/components/DarkModeToggle.jsx create mode 100644 frontend/src/components/LegalPage.jsx create mode 100644 frontend/src/components/Lobby.jsx create mode 100644 frontend/src/components/LoginScreen.jsx create mode 100644 frontend/src/components/PokerRoom.jsx create mode 100644 frontend/src/components/Room.jsx create mode 100644 frontend/src/components/SearchSelect.jsx create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/services/api.js create mode 100644 frontend/src/services/socket.js create mode 100644 frontend/src/styles.css create mode 100644 frontend/vite.config.js create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3574a9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +.env +.DS_Store +backend/node_modules +frontend/node_modules +backend/prisma/migrations +frontend/dist +backend/.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1b11e15 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-alpine AS frontend + +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +FROM node:22-alpine + +WORKDIR /app/backend +COPY backend/package.json backend/package-lock.json ./ +RUN npm ci --omit=dev +COPY backend/ ./ + +COPY --from=frontend /app/frontend/dist /app/frontend/dist + +ENV NODE_ENV=production +ENV PORT=4010 + +EXPOSE 4010 + +CMD ["node", "src/index.js"] diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..a045767 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,12 @@ +PORT=4010 +FRONTEND_URL=http://localhost:5174 +NATS_URL=nats://localhost:4222 + +# Jira OAuth (Atlassian 3LO) +JIRA_CLIENT_ID= +JIRA_CLIENT_SECRET= +JIRA_OAUTH_REDIRECT_URI=http://localhost:4010/api/jira/oauth/callback +JIRA_SCOPES="offline_access read:jira-work write:jira-work read:me" +JWT_SECRET=change-me-to-a-random-secret +JIRA_STORY_POINTS_FIELD=customfield_10016 +JIRA_MOCK_FALLBACK=true diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..6fd0aa9 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1648 @@ +{ + "name": "pokerface-backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pokerface-backend", + "version": "0.1.0", + "dependencies": { + "@mickl/socket.io-nats-adapter": "^2.0.0", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.1", + "jsonwebtoken": "^9.0.3", + "nats": "^2.28.2", + "socket.io": "^4.8.1" + }, + "devDependencies": { + "nodemon": "^3.1.7" + } + }, + "node_modules/@mickl/socket.io-nats-adapter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@mickl/socket.io-nats-adapter/-/socket.io-nats-adapter-2.0.1.tgz", + "integrity": "sha512-mktFWy0TP7cOh2vmRpG1fzKKTGqfoiOGgm9b/ZVu60sj72T5T6k8Y94502TygLMz88xtg5XVf0ZS+ydx2LQe6A==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.1", + "socket.io-adapter": "^2.2.0", + "uid2": "0.0.3" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "nats": "^2.0.0" + } + }, + "node_modules/@mickl/socket.io-nats-adapter/node_modules/uid2": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz", + "integrity": "sha512-5gSP1liv10Gjp8cMEnFd6shzkL/D6W1uhXSFNCxDC+YI8+L8wkCYCbJ7n77Ezb4wE/xzMogecE+DtamEe9PZjg==" + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "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/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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "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/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/engine.io": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", + "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/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-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/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/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/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": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "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/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-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", + "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nats": { + "version": "2.29.3", + "resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz", + "integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==", + "license": "Apache-2.0", + "dependencies": { + "nkeys.js": "1.1.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nkeys.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz", + "integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==", + "license": "Apache-2.0", + "dependencies": { + "tweetnacl": "1.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/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-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/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/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..13b71f7 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,24 @@ +{ + "name": "pokerface-backend", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.js", + "scripts": { + "dev": "nodemon src/index.js", + "start": "node src/index.js" + }, + "dependencies": { + "@mickl/socket.io-nats-adapter": "^2.0.0", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.1", + "jsonwebtoken": "^9.0.3", + "nats": "^2.28.2", + "socket.io": "^4.8.1" + }, + "devDependencies": { + "nodemon": "^3.1.7" + } +} diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..d485091 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,294 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createServer } from 'http'; +import express from 'express'; +import cors from 'cors'; +import cookieParser from 'cookie-parser'; +import dotenv from 'dotenv'; +import { Server } from 'socket.io'; +import natsAdapter from '@mickl/socket.io-nats-adapter'; +const { createAdapter } = natsAdapter; +import { connectNats, getNatsConnection } from './lib/nats.js'; +import pokerRoutes from './routes/poker.js'; +import jiraRoutes from './routes/jira.js'; +import roomRoutes from './routes/rooms.js'; +import { + canAccessSession, + getSessionSnapshot, + isSessionParticipant, + joinSession, + leaveSession, + revealIfComplete, + saveScopedEstimate, + submitVote +} from './services/pokerService.js'; +import { updateIssueEstimate } from './services/jiraService.js'; +import { getSocketUser } from './middleware/auth.js'; +import { safeError } from './lib/errors.js'; + +dotenv.config(); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const isProd = process.env.NODE_ENV === 'production'; + +const app = express(); +const httpServer = createServer(app); + +const port = Number(process.env.PORT || 4010); +if (isProd && !process.env.FRONTEND_URL) { + throw new Error('FRONTEND_URL must be set in production.'); +} +const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5174'; +const corsOptions = { origin: frontendUrl, credentials: true }; + +function isAllowedOrigin(origin) { + if (!origin) return !isProd; + return origin === frontendUrl; +} + +app.disable('x-powered-by'); +app.use((_req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); + if (isProd) { + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + next(); +}); + +app.use(cors(corsOptions)); +app.use(cookieParser()); +app.use(express.json({ limit: '1mb' })); + +app.get('/api/health', (_req, res) => { + res.json({ status: 'ok' }); +}); + +app.use('/api/poker', pokerRoutes); +app.use('/api/jira', jiraRoutes); +app.use('/api/poker/rooms', roomRoutes); + +if (isProd) { + const distPath = path.resolve(__dirname, '../../frontend/dist'); + app.use(express.static(distPath)); + app.get('*', (req, res) => { + if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' }); + res.sendFile(path.join(distPath, 'index.html')); + }); +} + +const io = new Server(httpServer, { + cors: corsOptions, + allowRequest: (req, callback) => { + if (isAllowedOrigin(req.headers.origin)) { + callback(null, true); + return; + } + callback('Origin not allowed', false); + } +}); + +async function emitSessionState(sessionId) { + const snapshot = await getSessionSnapshot(sessionId); + if (!snapshot) return; + + io.to(`poker:${sessionId}`).emit('poker:participants', { + sessionId, + participants: snapshot.participants, + votedUserKeys: snapshot.votedUserKeys, + voteCount: snapshot.voteCount, + participantCount: snapshot.participantCount + }); + + io.to(`poker:${sessionId}`).emit('poker:vote-update', { + sessionId, + voteCount: snapshot.voteCount, + participantCount: snapshot.participantCount, + votedUserKeys: snapshot.votedUserKeys, + allVoted: snapshot.voteCount > 0 && snapshot.voteCount === snapshot.participantCount + }); + + if (snapshot.session.state === 'REVEALED' || snapshot.session.state === 'SAVED') { + io.to(`poker:${sessionId}`).emit('poker:revealed', { + sessionId, + votes: snapshot.votesByUser, + average: snapshot.session.averageEstimate, + suggestedEstimate: snapshot.session.suggestedEstimate, + savedEstimate: snapshot.session.savedEstimate + }); + } +} + +io.on('connection', (socket) => { + const user = getSocketUser(socket); + if (!user || !user.jiraCloudId) { + socket.emit('poker:error', { error: 'Not authenticated' }); + socket.disconnect(true); + return; + } + socket.user = user; + + socket.on('poker:join', async ({ sessionId }) => { + try { + if (!sessionId) { + socket.emit('poker:error', { error: 'sessionId is required.' }); + return; + } + + if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) { + socket.emit('poker:error', { error: 'Session not found.' }); + return; + } + + socket.join(`poker:${sessionId}`); + const snapshot = await joinSession({ + sessionId, + tenantCloudId: socket.user.jiraCloudId, + userKey: socket.user.jiraAccountId, + userName: socket.user.displayName, + avatarUrl: socket.user.avatarUrl || null + }); + if (!snapshot) { + socket.emit('poker:error', { error: 'Session not found.' }); + return; + } + await emitSessionState(sessionId); + } catch (error) { + console.error('[socket] poker:join failed:', error); + socket.emit('poker:error', { error: safeError(error) }); + } + }); + + socket.on('poker:vote', async ({ sessionId, vote }) => { + try { + if (!sessionId) { + socket.emit('poker:error', { error: 'sessionId is required.' }); + return; + } + + if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) { + socket.emit('poker:error', { error: 'Session not found.' }); + return; + } + if (!await isSessionParticipant(sessionId, socket.user.jiraAccountId)) { + socket.emit('poker:error', { error: 'Join the session before voting.' }); + return; + } + + const voteResult = await submitVote({ + sessionId, + tenantCloudId: socket.user.jiraCloudId, + userKey: socket.user.jiraAccountId, + vote + }); + if (!voteResult) { + socket.emit('poker:error', { error: 'Unable to submit vote for this session.' }); + return; + } + const reveal = await revealIfComplete(sessionId); + + await emitSessionState(sessionId); + + if (reveal?.allVoted) { + io.to(`poker:${sessionId}`).emit('poker:revealed', { + sessionId, + votes: reveal.votesByUser, + average: reveal.average, + suggestedEstimate: reveal.suggestedEstimate + }); + } + } catch (error) { + console.error('[socket] poker:vote failed:', error); + socket.emit('poker:error', { error: safeError(error) }); + } + }); + + socket.on('poker:save', async ({ sessionId, estimate }) => { + try { + if (!sessionId) { + socket.emit('poker:error', { error: 'sessionId is required.' }); + return; + } + + if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) { + socket.emit('poker:error', { error: 'Session not found.' }); + return; + } + if (!await isSessionParticipant(sessionId, socket.user.jiraAccountId)) { + socket.emit('poker:error', { error: 'Join the session before saving.' }); + return; + } + + const numericEstimate = Number(estimate); + if (!Number.isFinite(numericEstimate)) { + socket.emit('poker:error', { error: 'estimate must be a number.' }); + return; + } + + const saved = await saveScopedEstimate({ + sessionId, + estimate: numericEstimate, + tenantCloudId: socket.user.jiraCloudId, + userKey: socket.user.jiraAccountId + }); + if (!saved) { + socket.emit('poker:error', { error: 'Unable to save estimate for this session.' }); + return; + } + + const issueRef = saved.session.issueId || saved.session.issueKey; + try { + await updateIssueEstimate(socket.user.jiraAccountId, issueRef, numericEstimate, saved.session.boardId); + } catch (_jiraError) { + // Jira update is best-effort so poker flow continues even when Jira is unavailable. + } + + io.to(`poker:${sessionId}`).emit('poker:saved', { + sessionId, + estimate: numericEstimate, + issueKey: saved.session.issueKey + }); + + io.to(`poker:${sessionId}`).emit('poker:ended', { sessionId }); + } catch (error) { + console.error('[socket] poker:save failed:', error); + socket.emit('poker:error', { error: safeError(error) }); + } + }); + + socket.on('poker:leave', async ({ sessionId }) => { + try { + if (!sessionId) return; + if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) return; + await leaveSession({ + sessionId, + tenantCloudId: socket.user.jiraCloudId, + userKey: socket.user.jiraAccountId + }); + socket.leave(`poker:${sessionId}`); + await emitSessionState(sessionId); + } catch (error) { + console.error('[socket] poker:leave failed:', error); + socket.emit('poker:error', { error: safeError(error) }); + } + }); +}); + +async function start() { + const nc = await connectNats(); + io.adapter(createAdapter(nc)); + + httpServer.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`Pokerface backend listening on :${port}`); + }); +} + +start().catch((error) => { + // eslint-disable-next-line no-console + console.error('Failed to start server:', error); + process.exit(1); +}); diff --git a/backend/src/lib/errors.js b/backend/src/lib/errors.js new file mode 100644 index 0000000..e0a463d --- /dev/null +++ b/backend/src/lib/errors.js @@ -0,0 +1,5 @@ +const isProd = process.env.NODE_ENV === 'production'; + +export function safeError(error) { + return isProd ? 'Internal server error.' : error.message; +} diff --git a/backend/src/lib/nats.js b/backend/src/lib/nats.js new file mode 100644 index 0000000..9456e70 --- /dev/null +++ b/backend/src/lib/nats.js @@ -0,0 +1,78 @@ +import { connect } from 'nats'; + +const natsUrl = process.env.NATS_URL || 'nats://localhost:4222'; +const BUCKET_TTL_MS = 24 * 60 * 60 * 1000; // 24h in milliseconds + +let nc = null; +let js = null; +let kvOAuth = null; +let kvRooms = null; +let kvSprintIndex = null; +let kvActiveRooms = null; +let kvSessions = null; +let kvSessionIndex = null; +let kvOAuthState = null; + +export async function connectNats() { + const opts = { servers: natsUrl }; + if (process.env.NATS_TOKEN) { + opts.token = process.env.NATS_TOKEN; + } else if (process.env.NATS_USER) { + opts.user = process.env.NATS_USER; + opts.pass = process.env.NATS_PASS || ''; + } + nc = await connect(opts); + js = nc.jetstream(); + + async function getOrCreateKv(name, ttl = BUCKET_TTL_MS) { + const kv = await js.views.kv(name, { ttl }); + // Enforce TTL on existing buckets whose config may be stale + const jsm = await nc.jetstreamManager(); + const info = await jsm.streams.info(`KV_${name}`); + if (info.config.max_age !== ttl * 1_000_000) { // NATS uses nanoseconds + info.config.max_age = ttl * 1_000_000; + await jsm.streams.update(info.config.name, info.config); + } + return kv; + } + + kvOAuth = await getOrCreateKv('oauth'); + kvRooms = await getOrCreateKv('rooms'); + kvSprintIndex = await getOrCreateKv('rooms-sprint-index'); + kvActiveRooms = await getOrCreateKv('active-rooms'); + kvSessions = await getOrCreateKv('sessions'); + kvSessionIndex = await getOrCreateKv('session-issue-index'); + kvOAuthState = await getOrCreateKv('oauth-state', 10 * 60 * 1000); // 10min TTL + + // eslint-disable-next-line no-console + console.log('Connected to NATS at', natsUrl); + + return nc; +} + +export function getNatsConnection() { + return nc; +} + +export async function withCasRetry(kv, key, transformFn, maxRetries = 5) { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const entry = await kv.get(key); + const current = entry ? entry.json() : null; + const revision = entry ? entry.revision : 0; + const result = await transformFn(current, revision); + if (result === undefined) return current; // no-op + try { + if (revision === 0) { + await kv.create(key, JSON.stringify(result)); + } else { + await kv.update(key, JSON.stringify(result), revision); + } + return result; + } catch (err) { + if (attempt === maxRetries) throw err; + // CAS conflict — retry + } + } +} + +export { nc, js, kvOAuth, kvRooms, kvSprintIndex, kvActiveRooms, kvSessions, kvSessionIndex, kvOAuthState }; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..9d99ed1 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,74 @@ +import jwt from 'jsonwebtoken'; + +const COOKIE_NAME = 'pokerface_session'; +const JWT_EXPIRY = '24h'; +const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +function getSecret() { + const secret = process.env.JWT_SECRET; + if (!secret) throw new Error('Missing JWT_SECRET env var'); + return secret; +} + +export function createSessionToken(user) { + return jwt.sign( + { + jiraAccountId: user.jiraAccountId, + jiraCloudId: user.jiraCloudId, + displayName: user.displayName, + avatarUrl: user.avatarUrl + }, + getSecret(), + { expiresIn: JWT_EXPIRY } + ); +} + +export function setSessionCookie(res, token) { + res.cookie(COOKIE_NAME, token, { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: SESSION_MAX_AGE_MS + }); +} + +export function clearSessionCookie(res) { + res.clearCookie(COOKIE_NAME); +} + +function verifyToken(token) { + try { + return jwt.verify(token, getSecret()); + } catch { + return null; + } +} + +export function requireAuth(req, res, next) { + const token = req.cookies?.[COOKIE_NAME]; + if (!token) { + return res.status(401).json({ error: 'Not authenticated' }); + } + + const user = verifyToken(token); + if (!user) { + return res.status(401).json({ error: 'Invalid or expired session' }); + } + + if (!user.jiraAccountId || !user.jiraCloudId) { + return res.status(401).json({ error: 'Invalid session tenant context' }); + } + + req.user = user; + next(); +} + +export function getSocketUser(socket) { + const cookieHeader = socket.handshake.headers.cookie; + if (!cookieHeader) return null; + + const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`)); + if (!match) return null; + + return verifyToken(match[1]); +} diff --git a/backend/src/routes/jira.js b/backend/src/routes/jira.js new file mode 100644 index 0000000..3865981 --- /dev/null +++ b/backend/src/routes/jira.js @@ -0,0 +1,175 @@ +import crypto from 'crypto'; +import express from 'express'; +import { + buildOAuthUrl, + exchangeCodeForToken, + getProjects, + getBoards, + getBoardSprints, + getSprintIssues, + saveOAuthConnection, + updateIssueEstimate +} from '../services/jiraService.js'; +import { + createSessionToken, + setSessionCookie, + clearSessionCookie, + requireAuth +} from '../middleware/auth.js'; +import { kvOAuthState } from '../lib/nats.js'; +import { safeError } from '../lib/errors.js'; + +const router = express.Router(); + +router.get('/oauth/start', async (_req, res) => { + try { + const state = crypto.randomBytes(24).toString('hex'); + await kvOAuthState.put(state, 'valid'); + + const url = buildOAuthUrl(state); + res.json({ url }); + } catch (error) { + console.error('[oauth] Start failed:', error); + res.status(500).json({ error: safeError(error) }); + } +}); + +router.get('/oauth/callback', async (req, res) => { + const { state, code } = req.query; + const isProd = process.env.NODE_ENV === 'production'; + const frontendUrl = process.env.FRONTEND_URL || (isProd ? '' : 'http://localhost:5174'); + + try { + const entry = await kvOAuthState.get(String(state)); + if (!state || !entry) { + return res.status(400).send('Invalid OAuth state.'); + } + + if (!code) { + return res.status(400).send('Missing OAuth code.'); + } + + await kvOAuthState.delete(String(state)); + + const tokenPayload = await exchangeCodeForToken(String(code)); + const { connection, profile } = await saveOAuthConnection(tokenPayload); + if (!connection.cloudId) { + throw new Error('No Jira cloud site available for this account.'); + } + + const jwt = createSessionToken({ + jiraAccountId: profile.accountId, + jiraCloudId: connection.cloudId, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl + }); + setSessionCookie(res, jwt); + + res.redirect(`${frontendUrl}?auth=success`); + } catch (error) { + console.error('[oauth] Callback failed:', error.message); + const safeMessage = isProd ? 'Jira authentication failed.' : error.message; + res.redirect(`${frontendUrl}?auth=error&message=${encodeURIComponent(safeMessage)}`); + } +}); + +router.get('/me', requireAuth, (req, res) => { + res.json({ + jiraAccountId: req.user.jiraAccountId, + jiraCloudId: req.user.jiraCloudId, + displayName: req.user.displayName, + avatarUrl: req.user.avatarUrl + }); +}); + +router.post('/logout', (_req, res) => { + clearSessionCookie(res); + res.json({ ok: true }); +}); + +router.get('/projects', requireAuth, async (req, res) => { + try { + const projects = await getProjects(req.user.jiraAccountId); + res.json({ projects }); + } catch (error) { + console.error('[jira] Projects failed:', error); + res.status(500).json({ error: safeError(error) }); + } +}); + +router.get('/boards', requireAuth, async (req, res) => { + try { + const { projectKeyOrId } = req.query; + if (!projectKeyOrId) { + return res.status(400).json({ error: 'projectKeyOrId query parameter is required.' }); + } + const boards = await getBoards(req.user.jiraAccountId, projectKeyOrId); + res.json({ boards }); + } catch (error) { + console.error('[jira] Boards failed:', error); + res.status(500).json({ error: safeError(error) }); + } +}); + +router.get('/boards/:boardId/sprints', requireAuth, async (req, res) => { + try { + const sprints = await getBoardSprints(req.user.jiraAccountId, req.params.boardId); + res.json({ sprints }); + } catch (error) { + console.error('[jira] Sprints failed:', error); + res.status(500).json({ error: safeError(error) }); + } +}); + +router.get('/status', requireAuth, async (req, res) => { + try { + res.json({ connected: true, jiraAccountId: req.user.jiraAccountId }); + } catch (error) { + console.error('[jira] Status failed:', error); + res.status(500).json({ error: safeError(error) }); + } +}); + +router.get('/sprints/:sprintId/issues', requireAuth, async (req, res) => { + try { + const { sprintId } = req.params; + const { boardId } = req.query; + const issues = await getSprintIssues(req.user.jiraAccountId, String(sprintId), boardId ? String(boardId) : null); + res.json({ issues, source: 'jira' }); + } catch (error) { + if (process.env.JIRA_MOCK_FALLBACK === 'true') { + const sprintId = req.params.sprintId; + const issues = [ + { id: `mock-${sprintId}-1`, key: `MOCK-${sprintId}1`, title: 'Authentication hardening', description: 'Harden authentication flow against common attacks.', estimate: 0, status: 'To Do', assignee: 'Alice' }, + { id: `mock-${sprintId}-2`, key: `MOCK-${sprintId}2`, title: 'Webhook retries', description: 'Add retry logic with exponential backoff for failed webhooks.', estimate: 0, status: 'To Do', assignee: 'Bob' }, + { id: `mock-${sprintId}-3`, key: `MOCK-${sprintId}3`, title: 'Billing summary endpoint', description: 'Create GET /billing/summary endpoint.', estimate: 5, status: 'In Progress', assignee: 'Eve' } + ]; + return res.json({ issues, source: 'mock' }); + } + + console.error('[jira] Sprint issues failed:', error); + res.status(500).json({ error: safeError(error) }); + } +}); + +router.patch('/issues/:issueIdOrKey/estimate', requireAuth, async (req, res) => { + try { + const { issueIdOrKey } = req.params; + const { estimate, boardId } = req.body; + + if (estimate === undefined || estimate === null) { + return res.status(400).json({ error: 'estimate is required.' }); + } + if (!boardId) { + return res.status(400).json({ error: 'boardId is required.' }); + } + + await updateIssueEstimate(req.user.jiraAccountId, issueIdOrKey, Number(estimate), String(boardId)); + res.json({ ok: true }); + } catch (error) { + console.error('[jira] Estimate update failed:', error); + res.status(500).json({ error: safeError(error) }); + } +}); + +export default router; diff --git a/backend/src/routes/poker.js b/backend/src/routes/poker.js new file mode 100644 index 0000000..8f76d7f --- /dev/null +++ b/backend/src/routes/poker.js @@ -0,0 +1,65 @@ +import express from 'express'; +import { requireAuth } from '../middleware/auth.js'; +import { canAccessSession, createScopedSession, getSessionSnapshot } from '../services/pokerService.js'; +import { getRoomById } from '../services/roomService.js'; +import { safeError } from '../lib/errors.js'; + +const router = express.Router(); + +router.post('/sessions', requireAuth, async (req, res) => { + try { + const { issueKey, issueId, issueTitle, roomId } = req.body; + + if (!req.user.jiraCloudId) { + return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' }); + } + + if (!issueKey || !issueTitle) { + return res.status(400).json({ error: 'issueKey and issueTitle are required.' }); + } + if (!roomId) { + return res.status(400).json({ error: 'roomId is required.' }); + } + + const room = await getRoomById(String(roomId), req.user.jiraCloudId); + if (!room) { + return res.status(404).json({ error: 'Room not found.' }); + } + + const snapshot = await createScopedSession({ + issueKey, + issueId, + issueTitle, + roomId: String(roomId), + boardId: room.boardId, + tenantCloudId: req.user.jiraCloudId + }); + return res.json({ session: snapshot.session }); + } catch (error) { + console.error('[poker] Create session failed:', error); + return res.status(500).json({ error: safeError(error) }); + } +}); + +router.get('/sessions/:sessionId', requireAuth, async (req, res) => { + try { + if (!req.user.jiraCloudId) { + return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' }); + } + + if (!await canAccessSession(req.params.sessionId, req.user.jiraCloudId)) { + return res.status(404).json({ error: 'Session not found.' }); + } + + const snapshot = await getSessionSnapshot(req.params.sessionId); + if (!snapshot) { + return res.status(404).json({ error: 'Session not found.' }); + } + res.json(snapshot); + } catch (error) { + console.error('[poker] Get session failed:', error); + res.status(500).json({ error: safeError(error) }); + } +}); + +export default router; diff --git a/backend/src/routes/rooms.js b/backend/src/routes/rooms.js new file mode 100644 index 0000000..56943c5 --- /dev/null +++ b/backend/src/routes/rooms.js @@ -0,0 +1,98 @@ +import express from 'express'; +import { requireAuth } from '../middleware/auth.js'; +import { createRoom, getActiveRooms, getRoomById, deleteRoom } from '../services/roomService.js'; +import { safeError } from '../lib/errors.js'; + +const router = express.Router(); + +router.post('/', requireAuth, async (req, res) => { + try { + if (!req.user.jiraCloudId) { + return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' }); + } + + const { boardId, projectKey, projectName, sprintId, sprintName } = req.body; + + if (!boardId || !sprintId || !sprintName) { + return res.status(400).json({ error: 'boardId, sprintId, and sprintName are required.' }); + } + + const numericBoardId = Number(boardId); + const numericSprintId = Number(sprintId); + if (!Number.isInteger(numericBoardId) || !Number.isInteger(numericSprintId) || numericBoardId <= 0 || numericSprintId <= 0) { + return res.status(400).json({ error: 'boardId and sprintId must be valid integers.' }); + } + + const room = await createRoom({ + cloudId: req.user.jiraCloudId, + boardId: numericBoardId, + projectKey: projectKey || '', + projectName: projectName || '', + sprintId: numericSprintId, + sprintName, + createdByAccountId: req.user.jiraAccountId, + createdByName: req.user.displayName + }); + + res.json({ room }); + } catch (error) { + console.error('[rooms] Create room failed:', error); + res.status(500).json({ error: safeError(error) }); + } +}); + +router.get('/', requireAuth, async (req, res) => { + try { + if (!req.user.jiraCloudId) { + return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' }); + } + + const rooms = await getActiveRooms(req.user.jiraCloudId); + res.json({ rooms }); + } catch (error) { + console.error('[rooms] Get active rooms failed:', error); + res.status(500).json({ error: safeError(error) }); + } +}); + +router.get('/:roomId', requireAuth, async (req, res) => { + try { + if (!req.user.jiraCloudId) { + return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' }); + } + + const room = await getRoomById(req.params.roomId, req.user.jiraCloudId); + if (!room) { + return res.status(404).json({ error: 'Room not found.' }); + } + res.json({ room }); + } catch (error) { + console.error('[rooms] Get room failed:', error); + res.status(500).json({ error: safeError(error) }); + } +}); + +router.delete('/:roomId', requireAuth, async (req, res) => { + try { + if (!req.user.jiraCloudId) { + return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' }); + } + + const room = await getRoomById(req.params.roomId, req.user.jiraCloudId); + if (!room) { + return res.status(404).json({ error: 'Room not found.' }); + } + + if (room.createdByAccountId !== req.user.jiraAccountId) { + return res.status(403).json({ error: 'Only the room creator can delete this room.' }); + } + + await deleteRoom(req.params.roomId, req.user.jiraCloudId); + res.json({ ok: true }); + } catch (error) { + console.error('[rooms] Delete room failed:', error); + res.status(500).json({ error: safeError(error) }); + } +}); + +export default router; diff --git a/backend/src/services/jiraService.js b/backend/src/services/jiraService.js new file mode 100644 index 0000000..41ac5a0 --- /dev/null +++ b/backend/src/services/jiraService.js @@ -0,0 +1,285 @@ +import { kvOAuth } from '../lib/nats.js'; + +const ATLASSIAN_AUTH_URL = 'https://auth.atlassian.com/authorize'; +const ATLASSIAN_TOKEN_URL = 'https://auth.atlassian.com/oauth/token'; +const ATLASSIAN_RESOURCES_URL = 'https://api.atlassian.com/oauth/token/accessible-resources'; +const ATLASSIAN_ME_URL = 'https://api.atlassian.com/me'; + +function getRequiredEnv(name) { + const value = process.env[name]; + if (!value) throw new Error(`Missing required env var: ${name}`); + return value; +} + +function getExpiresAt(expiresInSeconds) { + if (!expiresInSeconds) return null; + return Date.now() + (Number(expiresInSeconds) - 60) * 1000; +} + +export function buildOAuthUrl(state) { + const clientId = getRequiredEnv('JIRA_CLIENT_ID'); + const redirectUri = getRequiredEnv('JIRA_OAUTH_REDIRECT_URI'); + const scope = process.env.JIRA_SCOPES || 'offline_access read:jira-work write:jira-work read:me'; + + const params = new URLSearchParams({ + audience: 'api.atlassian.com', + client_id: clientId, + scope, + redirect_uri: redirectUri, + state, + response_type: 'code', + prompt: 'consent' + }); + + return `${ATLASSIAN_AUTH_URL}?${params.toString()}`; +} + +export async function exchangeCodeForToken(code) { + const body = { + grant_type: 'authorization_code', + client_id: getRequiredEnv('JIRA_CLIENT_ID'), + client_secret: getRequiredEnv('JIRA_CLIENT_SECRET'), + code, + redirect_uri: getRequiredEnv('JIRA_OAUTH_REDIRECT_URI') + }; + + const response = await fetch(ATLASSIAN_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Token exchange failed: ${response.status} ${errorText}`); + } + + return response.json(); +} + +async function getAccessibleResources(accessToken) { + const response = await fetch(ATLASSIAN_RESOURCES_URL, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json' + } + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to load Jira resources: ${response.status} ${errorText}`); + } + + return response.json(); +} + +export async function fetchJiraProfile(accessToken) { + const response = await fetch(ATLASSIAN_ME_URL, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json' + } + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to fetch Jira profile: ${response.status} ${errorText}`); + } + + const data = await response.json(); + return { + accountId: data.account_id, + displayName: data.name || data.displayName || 'Unknown', + avatarUrl: data.picture || null, + email: data.email || null + }; +} + +export async function saveOAuthConnection(tokenPayload) { + const profile = await fetchJiraProfile(tokenPayload.access_token); + const resources = await getAccessibleResources(tokenPayload.access_token); + const primary = resources[0] || {}; + + const connection = { + jiraAccountId: profile.accountId, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + email: profile.email, + cloudId: primary.id || null, + siteUrl: primary.url || null, + accessToken: tokenPayload.access_token, + refreshToken: tokenPayload.refresh_token || null, + scope: tokenPayload.scope || null, + expiresAt: getExpiresAt(tokenPayload.expires_in) + }; + + await kvOAuth.put(`oauth.${profile.accountId}`, JSON.stringify(connection)); + + return { connection, profile }; +} + +async function refreshAccessToken(connection) { + if (!connection.refreshToken) { + throw new Error('Jira refresh token is missing. Reconnect Jira OAuth.'); + } + + const body = { + grant_type: 'refresh_token', + client_id: getRequiredEnv('JIRA_CLIENT_ID'), + client_secret: getRequiredEnv('JIRA_CLIENT_SECRET'), + refresh_token: connection.refreshToken + }; + + const response = await fetch(ATLASSIAN_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Token refresh failed: ${response.status} ${errorText}`); + } + + const refreshed = await response.json(); + + const updated = { + ...connection, + accessToken: refreshed.access_token, + refreshToken: refreshed.refresh_token || connection.refreshToken, + scope: refreshed.scope || connection.scope, + expiresAt: getExpiresAt(refreshed.expires_in) + }; + + await kvOAuth.put(`oauth.${connection.jiraAccountId}`, JSON.stringify(updated)); + return updated; +} + +async function getValidConnection(jiraAccountId) { + const entry = await kvOAuth.get(`oauth.${jiraAccountId}`); + if (!entry) throw new Error('Jira is not connected for this account.'); + + const connection = entry.json(); + if (!connection.accessToken || !connection.cloudId) { + throw new Error('Jira is not connected for this account.'); + } + + if (!connection.expiresAt || connection.expiresAt > Date.now()) { + return connection; + } + + return refreshAccessToken(connection); +} + +async function jiraFetch(jiraAccountId, path, options = {}) { + const connection = await getValidConnection(jiraAccountId); + const url = `https://api.atlassian.com/ex/jira/${connection.cloudId}${path}`; + + const response = await fetch(url, { + ...options, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${connection.accessToken}`, + ...(options.headers || {}) + } + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Jira API failed: ${response.status} ${errorText}`); + } + + if (response.status === 204) return null; + return response.json(); +} + +function normalizeIssue(issue) { + return { + id: issue.id, + key: issue.key, + title: issue.fields?.summary || issue.key, + description: issue.fields?.description || null, + estimate: issue.fields?.[process.env.JIRA_STORY_POINTS_FIELD || 'customfield_10016'] || 0, + status: issue.fields?.status?.name || 'Unknown', + reporter: issue.fields?.reporter?.displayName || null + }; +} + +export async function getSprintIssues(jiraAccountId, sprintId, boardId) { + let spField = process.env.JIRA_STORY_POINTS_FIELD || 'customfield_10016'; + if (boardId) { + try { + const config = await jiraFetch(jiraAccountId, `/rest/agile/1.0/board/${boardId}/configuration`); + const estField = config?.estimation?.field?.fieldId; + if (estField) spField = estField; + } catch (_e) { + // fall back to default + } + } + + const response = await jiraFetch( + jiraAccountId, + `/rest/agile/1.0/sprint/${sprintId}/issue?maxResults=200&jql=${encodeURIComponent('resolution = Unresolved AND issuetype not in subtaskIssueTypes()')}&fields=summary,status,reporter,description,${spField}` + ); + + return (response?.issues || []).map((issue) => ({ + id: issue.id, + key: issue.key, + title: issue.fields?.summary || issue.key, + description: issue.fields?.description || null, + estimate: issue.fields?.[spField] || 0, + status: issue.fields?.status?.name || 'Unknown', + reporter: issue.fields?.reporter?.displayName || null + })).filter((issue) => !issue.estimate); +} + +export async function updateIssueEstimate(jiraAccountId, issueIdOrKey, estimate, boardId) { + await jiraFetch(jiraAccountId, `/rest/agile/1.0/issue/${issueIdOrKey}/estimation?boardId=${boardId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: String(estimate) }) + }); +} + +export async function getProjects(jiraAccountId) { + const response = await jiraFetch( + jiraAccountId, + '/rest/api/3/project/search?maxResults=100&orderBy=name' + ); + return (response?.values || []).map((project) => ({ + id: project.id, + key: project.key, + name: project.name, + avatarUrl: project.avatarUrls?.['24x24'] || null + })); +} + +export async function getBoards(jiraAccountId, projectKeyOrId) { + const response = await jiraFetch( + jiraAccountId, + `/rest/agile/1.0/board?projectKeyOrId=${encodeURIComponent(projectKeyOrId)}&maxResults=100` + ); + return (response?.values || []) + .filter((board) => board.type === 'scrum' || board.type === 'simple') + .map((board) => ({ + id: board.id, + name: board.name, + type: board.type, + projectKey: board.location?.projectKey || null, + projectName: board.location?.projectName || null + })); +} + +export async function getBoardSprints(jiraAccountId, boardId) { + const response = await jiraFetch( + jiraAccountId, + `/rest/agile/1.0/board/${boardId}/sprint?state=active,future` + ); + return (response?.values || []).map((sprint) => ({ + id: sprint.id, + name: sprint.name, + state: sprint.state, + startDate: sprint.startDate || null, + endDate: sprint.endDate || null + })); +} diff --git a/backend/src/services/pokerService.js b/backend/src/services/pokerService.js new file mode 100644 index 0000000..ab62d00 --- /dev/null +++ b/backend/src/services/pokerService.js @@ -0,0 +1,250 @@ +import crypto from 'crypto'; +import { kvSessions, kvSessionIndex, withCasRetry } from '../lib/nats.js'; + +const FIBONACCI = [0, 0.5, 1, 2, 3, 5, 8, 13, 20, 40, 100]; + +function parseVote(vote) { + if (vote === null || vote === undefined) return { rawValue: '', numericValue: null }; + if (typeof vote === 'number') return { rawValue: String(vote), numericValue: vote }; + if (typeof vote === 'string') { + const normalized = vote.trim(); + const numeric = Number(normalized); + return { + rawValue: normalized, + numericValue: Number.isFinite(numeric) ? numeric : null + }; + } + return { rawValue: String(vote), numericValue: null }; +} + +function nearestFibonacci(value) { + return FIBONACCI.reduce((best, candidate) => + Math.abs(candidate - value) < Math.abs(best - value) ? candidate : best + ); +} + +function serializeSession(session) { + return { + ...session, + participants: Object.fromEntries(session.participants), + votes: Object.fromEntries(session.votes) + }; +} + +function deserializeSession(data) { + if (!data) return null; + data.participants = new Map(Object.entries(data.participants)); + data.votes = new Map(Object.entries(data.votes)); + return data; +} + +async function getSession(sessionId) { + const entry = await kvSessions.get(sessionId); + if (!entry) return null; + return deserializeSession(entry.json()); +} + +async function putSession(session) { + await kvSessions.put(session.id, JSON.stringify(serializeSession(session))); +} + +async function withSessionCas(sessionId, transformFn) { + return withCasRetry(kvSessions, sessionId, (raw) => { + const session = deserializeSession(raw); + if (!session) return undefined; + const result = transformFn(session); + if (result === undefined) return undefined; + return serializeSession(result); + }); +} + +function getSnapshot(session) { + // Accept both Map-based and plain-object sessions + const participantsMap = session.participants instanceof Map + ? session.participants + : new Map(Object.entries(session.participants)); + const votesMap = session.votes instanceof Map + ? session.votes + : new Map(Object.entries(session.votes)); + + const participants = [...participantsMap.values()]; + const votes = Object.fromEntries(votesMap); + const votedUserKeys = [...votesMap.keys()]; + + return { + session: { + id: session.id, + issueKey: session.issueKey, + issueId: session.issueId, + issueTitle: session.issueTitle, + boardId: session.boardId, + state: session.state, + averageEstimate: session.averageEstimate, + suggestedEstimate: session.suggestedEstimate, + savedEstimate: session.savedEstimate + }, + participants, + votesByUser: votes, + voteCount: votesMap.size, + participantCount: participantsMap.size, + votedUserKeys + }; +} + +function issueIndexKey(cloudId, issueKey) { + return `${cloudId}.${issueKey}`; +} + +export async function createScopedSession({ issueKey, issueId, issueTitle, roomId, boardId, tenantCloudId }) { + // Check for existing active session via issue index + const indexEntry = await kvSessionIndex.get(issueIndexKey(tenantCloudId, issueKey)); + if (indexEntry) { + const existingId = indexEntry.string(); + const existing = await getSession(existingId); + if (existing && existing.tenantCloudId === tenantCloudId) { + if (existing.state === 'VOTING') { + return getSnapshot(existing); + } + // Clean up stale revealed/saved sessions + await kvSessions.delete(existingId); + await kvSessionIndex.delete(issueIndexKey(tenantCloudId, issueKey)); + } + } + + const id = crypto.randomUUID(); + const session = { + id, + issueKey, + issueId, + issueTitle, + roomId, + boardId, + tenantCloudId, + createdAt: Date.now(), + state: 'VOTING', + participants: new Map(), + votes: new Map(), + averageEstimate: null, + suggestedEstimate: null, + savedEstimate: null + }; + await putSession(session); + await kvSessionIndex.put(issueIndexKey(tenantCloudId, issueKey), id); + return getSnapshot(session); +} + +export async function getSessionSnapshot(sessionId) { + const session = await getSession(sessionId); + if (!session) return null; + return getSnapshot(session); +} + +export async function canAccessSession(sessionId, tenantCloudId) { + const session = await getSession(sessionId); + if (!session) return false; + return session.tenantCloudId === tenantCloudId; +} + +export async function isSessionParticipant(sessionId, userKey) { + const session = await getSession(sessionId); + if (!session) return false; + return session.participants.has(userKey); +} + +export async function joinSession({ sessionId, tenantCloudId, userKey, userName, avatarUrl }) { + const result = await withSessionCas(sessionId, (session) => { + if (session.tenantCloudId !== tenantCloudId) return undefined; + session.participants.set(userKey, { userKey, userName, avatarUrl }); + session.votes.delete(userKey); + return session; + }); + if (!result) return null; + return getSnapshot(result); +} + +export async function leaveSession({ sessionId, tenantCloudId, userKey }) { + const result = await withSessionCas(sessionId, (session) => { + if (session.tenantCloudId !== tenantCloudId) return undefined; + session.participants.delete(userKey); + session.votes.delete(userKey); + return session; + }); + if (!result) return null; + return getSnapshot(result); +} + +export async function submitVote({ sessionId, tenantCloudId, userKey, vote }) { + const result = await withSessionCas(sessionId, (session) => { + if (session.state === 'REVEALED' || session.state === 'SAVED') return undefined; + if (session.state !== 'VOTING') return undefined; + if (session.tenantCloudId !== tenantCloudId) return undefined; + if (!session.participants.has(userKey)) return undefined; + const parsed = parseVote(vote); + session.votes.set(userKey, parsed.rawValue); + return session; + }); + if (!result) { + // Return current snapshot for REVEALED/SAVED states + const current = await getSession(sessionId); + if (current && (current.state === 'REVEALED' || current.state === 'SAVED')) { + return getSnapshot(current); + } + return null; + } + return getSnapshot(result); +} + +export async function revealIfComplete(sessionId) { + const result = await withSessionCas(sessionId, (session) => { + const allVoted = session.participants.size > 0 && + session.votes.size === session.participants.size; + + if (!allVoted) return undefined; // no mutation needed + + const numericVotes = [...session.votes.values()] + .map(Number) + .filter(Number.isFinite); + + const average = numericVotes.length + ? numericVotes.reduce((sum, v) => sum + v, 0) / numericVotes.length + : 0; + + const suggestedEstimate = nearestFibonacci(average); + + session.state = 'REVEALED'; + session.averageEstimate = average; + session.suggestedEstimate = suggestedEstimate; + return session; + }); + + if (!result) { + // Not all voted — return current snapshot + const current = await getSession(sessionId); + if (!current) return null; + return { ...getSnapshot(current), allVoted: false }; + } + + const snapshot = getSnapshot(result); + return { + ...snapshot, + allVoted: true, + average: snapshot.session.averageEstimate, + suggestedEstimate: snapshot.session.suggestedEstimate + }; +} + +export async function saveScopedEstimate({ sessionId, estimate, tenantCloudId, userKey }) { + const session = await getSession(sessionId); + if (!session) return null; + if (session.tenantCloudId !== tenantCloudId) return null; + if (!session.participants.has(userKey)) return null; + + session.savedEstimate = estimate; + session.state = 'SAVED'; + + const snapshot = getSnapshot(session); + // Clean up — session is done + await kvSessions.delete(sessionId); + await kvSessionIndex.delete(issueIndexKey(tenantCloudId, session.issueKey)); + return snapshot; +} diff --git a/backend/src/services/roomService.js b/backend/src/services/roomService.js new file mode 100644 index 0000000..b666cf9 --- /dev/null +++ b/backend/src/services/roomService.js @@ -0,0 +1,124 @@ +import crypto from 'crypto'; +import { kvRooms, kvSprintIndex, kvActiveRooms, withCasRetry } from '../lib/nats.js'; + +function assertCloudId(cloudId) { + if (!cloudId) { + throw new Error('Missing Jira tenant (cloudId). Please reconnect Jira.'); + } +} + +function roomKey(cloudId, id) { return `${cloudId}.${id}`; } +function sprintKey(cloudId, sprintId) { return `${cloudId}.${sprintId}`; } + +export async function createRoom(payload) { + const { + cloudId, + boardId, + projectKey, + projectName, + sprintId, + sprintName, + createdByAccountId, + createdByName + } = payload; + assertCloudId(cloudId); + + // Return existing room for this sprint if one exists + try { + const existingEntry = await kvSprintIndex.get(sprintKey(cloudId, sprintId)); + if (existingEntry) { + const existingId = existingEntry.string(); + if (existingId) { + const roomEntry = await kvRooms.get(roomKey(cloudId, existingId)); + if (roomEntry) return roomEntry.json(); + } + } + } catch { + // Stale index data — continue to create a new room + } + + const id = crypto.randomUUID(); + const room = { + id, + cloudId, + boardId, + projectKey, + projectName, + sprintId, + sprintName, + createdByAccountId, + createdByName + }; + + await kvRooms.put(roomKey(cloudId, id), JSON.stringify(room)); + await kvSprintIndex.put(sprintKey(cloudId, sprintId), id); + + // Update active rooms set with CAS retry + await withCasRetry(kvActiveRooms, cloudId, (activeIds) => { + const ids = activeIds || []; + if (ids.includes(id)) return undefined; // already present, no-op + return [...ids, id]; + }); + + return room; +} + +export async function getActiveRooms(cloudId) { + assertCloudId(cloudId); + + const activeEntry = await kvActiveRooms.get(cloudId); + if (!activeEntry) return []; + + const ids = activeEntry.json(); + if (!ids.length) return []; + + const staleIds = []; + const parsedRooms = []; + + for (const id of ids) { + if (!id) { staleIds.push(id); continue; } + try { + const entry = await kvRooms.get(roomKey(cloudId, id)); + if (!entry) { + staleIds.push(id); + continue; + } + parsedRooms.push(entry.json()); + } catch { + staleIds.push(id); + } + } + + if (staleIds.length) { + await withCasRetry(kvActiveRooms, cloudId, (current) => { + const currentIds = current || []; + return currentIds.filter((id) => !staleIds.includes(id)); + }); + } + + return parsedRooms; +} + +export async function getRoomById(roomId, cloudId) { + assertCloudId(cloudId); + const entry = await kvRooms.get(roomKey(cloudId, roomId)); + return entry ? entry.json() : null; +} + +export async function deleteRoom(roomId, cloudId) { + assertCloudId(cloudId); + const entry = await kvRooms.get(roomKey(cloudId, roomId)); + if (!entry) return; + + const room = entry.json(); + await kvRooms.delete(roomKey(cloudId, roomId)); + await kvSprintIndex.delete(sprintKey(cloudId, room.sprintId)); + + // Remove from active rooms set with CAS retry + await withCasRetry(kvActiveRooms, cloudId, (activeIds) => { + if (!activeIds) return undefined; + const filtered = activeIds.filter((id) => id !== roomId); + if (filtered.length === activeIds.length) return undefined; // not found, no-op + return filtered; + }); +} diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..3dd8142 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:4010/api diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..6f47842 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Pokerface + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..6ef9349 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2375 @@ +{ + "name": "pokerface-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pokerface-frontend", + "version": "0.1.0", + "dependencies": { + "@tailwindcss/vite": "^4.2.1", + "highlight.js": "^11.11.1", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "socket.io-client": "^4.8.1", + "tailwindcss": "^4.2.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^5.0.4", + "vite": "^7.1.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d31c900 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "pokerface-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.2.1", + "highlight.js": "^11.11.1", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "socket.io-client": "^4.8.1", + "tailwindcss": "^4.2.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^5.0.4", + "vite": "^7.1.7" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..c88bc7f --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,5 @@ + + + + PF + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..3449648 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,137 @@ +import { useEffect, useState } from 'react'; +import LoginScreen from './components/LoginScreen'; +import Lobby from './components/Lobby'; +import Room from './components/Room'; +import LegalPage from './components/LegalPage'; +import { api } from './services/api'; + +function useDarkMode() { + const [dark, setDark] = useState(() => { + const saved = localStorage.getItem('pokerface-dark'); + if (saved !== null) return saved === 'true'; + return window.matchMedia('(prefers-color-scheme: dark)').matches; + }); + + useEffect(() => { + document.documentElement.classList.toggle('dark', dark); + localStorage.setItem('pokerface-dark', dark); + }, [dark]); + + return [dark, () => setDark((d) => !d)]; +} + +export default function App() { + const [view, setView] = useState('loading'); + const [user, setUser] = useState(null); + const [activeRoom, setActiveRoom] = useState(null); + const [prevView, setPrevView] = useState('login'); + const [dark, toggleDark] = useDarkMode(); + + useEffect(() => { + checkAuth(); + }, []); + + async function checkAuth() { + const params = new URLSearchParams(window.location.search); + if (params.get('auth') === 'error') { + const message = params.get('message') || 'Authentication failed.'; + console.error('[auth] OAuth callback error:', message); + window.history.replaceState({}, '', window.location.pathname); + setView('login'); + return; + } + if (params.has('auth')) { + window.history.replaceState({}, '', window.location.pathname); + } + + try { + const me = await api.getMe(); + setUser(me); + + // Restore room from URL if present + const roomId = new URLSearchParams(window.location.search).get('room'); + if (roomId) { + try { + const { room } = await api.getRoom(roomId); + if (room) { + setActiveRoom(room); + setView('room'); + return; + } + } catch { + // room gone, fall through to lobby + window.history.replaceState({}, '', window.location.pathname); + } + } + + setView('lobby'); + } catch { + setView('login'); + } + } + + async function handleLogout() { + try { + await api.logout(); + } catch { + // ignore + } + setUser(null); + setActiveRoom(null); + setView('login'); + window.history.replaceState({}, '', window.location.pathname); + } + + if (view === 'loading') { + return ( +
+

Loading...

+
+ ); + } + + function showLegal(page) { + setPrevView(view); + setView(`legal-${page}`); + } + + if (view.startsWith('legal-')) { + const page = view.replace('legal-', ''); + return setView(prevView)} />; + } + + if (view === 'login') { + return ; + } + + if (view === 'room' && activeRoom) { + return ( + { + setActiveRoom(null); + setView('lobby'); + window.history.replaceState({}, '', window.location.pathname); + }} + /> + ); + } + + return ( + { + setActiveRoom(room); + setView('room'); + window.history.replaceState({}, '', `?room=${room.id}`); + }} + onLogout={handleLogout} + onShowLegal={showLegal} + /> + ); +} diff --git a/frontend/src/components/AdfRenderer.jsx b/frontend/src/components/AdfRenderer.jsx new file mode 100644 index 0000000..aecb3a9 --- /dev/null +++ b/frontend/src/components/AdfRenderer.jsx @@ -0,0 +1,194 @@ +import React, { useEffect, useRef } from 'react'; +import hljs from 'highlight.js/lib/core'; +import javascript from 'highlight.js/lib/languages/javascript'; +import typescript from 'highlight.js/lib/languages/typescript'; +import python from 'highlight.js/lib/languages/python'; +import java from 'highlight.js/lib/languages/java'; +import sql from 'highlight.js/lib/languages/sql'; +import json from 'highlight.js/lib/languages/json'; +import xml from 'highlight.js/lib/languages/xml'; +import css from 'highlight.js/lib/languages/css'; +import bash from 'highlight.js/lib/languages/bash'; +import csharp from 'highlight.js/lib/languages/csharp'; +import yaml from 'highlight.js/lib/languages/yaml'; +import 'highlight.js/styles/github-dark.min.css'; + +hljs.registerLanguage('javascript', javascript); +hljs.registerLanguage('js', javascript); +hljs.registerLanguage('typescript', typescript); +hljs.registerLanguage('ts', typescript); +hljs.registerLanguage('python', python); +hljs.registerLanguage('py', python); +hljs.registerLanguage('java', java); +hljs.registerLanguage('sql', sql); +hljs.registerLanguage('json', json); +hljs.registerLanguage('xml', xml); +hljs.registerLanguage('html', xml); +hljs.registerLanguage('css', css); +hljs.registerLanguage('bash', bash); +hljs.registerLanguage('sh', bash); +hljs.registerLanguage('shell', bash); +hljs.registerLanguage('csharp', csharp); +hljs.registerLanguage('c#', csharp); +hljs.registerLanguage('yaml', yaml); + +function CodeBlock({ code, language }) { + const elRef = useRef(null); + + useEffect(() => { + if (!elRef.current) return; + const codeEl = elRef.current.querySelector('code'); + if (!codeEl) return; + + // Reset for re-highlight + codeEl.removeAttribute('data-highlighted'); + codeEl.className = language && hljs.getLanguage(language) ? `language-${language}` : ''; + codeEl.textContent = code; + + try { + hljs.highlightElement(codeEl); + } catch { + // fallback: no highlighting + } + }, [code, language]); + + return ( +
+      {code}
+    
+ ); +} + +function parseWikiInline(text) { + const parts = []; + const regex = /\*([^*\n]+)\*|_([^_\n]+)_|-([^-\n]+)-|\+([^+\n]+)\+|\{\{([^}]*?)\}\}|\[([^|\]\n]+)\|([^\]\n]+?)(?:\|[^\]]*?)?\]|\[(https?:\/\/[^\]\s]+)\]/g; + let lastIndex = 0; + let match; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index)); + + if (match[1] != null) { + parts.push({match[1]}); + } else if (match[2] != null) { + parts.push({match[2]}); + } else if (match[3] != null) { + parts.push({match[3]}); + } else if (match[4] != null) { + parts.push({match[4]}); + } else if (match[5] != null) { + parts.push({match[5]}); + } else if (match[6] != null && match[7] != null) { + parts.push({match[6]}); + } else if (match[8] != null) { + parts.push({match[8]}); + } + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) parts.push(text.slice(lastIndex)); + return parts.length === 0 ? [text] : parts; +} + +function renderWikiMarkup(text) { + const elements = []; + + // Extract code blocks first + const segments = []; + const codeRegex = /\{(code|noformat)(?::([^}]*))?\}([\s\S]*?)\{\1\}/g; + let lastIdx = 0; + let m; + while ((m = codeRegex.exec(text)) !== null) { + if (m.index > lastIdx) segments.push({ type: 'text', value: text.slice(lastIdx, m.index) }); + segments.push({ type: 'code', value: m[3], language: m[2] || '' }); + lastIdx = m.index + m[0].length; + } + if (lastIdx < text.length) segments.push({ type: 'text', value: text.slice(lastIdx) }); + + for (const seg of segments) { + if (seg.type === 'code') { + elements.push(); + continue; + } + + const lines = seg.value.split('\n'); + let i = 0; + while (i < lines.length) { + const line = lines[i]; + + const headingMatch = line.match(/^h([1-6])\.\s+(.*)/); + if (headingMatch) { + const level = headingMatch[1]; + const Tag = `h${level}`; + const sizes = { 1: 'text-xl font-bold mb-3 mt-4', 2: 'text-lg font-bold mb-2 mt-3', 3: 'text-base font-semibold mb-2 mt-3', 4: 'text-sm font-semibold mb-1 mt-2', 5: 'text-xs font-semibold mb-1 mt-2', 6: 'text-xs font-semibold mb-1 mt-1' }; + elements.push({parseWikiInline(headingMatch[2])}); + i++; continue; + } + + if (line.match(/^----?\s*$/)) { + elements.push(
); + i++; continue; + } + + const bqMatch = line.match(/^bq\.\s+(.*)/); + if (bqMatch) { + elements.push( +
+

{parseWikiInline(bqMatch[1])}

+
+ ); + i++; continue; + } + + if (line.match(/^\*+\s+/)) { + const items = []; + while (i < lines.length && lines[i].match(/^\*+\s+/)) { + const lm = lines[i].match(/^(\*+)\s+(.*)/); + items.push({ depth: lm[1].length, text: lm[2] }); + i++; + } + elements.push( +
    + {items.map((item, j) =>
  • {parseWikiInline(item.text)}
  • )} +
+ ); + continue; + } + + if (line.match(/^#+\s+/)) { + const items = []; + while (i < lines.length && lines[i].match(/^#+\s+/)) { + const lm = lines[i].match(/^(#+)\s+(.*)/); + items.push({ depth: lm[1].length, text: lm[2] }); + i++; + } + elements.push( +
    + {items.map((item, j) =>
  1. {parseWikiInline(item.text)}
  2. )} +
+ ); + continue; + } + + if (line.trim() === '') { i++; continue; } + + elements.push(

{parseWikiInline(line)}

); + i++; + } + } + + return elements; +} + +export default function AdfRenderer({ document, className = '', fallback = '' }) { + if (!document) { + return fallback ?

{fallback}

: null; + } + + if (typeof document === 'string') { + return
{renderWikiMarkup(document)}
; + } + + return fallback ?

{fallback}

: null; +} diff --git a/frontend/src/components/DarkModeToggle.jsx b/frontend/src/components/DarkModeToggle.jsx new file mode 100644 index 0000000..de44d70 --- /dev/null +++ b/frontend/src/components/DarkModeToggle.jsx @@ -0,0 +1,19 @@ +export default function DarkModeToggle({ dark, toggleDark, className = '' }) { + return ( + + ); +} diff --git a/frontend/src/components/LegalPage.jsx b/frontend/src/components/LegalPage.jsx new file mode 100644 index 0000000..02d7f53 --- /dev/null +++ b/frontend/src/components/LegalPage.jsx @@ -0,0 +1,239 @@ +export default function LegalPage({ page, dark, onBack }) { + return ( +
+
+ + + POKERFACE + +
+ +
+ {page === 'terms' && } + {page === 'privacy' && } + {page === 'support' && } +
+
+ ); +} + +function Heading({ children, dark }) { + return

{children}

; +} + +function SubHeading({ children, dark }) { + return

{children}

; +} + +function P({ children, dark }) { + return

{children}

; +} + +function Li({ children, dark }) { + return
  • {children}
  • ; +} + +function TermsOfService({ dark }) { + return ( + <> + Terms of Service +

    Last updated: February 2026

    + + 1. Acceptance +

    + By accessing or using Pokerface ("the Service"), you agree to these terms. + If you do not agree, do not use the Service. +

    + + 2. Description +

    + Pokerface is a free sprint planning poker tool that integrates with Atlassian Jira. + It is provided as a convenience for agile teams to facilitate estimation sessions. +

    + + 3. No Warranty +

    + THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND, + WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND + NON-INFRINGEMENT. NO ADVICE OR INFORMATION, WHETHER ORAL OR WRITTEN, OBTAINED FROM + THE SERVICE SHALL CREATE ANY WARRANTY NOT EXPRESSLY STATED HEREIN. +

    + + 4. Limitation of Liability +

    + TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL THE DEVELOPERS, + OPERATORS, OR CONTRIBUTORS OF POKERFACE BE LIABLE FOR ANY INDIRECT, INCIDENTAL, + SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS, DATA, USE, OR + GOODWILL, HOWEVER CAUSED AND UNDER ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY, OR OTHERWISE, ARISING OUT OF OR IN + CONNECTION WITH YOUR ACCESS TO OR USE OF (OR INABILITY TO USE) THE SERVICE. +

    +

    + THE TOTAL AGGREGATE LIABILITY OF THE SERVICE OPERATORS FOR ALL CLAIMS RELATING TO + THE SERVICE SHALL NOT EXCEED ZERO EUROS (EUR 0.00). +

    + + 5. No Guarantee of Availability +

    + The Service may be modified, suspended, or discontinued at any time without notice. + We are under no obligation to maintain, support, or update the Service. +

    + + 6. User Responsibilities +

    + You are responsible for your use of the Service and any data you transmit through it. + You must comply with Atlassian's terms of service when using Jira integration features. +

    + + 7. Third-Party Services +

    + Pokerface integrates with Atlassian Jira via OAuth. Your use of Jira is governed by + Atlassian's own terms and privacy policy. We are not responsible for any third-party + service availability or behavior. +

    + + 8. Changes to Terms +

    + These terms may be updated at any time. Continued use of the Service after changes + constitutes acceptance of the revised terms. +

    + + ); +} + +function PrivacyPolicy({ dark }) { + return ( + <> + Privacy Policy +

    Last updated: February 2026

    + + 1. What Data We Collect +

    + When you sign in with Jira, we receive the following information from Atlassian via OAuth: +

    +
      +
    • Jira account ID — your unique Atlassian identifier
    • +
    • Display name — your Jira profile name
    • +
    • Avatar URL — a link to your Jira profile picture
    • +
    • Email address — your Jira account email
    • +
    • Cloud ID and site URL — identifies your Jira workspace
    • +
    • OAuth tokens — access and refresh tokens for Jira API calls
    • +
    +

    + During poker sessions, we temporarily store: +

    +
      +
    • Room and session metadata (project name, sprint name, issue keys)
    • +
    • Participant names and avatar URLs
    • +
    • Votes submitted during estimation sessions
    • +
    + + 2. How We Use Your Data +

    + Your data is used solely to operate the poker planning functionality: + authenticating you with Jira, displaying participants in sessions, recording votes, + and writing agreed estimates back to Jira issues. We do not use your data for + analytics, advertising, profiling, or any other purpose. +

    + + 3. Data Storage and Retention +

    + All data is stored in NATS JetStream key-value buckets with automatic time-to-live (TTL) expiration: +

    +
      +
    • OAuth connections — automatically deleted after 24 hours
    • +
    • Rooms and sessions — automatically deleted after 24 hours
    • +
    • OAuth state tokens — automatically deleted after 10 minutes
    • +
    +

    + There is no long-term database. All session data is ephemeral and automatically purged + by TTL. When a poker session is saved, the session data is deleted immediately. +

    + + 4. Cookies +

    + Pokerface uses a single, strictly functional cookie: +

    +
      +
    • + pokerface_session — an HttpOnly, Secure, SameSite=Lax JWT cookie + that contains your Jira account ID, cloud ID, display name, and avatar URL. It + expires after 24 hours. This cookie is required for the application to function. +
    • +
    +

    + We do not use tracking cookies, analytics cookies, or any third-party cookies. + A dark mode preference is stored in your browser's localStorage (not a cookie) + and never sent to our servers. +

    + + 5. Data Sharing +

    + We do not sell, share, or transfer your personal data to any third party. + The only external communication is between our backend and Atlassian's Jira API, + using the OAuth tokens you authorized, to read project/sprint data and write + estimates back to issues. +

    + + 6. Data Security +

    + All traffic is encrypted via HTTPS in production. Session cookies are marked HttpOnly + and Secure. Security headers (HSTS, X-Frame-Options DENY, nosniff) are applied to all + responses. OAuth tokens are stored server-side only and never exposed to the browser. +

    + + 7. Your Rights +

    + Since all data expires automatically within 24 hours, there is no persistent personal + data to request deletion of. You can sign out at any time to clear your session cookie. + Revoking the Pokerface OAuth connection in your Atlassian account settings will + invalidate all stored tokens. +

    + + ); +} + +function Support({ dark }) { + return ( + <> + Support +

    Last updated: February 2026

    + + About Pokerface +

    + Pokerface is a free, open tool for sprint planning poker with Jira integration. + It is provided as-is, with no guarantees of availability, support, or maintenance. +

    + + No Formal Support +

    + This product does not come with dedicated support, SLAs, or guaranteed response times. + There is no helpdesk, ticketing system, or support team. +

    + + Best-Effort Assistance +

    + If you encounter a bug or issue, you may reach out via the project's repository. + Any assistance is provided on a best-effort basis at the maintainer's discretion. +

    + + Alternatives +

    + If Pokerface does not meet your needs, there are many alternative planning poker tools + available in the Atlassian Marketplace and elsewhere. You are free to stop using + Pokerface at any time — simply sign out and revoke the OAuth connection in your + Atlassian account settings. +

    + + ); +} diff --git a/frontend/src/components/Lobby.jsx b/frontend/src/components/Lobby.jsx new file mode 100644 index 0000000..e4d9a2b --- /dev/null +++ b/frontend/src/components/Lobby.jsx @@ -0,0 +1,269 @@ +import { useEffect, useState } from 'react'; +import { api } from '../services/api'; +import SearchSelect from './SearchSelect'; +import DarkModeToggle from './DarkModeToggle'; + +export default function Lobby({ user, dark, toggleDark, onEnterRoom, onLogout, onShowLegal }) { + const [rooms, setRooms] = useState([]); + const [showCreate, setShowCreate] = useState(false); + const [projects, setProjects] = useState([]); + const [boards, setBoards] = useState([]); + const [sprints, setSprints] = useState([]); + const [selectedProject, setSelectedProject] = useState(null); + const [selectedBoard, setSelectedBoard] = useState(null); + const [selectedSprint, setSelectedSprint] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); + + useEffect(() => { + loadRooms(); + }, []); + + async function loadRooms() { + try { + const { rooms: activeRooms } = await api.getRooms(); + setRooms(activeRooms || []); + } catch (_err) { + // ignore + } + } + + async function handlePlanSession() { + setShowCreate(true); + setError(''); + try { + const { projects: projectList } = await api.getProjects(); + setProjects(projectList || []); + } catch (err) { + setError(err.message); + } + } + + async function handleProjectChange(projectKey) { + const project = projects.find((p) => p.key === projectKey); + setSelectedProject(project || null); + setSelectedBoard(null); + setSelectedSprint(null); + setBoards([]); + setSprints([]); + + if (!projectKey) return; + + try { + const { boards: boardList } = await api.getBoards(projectKey); + const list = boardList || []; + setBoards(list); + if (list.length === 1) { + const board = list[0]; + setSelectedBoard(board); + try { + const { sprints: sprintList } = await api.getBoardSprints(board.id); + setSprints(sprintList || []); + } catch (err) { + setError(err.message); + } + } + } catch (err) { + setError(err.message); + } + } + + async function handleBoardChange(boardId) { + const board = boards.find((b) => b.id === Number(boardId)); + setSelectedBoard(board || null); + setSelectedSprint(null); + setSprints([]); + + if (!boardId) return; + + try { + const { sprints: sprintList } = await api.getBoardSprints(boardId); + setSprints(sprintList || []); + } catch (err) { + setError(err.message); + } + } + + async function handleCreateRoom() { + if (!selectedProject || !selectedBoard || !selectedSprint) return; + setLoading(true); + setError(''); + try { + const { room } = await api.createRoom({ + boardId: selectedBoard.id, + projectKey: selectedProject.key, + projectName: selectedProject.name, + sprintId: selectedSprint.id, + sprintName: selectedSprint.name + }); + onEnterRoom(room); + } catch (err) { + setError(err.message); + setLoading(false); + } + } + + const projectOptions = projects.map((p) => ({ value: p.key, label: `${p.name} (${p.key})` })); + const boardOptions = boards.map((b) => ({ value: b.id, label: b.name })); + const sprintOptions = sprints.map((s) => ({ value: s.id, label: `${s.name} (${s.state})` })); + + return ( +
    + {/* Header */} +
    +
    + POKERFACE + / + Lobby +
    +
    + + {user.avatarUrl && ( + + )} + {user.displayName} + +
    +
    + + {/* Content */} +
    +
    +

    Active Rooms

    + +
    + + {error &&

    {error}

    } + + {showCreate && ( +
    +
    + Project + handleProjectChange(val)} + placeholder="Select a project..." + /> +
    + + {boards.length > 1 && ( +
    + Board + handleBoardChange(val)} + placeholder="Select a board..." + /> +
    + )} + + {sprints.length > 0 && ( +
    + Sprint + { + const s = sprints.find((sp) => sp.id === Number(val)); + setSelectedSprint(s || null); + }} + placeholder="Select a sprint..." + /> +
    + )} + + +
    + )} + +
    + {rooms.length === 0 ? ( +

    No active rooms. Create one to start estimating.

    + ) : ( + rooms.map((room, i) => ( +
    onEnterRoom(room)} + style={{ animationDelay: `${i * 60}ms` }} + className="animate-fade-up border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4 cursor-pointer flex flex-col gap-1.5 transition-all hover:border-emerald-400 hover:shadow-lg hover:shadow-emerald-500/5 hover:-translate-y-0.5 group relative" + > + {room.createdByAccountId === user.jiraAccountId && ( + + )} + + {room.projectName || room.projectKey} + + {room.sprintName} + Created by {room.createdByName} +
    + )) + )} +
    +
    + +
    + + · + + · + +
    + + {deleteTarget && ( +
    setDeleteTarget(null)}> +
    e.stopPropagation()} className="bg-white dark:bg-slate-800 w-72 p-5 flex flex-col gap-4 shadow-xl animate-fade-in"> +

    Delete Room

    +

    + Remove {deleteTarget.projectName || deleteTarget.projectKey} — {deleteTarget.sprintName}? +

    +
    + + +
    +
    +
    + )} +
    + ); +} diff --git a/frontend/src/components/LoginScreen.jsx b/frontend/src/components/LoginScreen.jsx new file mode 100644 index 0000000..b5a51a6 --- /dev/null +++ b/frontend/src/components/LoginScreen.jsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { api } from '../services/api'; +import DarkModeToggle from './DarkModeToggle'; + +export default function LoginScreen({ dark, toggleDark, onShowLegal }) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + async function handleLogin() { + setLoading(true); + setError(''); + try { + const { url } = await api.startJiraOAuth(); + window.location.href = url; + } catch (err) { + setError(err.message); + setLoading(false); + } + } + + return ( +
    + + + {/* Ambient glow */} +
    + +
    +
    +

    + POKERFACE +

    +
    +

    + Sprint planning poker for Jira teams +

    +
    + +
    + + {error &&

    {error}

    } +
    + +
    + + · + + · + +
    +
    +
    + ); +} diff --git a/frontend/src/components/PokerRoom.jsx b/frontend/src/components/PokerRoom.jsx new file mode 100644 index 0000000..ea095c5 --- /dev/null +++ b/frontend/src/components/PokerRoom.jsx @@ -0,0 +1,255 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { getSocket } from '../services/socket'; + +const CARDS = [ + { value: '?', label: '?', color: '#94a3b8' }, + { value: '☕', label: '☕', color: '#f59e0b' }, + { value: '0', label: '0', color: '#64748b' }, + { value: '0.5', label: '0.5', color: '#64748b' }, + { value: '1', label: '1', color: '#3b82f6' }, + { value: '2', label: '2', color: '#3b82f6' }, + { value: '3', label: '3', color: '#8b5cf6' }, + { value: '5', label: '5', color: '#8b5cf6' }, + { value: '8', label: '8', color: '#ec4899' }, + { value: '13', label: '13', color: '#ec4899' }, + { value: '20', label: '20', color: '#ef4444' }, + { value: '40', label: '40', color: '#ef4444' }, + { value: '100', label: '100', color: '#dc2626' } +]; + +export default function PokerRoom({ session, issue, user, onSaved }) { + const [participants, setParticipants] = useState([]); + const [votedUserKeys, setVotedUserKeys] = useState([]); + const [revealed, setRevealed] = useState(false); + const [votes, setVotes] = useState({}); + const [myVote, setMyVote] = useState(null); + const [suggestedEstimate, setSuggestedEstimate] = useState(null); + const [average, setAverage] = useState(null); + const [manualEstimate, setManualEstimate] = useState(''); + const [error, setError] = useState(''); + + const socket = useMemo(() => getSocket(), []); + const joinedRef = useRef(null); + + useEffect(() => { + function onParticipants(payload) { + if (payload.sessionId !== session.id) return; + setParticipants(payload.participants || []); + setVotedUserKeys(payload.votedUserKeys || []); + } + + function onVoteUpdate(payload) { + if (payload.sessionId !== session.id) return; + setVotedUserKeys(payload.votedUserKeys || []); + } + + function onRevealed(payload) { + if (payload.sessionId !== session.id) return; + setRevealed(true); + setVotes(payload.votes || {}); + setAverage(payload.average ?? null); + setSuggestedEstimate(payload.suggestedEstimate ?? null); + if (payload.suggestedEstimate !== undefined && payload.suggestedEstimate !== null) { + setManualEstimate(String(payload.suggestedEstimate)); + } + } + + function onSavedPayload(payload) { + if (payload.sessionId !== session.id) return; + onSaved(payload.estimate); + } + + function onError(payload) { + setError(payload?.error || 'Unexpected poker error'); + } + + socket.on('poker:participants', onParticipants); + socket.on('poker:vote-update', onVoteUpdate); + socket.on('poker:revealed', onRevealed); + socket.on('poker:saved', onSavedPayload); + socket.on('poker:error', onError); + + // Guard against React strict mode double-invoking effects + if (joinedRef.current !== session.id) { + joinedRef.current = session.id; + socket.emit('poker:join', { + sessionId: session.id, + userKey: user.key, + userName: user.name + }); + } + + return () => { + socket.off('poker:participants', onParticipants); + socket.off('poker:vote-update', onVoteUpdate); + socket.off('poker:revealed', onRevealed); + socket.off('poker:saved', onSavedPayload); + socket.off('poker:error', onError); + }; + }, [session.id, socket, user.key, user.name]); + + function handleVote(value) { + if (revealed) return; + setMyVote(value); + socket.emit('poker:vote', { + sessionId: session.id, + userKey: user.key, + vote: value + }); + } + + function handleSave() { + const estimate = Number(manualEstimate || suggestedEstimate); + if (!Number.isFinite(estimate)) { + setError('Pick a numeric estimate before saving.'); + return; + } + + socket.emit('poker:save', { + sessionId: session.id, + estimate + }); + } + + return ( +
    + {/* Cards or Results */} +
    +
    +

    + {revealed ? 'Results' : 'Choose Your Estimate'} +

    +
    +
    + {revealed ? ( +
    +
    +
    +
    Average
    +
    + {average?.toFixed(1) ?? '-'} +
    +
    +
    +
    Suggested
    +
    + {suggestedEstimate ?? '-'} +
    +
    +
    + + {/* Individual votes */} +
    + {Object.entries(votes).map(([userKey, value]) => { + const name = participants.find((p) => p.userKey === userKey)?.userName || userKey; + return ( + + {name}: {value} + + ); + })} +
    + + {/* Override + Save */} +
    + setManualEstimate(e.target.value)} + placeholder="Override" + className="flex-1 border border-slate-300 dark:border-slate-600 rounded-sm px-3 py-2 text-sm bg-white dark:bg-slate-800 dark:text-slate-100" + /> + +
    +
    + ) : ( +
    + {CARDS.map((card) => { + const isSelected = myVote === card.value; + return ( + + ); + })} +
    + )} +
    +
    + + {/* Participants */} +
    +
    +

    + Participants +

    +
    +
    +
    + {participants.map((participant) => { + const hasVoted = revealed + ? votes[participant.userKey] !== undefined + : votedUserKeys.includes(participant.userKey); + const vote = revealed ? votes[participant.userKey] : null; + const isCurrentUser = participant.userKey === user.key; + + return ( +
    +
    +
    + {participant.userName?.charAt(0)?.toUpperCase() || '?'} +
    + + {participant.userName} + {isCurrentUser && (you)} + +
    +
    + {revealed && vote !== null && vote !== undefined ? ( + {vote} + ) : hasVoted ? ( + + ) : ( + ... + )} +
    +
    + ); + })} +
    +
    +
    + + {error && ( +
    +

    {error}

    +
    + )} +
    + ); +} diff --git a/frontend/src/components/Room.jsx b/frontend/src/components/Room.jsx new file mode 100644 index 0000000..1f128c8 --- /dev/null +++ b/frontend/src/components/Room.jsx @@ -0,0 +1,210 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api } from '../services/api'; +import PokerRoom from './PokerRoom'; +import DarkModeToggle from './DarkModeToggle'; +import AdfRenderer from './AdfRenderer'; + +export default function Room({ room, user, dark, toggleDark, onBack }) { + const [issues, setIssues] = useState([]); + const [activeSession, setActiveSession] = useState(null); + const [done, setDone] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const issuesRef = useRef([]); + + const pokerUser = { key: user.jiraAccountId, name: user.displayName }; + + useEffect(() => { + loadIssues(); + }, [room.sprintId]); + + async function loadIssues() { + setLoading(true); + try { + const { issues: sprintIssues } = await api.getSprintIssues(room.sprintId, room.boardId); + const list = sprintIssues || []; + setIssues(list); + issuesRef.current = list; + startFirstUnestimated(list); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + function startFirstUnestimated(issueList) { + const issue = issueList.find((i) => !i.estimate || i.estimate === 0); + if (!issue) { + finishSession(); + return; + } + startSessionForIssue(issue); + } + + async function startSessionForIssue(issue) { + setError(''); + try { + const { session } = await api.startSession({ + issueKey: issue.key, + issueId: issue.id, + issueTitle: issue.title, + roomId: room.id, + boardId: room.boardId + }); + setActiveSession({ session, issue }); + } catch (err) { + setError(err.message); + } + } + + async function finishSession() { + setDone(true); + try { + await api.deleteRoom(room.id); + } catch { + // best-effort cleanup + } + } + + const activeSessionRef = useRef(null); + activeSessionRef.current = activeSession; + + const advanceToNext = useCallback((estimate) => { + if (!activeSessionRef.current) return; + + const currentKey = activeSessionRef.current.issue.key; + const updated = issuesRef.current.map((i) => + i.key === currentKey ? { ...i, estimate } : i + ); + issuesRef.current = updated; + setIssues(updated); + setActiveSession(null); + + const next = updated.find((i) => !i.estimate || i.estimate === 0); + if (!next) { + finishSession(); + return; + } + + setTimeout(() => startSessionForIssue(next), 300); + }, []); + + const estimatedCount = issues.filter((i) => i.estimate && i.estimate > 0).length; + const totalCount = issues.length; + + if (loading) { + return ( +
    +

    Loading sprint issues...

    +
    + ); + } + + if (done) { + return ( +
    +
    +

    Session Complete

    +

    + {estimatedCount} of {totalCount} issues estimated +

    +
      + {issues.map((issue) => ( +
    • +
      + {issue.key} +

      {issue.title}

      +
      + + {issue.estimate || 0} pts + +
    • + ))} +
    + +
    +
    + ); + } + + return ( +
    + {/* Header */} +
    +
    + POKERFACE + / + {room.projectName || room.projectKey} + + {room.sprintName} +
    +
    + + {estimatedCount}/{totalCount} estimated + +
    +
    + + {error && ( +
    +

    {error}

    +
    + )} + + {/* Split layout */} + {activeSession && ( +
    + {/* Left: Poker cards + participants */} +
    + +
    + + {/* Right: Issue details */} +
    +
    +
    + {activeSession.issue.key} +
    +
    +
    +

    + {activeSession.issue.title} +

    +
    + {activeSession.issue.description && ( +
    + +
    + )} +
    +
    + Reporter: {activeSession.issue.reporter || 'Unknown'} + Status: {activeSession.issue.status} +
    +
    +
    +
    + )} +
    + ); +} diff --git a/frontend/src/components/SearchSelect.jsx b/frontend/src/components/SearchSelect.jsx new file mode 100644 index 0000000..75911a6 --- /dev/null +++ b/frontend/src/components/SearchSelect.jsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from 'react'; + +export default function SearchSelect({ options, value, onChange, placeholder }) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [highlightIndex, setHighlightIndex] = useState(0); + const containerRef = useRef(null); + const inputRef = useRef(null); + + const filtered = options.filter((o) => + o.label.toLowerCase().includes(query.toLowerCase()) + ); + + const selectedLabel = options.find((o) => String(o.value) === String(value))?.label || ''; + + useEffect(() => { + function handleClickOutside(e) { + if (containerRef.current && !containerRef.current.contains(e.target)) { + setOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + useEffect(() => { + setHighlightIndex(0); + }, [query]); + + function handleOpen() { + setOpen(true); + setQuery(''); + setTimeout(() => inputRef.current?.focus(), 0); + } + + function selectOption(opt) { + onChange(opt.value); + setOpen(false); + setQuery(''); + } + + function handleKeyDown(e) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightIndex((i) => Math.min(i + 1, filtered.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (filtered[highlightIndex]) selectOption(filtered[highlightIndex]); + } else if (e.key === 'Escape') { + setOpen(false); + } + } + + return ( +
    + + + {open && ( +
    + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search..." + className="border-b border-slate-200 dark:border-slate-700 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 placeholder:text-slate-400 outline-none bg-transparent" + /> +
    + {filtered.length === 0 ? ( +
    No matches
    + ) : ( + filtered.map((opt, i) => ( +
    selectOption(opt)} + onMouseEnter={() => setHighlightIndex(i)} + className={`px-3 py-2 text-sm cursor-pointer transition-colors ${ + i === highlightIndex + ? 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300' + : 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700' + } ${String(opt.value) === String(value) ? 'font-semibold' : ''}`} + > + {opt.label} +
    + )) + )} +
    +
    + )} +
    + ); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..2b1eabf --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.jsx'; +import './styles.css'; + +createRoot(document.getElementById('root')).render( + + + +); diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js new file mode 100644 index 0000000..74d3060 --- /dev/null +++ b/frontend/src/services/api.js @@ -0,0 +1,55 @@ +const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; + +async function request(path, options = {}) { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}) + }, + ...options + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('UNAUTHORIZED'); + } + const text = await response.text(); + throw new Error(text || `Request failed: ${response.status}`); + } + + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + return response.json(); + } + return null; +} + +export const api = { + getMe: () => request('/jira/me'), + logout: () => request('/jira/logout', { method: 'POST' }), + startJiraOAuth: () => request('/jira/oauth/start'), + getProjects: () => request('/jira/projects'), + getBoards: (projectKeyOrId) => request(`/jira/boards?projectKeyOrId=${encodeURIComponent(projectKeyOrId)}`), + getBoardSprints: (boardId) => request(`/jira/boards/${encodeURIComponent(boardId)}/sprints`), + getSprintIssues: (sprintId, boardId) => request(`/jira/sprints/${encodeURIComponent(sprintId)}/issues${boardId ? `?boardId=${encodeURIComponent(boardId)}` : ''}`), + createRoom: (payload) => + request('/poker/rooms', { + method: 'POST', + body: JSON.stringify(payload) + }), + getRooms: () => request('/poker/rooms'), + getRoom: (roomId) => request(`/poker/rooms/${encodeURIComponent(roomId)}`), + deleteRoom: (roomId) => + request(`/poker/rooms/${encodeURIComponent(roomId)}`, { method: 'DELETE' }), + startSession: (payload) => + request('/poker/sessions', { + method: 'POST', + body: JSON.stringify(payload) + }), + getSession: (sessionId) => request(`/poker/sessions/${encodeURIComponent(sessionId)}`) +}; + +// When using same-origin (default), socketBaseUrl is undefined so io() connects to the current host +const explicit = import.meta.env.VITE_API_URL; +export const socketBaseUrl = explicit ? explicit.replace(/\/api$/, '') : undefined; diff --git a/frontend/src/services/socket.js b/frontend/src/services/socket.js new file mode 100644 index 0000000..be690ea --- /dev/null +++ b/frontend/src/services/socket.js @@ -0,0 +1,25 @@ +import { io } from 'socket.io-client'; +import { socketBaseUrl } from './api'; + +let socket; + +export function getSocket() { + if (!socket) { + const opts = { + autoConnect: true, + reconnection: true, + reconnectionAttempts: 10, + reconnectionDelay: 750 + }; + + if (socketBaseUrl) { + opts.withCredentials = true; + socket = io(socketBaseUrl, opts); + } else { + socket = io(opts); + } + +} + + return socket; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..8099409 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,58 @@ +@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Syne:wght@700;800&display=swap'); +@import "tailwindcss"; +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + --font-syne: 'Syne', sans-serif; +} + +@layer base { + body { + font-family: 'Outfit', sans-serif; + margin: 0; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + color: #0f172a; + background: #f0f1f5; + background-image: + radial-gradient(ellipse at 20% 0%, rgba(16, 185, 129, 0.045) 0%, transparent 50%), + radial-gradient(ellipse at 80% 100%, rgba(99, 102, 241, 0.03) 0%, transparent 50%); + transition: background-color 0.2s, color 0.2s; + } + + :root { + --card-border: #e2e8f0; + --card-text: #475569; + } + + .dark { + --card-border: #334155; + --card-text: #94a3b8; + } + + .dark body { + color: #e2e8f0; + background: #0f172a; + background-image: + radial-gradient(ellipse at 20% 0%, rgba(16, 185, 129, 0.06) 0%, transparent 50%), + radial-gradient(ellipse at 80% 100%, rgba(99, 102, 241, 0.04) 0%, transparent 50%); + } +} + +@keyframes fade-up { + from { opacity: 0; transform: translateY(14px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@utility animate-fade-up { + animation: fade-up 0.5s ease-out both; +} + +@utility animate-fade-in { + animation: fade-in 0.35s ease-out both; +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..87f9e3f --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + port: 5174, + allowedHosts: true, + proxy: { + '/api': { + target: 'http://localhost:4010', + changeOrigin: true + }, + '/socket.io': { + target: 'http://localhost:4010', + changeOrigin: true, + ws: true + } + } + } +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2e9c537 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,330 @@ +{ + "name": "pokerface", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pokerface", + "version": "0.1.0", + "hasInstallScript": true, + "devDependencies": { + "concurrently": "^9.1.2" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f58d3f3 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "pokerface", + "version": "0.1.0", + "private": true, + "scripts": { + "postinstall": "npm install --prefix backend && npm install --prefix frontend", + "dev": "concurrently -n be,fe -c blue,green \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", + "build": "npm run build --prefix frontend", + "start": "node backend/src/index.js" + }, + "devDependencies": { + "concurrently": "^9.1.2" + } +}